A Payment Isn't Finished When the Customer Pays

Sep 8, 202615 min readdistributed-systems

A customer pays. Your webhook says succeeded. You write a row, send a receipt, credit a seller. The UI shows a green check. Everyone involved believes the money is theirs.

Two weeks later the card network asks for it back.

This is a dispute — a chargeback, or the inquiry that comes before one — and it is the moment a payments integration stops being "call Stripe, store the ID" and starts being a distributed system. The hard part was never charging the card. The hard part is that money can move backward, on a schedule you do not control, through events that arrive more than once, out of order, and with a payload you should not blindly trust.

I have spent a long stretch of this year on that problem. Stripe is the concrete case. The question underneath it is bigger, and it is the same question behind a laptop that wakes up three weeks later and must not replay four commands:

How do you keep state correct when an external system is asynchronous, authoritative, and capable of reversing a decision you already published?

What follows is six things that question forced me to believe. The product around it does not matter. Any system that takes a card, settles to someone else, and then has to live with the network's right of reversal hits the same shape.

1. A payment is a state machine, not a boolean#

If you model a charge as paid-or-not, every later event is an exception. Refunds become special cases. Payouts become special cases. Disputes become a panic. That model cannot answer a simple question: where is this dollar right now?

A card payment is a state machine that outlives the checkout request.

code
authorized
  -> succeeded
      -> settled                         # funds available, maybe already paid out
          -> inquiry opened              # warning_needs_response
              -> evidence / refund       # platform funds still in place
                  -> warning_closed      # never became a chargeback
                  -> escalates --------+
                                       |
          -> chargeback opened <-------+  # needs_response
              -> evidence submitted       # under_review
                  -> won                  # network returns the principal
                  -> lost                 # principal stays gone; fee stays gone

succeeded is a real state. It is not a terminal one. Stripe's dispute object makes this explicit: inquiries live in warning_* statuses; a formal chargeback is needs_response then under_review then won or lost. There is also prevented, for disputes that never became a chargeback. New values will show up. Unknown ones should fail closed — park them, log them, do not invent an upload form.

The inquiry / chargeback split is not cosmetic. Stripe's docs are clear: during an inquiry, funds are not withdrawn unless it escalates. On a formal chargeback, the network pulls the disputed amount and a dispute fee from your Stripe balance immediately.

That last sentence is about Stripe's ledger, not yours.

code
chargeback opens

  Stripe (platform)              your replica (seller)
  -----------------              ---------------------
  principal: already gone        still showing available
  dispute fee: already taken     not yet debited
  status: needs_response         maybe no row yet

On chargeback open, Stripe has already withdrawn the disputed funds from the platform. Your system should not immediately create a second seller-side debit unless your chosen accounting model requires it. Two ledgers, two clocks. Mixing them up is how you double-charge a seller for money Stripe already took.

Won and lost are terminal for the dispute. They are not terminal for the money. A loss still has to claw funds back from a seller who may already have withdrawn them. A win still has to make sure you did not invent a charge on the way in.

Treat the processor as the authority on what the network thinks. Treat your database as the authority on what you have done about it. Those are different objects.

2. Your database and Stripe are two replicas of reality#

They will disagree. That disagreement is not a bug. It is the gap you have to design for.

Your database and Stripe are allowed to disagree, temporarily, as long as disagreement is visible, retryable, and cannot double-spend.

This is inevitable, not sloppy. Stripe is another computer, on another clock, with another ledger. A charge.dispute.created webhook can arrive before your sale exists locally. An update can beat a create. Evidence eligibility on your row can say needs_response while the live dispute has flipped, or the deadline has passed in the last thirty seconds.

At any moment after a dispute opens, these can all be different:

  • Stripe's dispute status and your row's status
  • whether Stripe has already withdrawn funds
  • whether your ledger has posted anything to the seller
  • whether evidence is still accepted

"Temporarily" is doing the work. If you ack 200 on a missing sale, the disagreement becomes permanent and you have dropped a chargeback. If you 503 forever on a sale that will never exist, you burn Stripe's live-mode retry window — up to three days, exponential backoff — and then the event dies.

The protocol is: return 503 when a prerequisite row is missing. Stripe will retry. The sale webhook will usually land in between. You wait, on purpose, in a language the processor already speaks. Log it. Put a metric on it. Have an ops path for the ones that exhaust retries. Eventual consistency is not a vibe. It is a retry budget you are spending deliberately.

The replica is allowed to be behind. It is not allowed to be authoritative for a write that costs money.

3. Webhooks are notifications, not truth#

Stripe emits events for changes you need to react to. Those events are necessary. They are not a complete source of truth.

The docs say this more bluntly than most integrations admit. Delivery is retried until you answer 2xx, or until the window closes — treat it as at-least-once. Order is not guaranteed: charge.dispute.updated can beat charge.dispute.created. The payload of a snapshot event is the object as it was when the event was generated. Stripe tells you to retrieve the resource if you need the current one. Retries get a new signature and timestamp. None of that is optional.

So the inbound HTTP request is a hint with a signature. Verify Stripe-Signature against the raw body before you look at the JSON. A parsed body you re-serialize will fail. Fail closed.

After the signature, still do not treat the payload as money. Amounts you will post to a ledger belong on the live object, not on a snapshot that may be missing a balance transaction.

The useful sequence is three verbs:

code
verify signature
  -> fetch the live object
      -> reconcile your replica to it

What you persist is not "I received this JSON." What you persist is "I have reconciled to this processor state, and I have performed these side effects."

That is the same model as fleet control: the push is a doorbell. It says something changed, go look. A dropped, duplicated, or reordered notification costs you a fetch. It must not cost you a second debit.

Evidence is the sharp version. Your replica can still think the window is open. Fetch the live dispute before you do irreversible work. Fetch again immediately before the write — the deadline can pass in between. If the second check fails, throw the in-flight work away. A local "pending" that never expires is a third replica you did not mean to create.

charge.dispute.funds_withdrawn and funds_reinstated are easy to overfit. Stripe has already moved cash. If your ledger posts from those events and from closed/lost, you double-book. Status events drive the state machine. Fund-movement events are logs unless you have no other signal.

4. Idempotency belongs to side effects#

Tutorials stop at "store event.id with a unique constraint." Do that. Then keep going. A dispute has several operations that must each be safe to retry, and they do not share a key. Deduplicating only on event ID is not enough: charge.dispute.updated and charge.dispute.closed are different event.ids that can describe the same status: lost.

The row. The processor's dispute ID is unique. Insert once. A second created event returns already-exists and 200. That prevents duplicate notifications. It does not prevent duplicate money movement, because money does not happen on create.

The status transition.

sql
UPDATE disputes
SET status = 'lost', updated_at = now()
WHERE stripe_dispute_id = $1
  AND status <> 'lost'
RETURNING *;

Zero rows means another worker already claimed that transition. For won, that is enough — there is no cash work, so you stop.

For lost, it is not enough. Status can commit and then the process can die before the reversal. The next retry sees lost already. If you skip on that basis, you never recover the money. The dedup signal for loss is not the status. It is whether the principal journal posted.

The ledger. Every journal gets a durable unique key for that side effect — principal, fee, tax — not for the webhook. Retries return the posted journal. If posting fails, a dead-letter row with the same key lets a human finish the books without guessing.

The processor's keys. Money-moving API calls take Idempotency-Key. Stripe may prune those keys after they are at least 24 hours old. Your journal unique constraint remembers them forever. You need both. The processor key stops a retry from creating a second reversal inside the window. The journal key stops a retry next month, after the processor has forgotten, from posting a second debit you cannot undo by asking nicely.

One trap: stability versus rotation. A recovery transfer's key must be stable for that recovery. Bake the attempt number into it, crash after the processor succeeds but before you mark the row complete, and the next cron tick debits again. A top-up that funds a changing shortfall should rotate per attempt. Same family of problem, opposite answer.

code
retry lands
  -> status already lost? look at the journal, not the enum
      -> principal journal posted? stop
      -> else continue   # crash mid-loss, not a duplicate
  -> reverse / claw principal
  -> post principal journal
  -> charge fee          # separate key, separate failure

The test that matters is not "handler returns 200 twice." It is: replay the closed event against a lost dispute and watch the seller's balance not move. If it moves, the guard is on the wrong object.

For loss, the journal is the lock. The enum is a cache of the lock.

5. Money movements fail independently#

People talk about disputes as a status badge. The interesting part is the cash, and the cash is not one movement.

On a typical marketplace charge, three parties already split the original payment: the platform, the seller, the tax authority. Then a chargeback adds a fourth claim: the cardholder, plus a network fee that does not care who was right.

Stripe's side, on a formal chargeback, is already done on open: principal and dispute fee have left the platform balance. Your side is a set of independent recoveries that can each succeed or fail — claw the principal from the seller, recover the fee, reverse the tax slice so the seller is not debited for money they were only holding, and decide what to do about a payout that may already have left.

code
sale (day 0)
  DR stripe_receivable     100
  CR seller_available       82
  CR platform_revenue       10
  CR tax_liability           8

chargeback lost (day 18)
  DR seller_available      100    # principal
  CR stripe_receivable     100

  DR tax_liability           8    # tax no longer owed
  CR seller_available        8

  DR seller_available       15    # network fee
  CR stripe_receivable      15

The principal can succeed and the fee can fail. The fee can succeed and the principal can no-op because a refund already moved that money — the processor errors if you reverse more than remains. That error is success. Treating it as failure is how you page yourself about a dollar that is fine.

A design that has survived this:

On chargeback open, record. Do not debit the seller unless your model is a hold. Notify them. Capture amount, reason, deadline, IDs. Charging a fee on open and refunding it on win is two extra money movements, each of which can fail. Stripe has already taken the platform's copy. Yours is the seller.

On win, confirm that you did nothing to the seller. If open was a no-op for seller cash, win is also a no-op. The only work is status, notifications, and not inventing work on retry.

On loss, recover what is still recoverable, independently. Ack the webhook when the durable work is done or handed to a slower reconciler. Do not hold the HTTP request open until a connected account has a balance.

WorkIf it failsAck?
Persist statusYou drift from StripeNo
Claw the principalSeller keeps money that is goneNo, unless already moved
Post the journalProcessor moved cash, your books did notRetry; dead-letter if poison
Charge the feePrincipal recovered, fee outstandingEnqueue recovery, then yes
Email the sellerHuman is in the darkYes. Never fail a money webhook because SMTP blinked

The fee is the pattern I would steal first. Insufficient balance can last days. A row that retries on a schedule matches the failure. Holding the webhook until the seller tops up is how you lose the event.

There is a fork I want to name, because pretending there is one correct answer is how blogs get cute. You can hold the seller's funds on open — debit available, credit a hold, exclude it from payouts — and convert or release on the outcome. Or you can leave the money and claw back only on loss. The first matches how the network thinks. The second is simpler, and means a payout can complete during the evidence window, and the loss lands against empty. Mixing them is worse than either: a hold account that is provisioned but never posted makes the dashboard lie.

I would take the hold. I would also ship clawback-on-loss first if the hold is not ready, and I would not dress the gap up as a feature.

6. Reconciliation is what makes the system eventually correct#

Idempotency stops you from doing the same thing twice. Reconciliation is how you finish the thing you only did once, halfway.

A 503 on a missing sale is reconciliation with the processor's retry loop. A dead-letter journal is reconciliation with a poison write. A fee-recovery row is reconciliation with a connected account that is empty today and might not be next week. Fetching the live dispute before evidence is reconciliation with a replica that went stale in the last thirty seconds.

The other money paths are the same machine, viewed from a different edge. A sale that already lost a dispute has already returned the buyer their money — refunding it again is a gift. A payout in flight when the dispute opens is why the hold exists, and why the fee queue exists if you skipped the hold. If your dispute row cannot answer "has the buyer already been made whole?", the refund code will guess. Guessing about money is how you write the next incident doc.

A dashboard counter that decrements inside the webhook is the cheap version of this mistake. Gate it on the journal key, or do not have it. A denormalised number that updates on retry is just another replica, lying faster.

What I would tell myself at the start#

The question was never "how do I handle Stripe disputes?" It was: how do real systems stay correct when state changes asynchronously?

A dispute is one answer. A device that has been shut for three weeks is another. A queue that delivers the same message twice is another. An AI agent that can initiate an irreversible side effect will be another. The processor changes. The shape does not.

Four things, for this instance of the shape.

Model the life of the dollar, not the checkout. succeeded is a step. Settlement, inquiry, chargeback, evidence, win, loss, fee, tax, payout, refund — same dollar, different owners. If you cannot draw it, you cannot journal it.

Make the webhook cheap to lose. Signature, then fetch, then reconcile. 503 when a prerequisite is missing. Ack 200 only when the durable work is done or handed to a retry system that is not the processor's three-day window.

Put idempotency on the side effect, not only on the event. Event ID uniqueness stops one class of duplicate. Status transitions stop another. Journal keys and processor keys stop the class that empties a connected account. For loss, the journal is the lock. The enum is a cache of the lock.

Decide which replica is allowed to lie. Stripe can be ahead of you. You can be ahead of a cached dashboard. Neither replica may authorise a transfer, a fee, or an evidence upload from stale data. When they disagree, the next action is to look again, not to improvise.

Charge the card if you want. The system starts when that charge is no longer yours to keep.