Stack guide
Stripe Connect refunds in Node and Express
The canonical Express refund route is five lines that work perfectly in demos and leak money in production — because production adds retries, queues and secondary entry points the demo never meets. This guide builds the route correctly, then walks through exactly how Node’s async semantics interact with Stripe’s delivery guarantees to create the races everyone hits.
The correct call, with both flags
Everything else in this guide exists to protect these lines:
await stripe.refunds.create({
charge: chargeId,
reverse_transfer: true,
refund_application_fee: true,
}, { idempotencyKey: `refund-${orderId}-${attempt}` });Retries: timeouts are not failures
axios timeouts and fetch aborts describe your patience, not Stripe’s outcome. The refund may have succeeded while you stopped waiting; a blind retry double-refunds. Intent-scoped idempotency keys make retries collapse onto original responses — scope them to order-plus-intent, never to request instances.
Queuing reversals safely
BullMQ workers processing reversal jobs need the same discipline plus live-state checks: keys expire after 24 hours, so long-delayed retries genuinely re-execute unless amount_reversed is read first. Persist Stripe’s response against the job for the support conversation that eventually asks.
Error mapping worth centralising
amount_greater_than_transfer_reversal means arithmetic, not infrastructure. Insufficient-funds-on-account means post-payout mechanics. Already-reversed means missing guards. Centralise interpretation once so every handler responds identically instead of each inventing retry policies.
Common questions
Does this require a FeeGuard integration?
No — the page stands alone as stack guidance. FeeGuard observes your event stream externally rather than embedding in it.
How do we test the race conditions locally?
Stripe CLI record/replay plus artificial delays between refund creation and transfer events reproduces ordering hazards deterministically enough for CI.