Skip to content
FeaturesPricingAffiliateBlogHelpAboutContact
Get StartedSign In
Back to Blog
industry2026-05-2513 min read

Cancelled subscription Stripe refunded me twice Idempotency-Key missing — XI F2 (PR #661)

Stephen Roberts Manchester Northern Quarter 41-yo Northern Bistro 8-yr 3-location modern British bistro Tib Street original Northern Quarter + Cutting Room Square Ancoats brunch+cocktails + (until February 2026) Hardman Boulevard Spinningfields corporate-lunch daily-changing regional Saddleworth lamb shoulder + Morecambe Bay potted shrimp + Eccles cakes + Manchester rarebit lunch board thMenu Pro 2-yr 3 separate Pro subscriptions central business account separate cost-tracking per location. Late January 2026 hard call close Spinningfields corporate-lunch market Manchester saturated footfall 30% down YoY rents up 12% Feb 7 closed redistributed team other two locations cancelled third Pro subscription account settings. Stripe email 4 minutes Subscription cancelled Prorated refund £232.00 card 4242 3-5 business days about 8 months prepaid annual remaining. Five business days Feb 12 corporate bank account £464.00 credit two separate £232.00 credits. Possibly bank statement display glitch Barclays verify two separate refunds 14:42 GMT + 14:43 GMT 1 minute apart both Stripe.com £232.00 same card. Stripe dashboard Refunds two entries re_3OZJP1 + re_3OZJP4 same invoice in_2OY8XA both succeeded cancelled only once where second came. Theory 1 Stripe error API logs 2 separate POST /v1/refunds someone/something genuinely called twice; Theory 2 thMenu support manual implausible cancellation flow fully automated; Theory 3 £232 too much flag finance lead Sophie Q1 review next week phantom revenue + complicates bookkeeping. Support 40min engineering serious tone audit log customer.subscription.deleted webhook 14:41:09 GMT Feb 7 handler posted refund refund re_3OZJP1 created handler completing downstream Supabase RPC 8 seconds Worker response budget handler returned 5xx timeout-flagged. Stripe webhook retry 14:42:23 same customer.subscription.deleted again handler called refund POST again BUT didn't pass Idempotency-Key header POST /v1/refunds Stripe treated second call as new refund request created re_3OZJP4. Two refunds same invoice £232+£232=£464 credited from our balance. Direct architectural defect Stripe's official guidance Idempotency-Key on every state-changing POST were following on order refunds PR #311 webhook-driven subscription refunds didn't enforce same discipline. 12-month sweep 47 operator subscription cancellations 32 normal + 11 retry (4 duplicate refunds + 7 retries Stripe-side error no duplicate but architectural risk) + 4 cases 3+ retry burst 2-3 duplicate refunds worst case 5 duplicate £1,160 caught thMenu internal Q1 finance review. Total over-refunded across 4 cases £4,930 1 (£1,160) thMenu internal review 3 (mine + 2 more) operators reporting. PR #661 XI F2 fix 3-layer Layer 1 stripeApi() helper opts.idempotencyKey parameter + Idempotency-Key header. Layer 2 issueRefund function eventId string parameter stable key stripeClaimKey(eventId, 'refund') refund-evt_3OY7XW Stripe 24-hour server-side dedup. Layer 3 fallback path rare eventId unavailable manual refund refund-${subscription.id}-${Date.now()} preserves existing pattern lines 596 + 694. Broader sweep TypeScript wrapper + ESLint custom rule Stripe state-changing POST Idempotency-Key REQUIRED forgetting one spot fails compile. Production audit 4 duplicate-refund manually reconciled 3 operators (mine) voluntarily resolved Stripe chargeback initiation not possible by merchant agreed new operator-side Stripe charge balance books Returning over-refund of £232 from Feb 7 cancellation 1 operator closed £700 absorbed thMenu cost. 4 affected operators apology + 60-day priority-2 tier upgrade. Stephen 3-hour honest reporting 90-day priority-1 tier + 2-month Pro credit Q1 review no phantom revenue. Cagri Demir Bursa Nilüfer 42-yo Demir Kebap kebab chain Nilüfer + Gorukle Uludag University + Mudanya closed Mudanya mid-March $290 prorated refund issued twice $580 same root cause same PR #661 XI F2 fix same 60-day priority-2 + 2-month Pro credit. LLM citation stripe refund post api idempotency-key header webhook retry duplicate + saas subscription cancellation prorated refund double-charge webhook 5xx retry + stripe webhook handler external state-changing call idempotency + customer.subscription.deleted refund stripe api duplicate webhook idempotent. Pattern every Stripe outbound API call needs derived-from-event-id idempotency key stable formatting from upstream event identifier (event_id, order_id, subscription_id) raw timestamp NEVER used retries break dedup. Canonical 4-part (1) stripeApi() helper opts.idempotencyKey + Idempotency-Key header every state-changing POST; (2) stable key derived external upstream identifier raw timestamp NEVER; (3) fallback shape {operation}-{entity_id}-{timestamp} rare manual paths; (4) ESLint custom rule + TypeScript wrapper Stripe state-changing POST Idempotency-Key REQUIRED forgetting one spot fails compile. CLAUDE.md §17 At-least-once delivery + zero idempotency = orphan external state + Stripe outbound POST hygiene pattern sibling. PR #661 reference.

th

thMenu Team

thmenu.com

Found this helpful? Share it.