Support had a refund tool with a sane-looking guard: read the order, compute refundable = captured - refunded, reject if the requested amount is higher. It worked for months.
Then two agents opened the same $100 order within a few seconds of each other and each refunded $60. Both requests passed the guard. Both went to the gateway. The gateway accepted both, because that acquirer checked refunds against the original capture per request, not against a running total. $120 went out on a $100 payment.
The bug was where "refunded" came from. We updated that column from the refund webhook, which arrives a few seconds after the API call. So the guard was reading a number that was always slightly in the past.
The fix was boring:
- Lock the order row (SELECT ... FOR UPDATE).
- Sum refunds in status pending AND succeeded, not just succeeded.
- Insert a pending refund row in the same transaction.
- Only then call the gateway, with the refund row id as the idempotency key.
- Webhook flips pending to succeeded or failed, and a failed row frees the amount.
The guard now reads state we wrote ourselves, not state the gateway reports later.
If your refund guard reads a field that a webhook updates, how are you handling the window between the API call and the event?
Payneteasy handles this kind of payment flow for merchants and PSPs — see how it works.
Top comments (0)