Practice · Payments · Card 4
The refunded event arrived first. Now what?
Webhooks aren't guaranteed to arrive in the order things happened. Your handler has to stay correct when the order is wrong.
The handler
# Real order at the provider:
# t=0 payment_intent.succeeded
# t=10s payment_intent.refunded (full refund)
#
# Order your server received the webhooks:
# 1) payment_intent.refunded (arrived first)
# 2) payment_intent.succeeded (arrived second)
#
# The handler:
case event.type
when "payment_intent.succeeded"
Payment.find_by!(intent_id: event.data.object.id)
.update!(status: :succeeded)
when "payment_intent.refunded"
Payment.find_by!(intent_id: event.data.object.id)
.update!(status: :refunded)
end The question
With the order of arrival above, what's the final state of the Payment, and what's the right way to fix the handler?
Take a moment. Each event arrives, the handler applies the new status, the last one wins. Walk through which last-one-wins ends here. Then think about what change to the handler makes it correct under any arrival order.
The bug
Final state is succeeded, which is wrong. The refund event arrives first, Payment becomes refunded. The succeeded event arrives second, the handler blindly overwrites it back to succeeded. The money is back to the customer but your DB says they paid.
The fix
Two patterns that stay correct under reordering:
- Check the current state before applying. Before applying "succeeded," check whether the Payment is already in a terminal state (refunded, disputed). If so, ignore the event — it's late.
- Re-fetch the truth from the provider. The webhook is a notification; the provider's API is the truth.
Stripe::PaymentIntent.retrieve(id)always returns the current state. Apply that, ignoring what the event itself claims.
The second pattern also makes the handler robust against duplicate deliveries. Re-fetching gives you the current state regardless of how many times the same event arrived.
Wrong takes worth noticing
- "The final state is refunded; the handler is fine." It looks fine in some sequences but only by accident. With the reverse order, the handler corrupts the state.
- "Stripe guarantees in-order delivery on the same intent." It does not. Webhook ordering is best-effort. Retries from your endpoint timing out routinely arrive later than newer events.
- "
.update!will raise a state-machine error." Only if you wired one up. Defaultupdate!doesn't enforce legal transitions.
The principle
Webhooks are notifications. The provider's API is the source of truth. Senior handlers treat each event as "something changed; let me look up the truth and reconcile," not as "apply this status verbatim."
Theory
Full walkthrough at Payments · Webhooks: Verify, Idempotent, Replayable.