12 March 2024 / Payment workflows

The workflow that starts when a payment clears

When a payment clears, I want the right account updated, one message sent instead of three, and enough left behind that support can work out what happened if a later step fails.

Intersecting steel beams and bracing viewed from beneath a bridge.
Photo: Sebastian Schuster (opens in a new tab)

When a payment provider tells your app a transaction succeeded, I'd treat that webhook as the only signal that counts. The browser redirect can't be trusted for this, because people pay and then close the tab, or refresh the completion page several times. Webhooks get retried and can arrive out of order, so the handler needs to check the signature using the provider's documented method, only parse the body after that, confirm it's an event type you expect, and store the provider's event ID with the time it arrived. If the same ID turns up again, return a success response and don't redo anything.

Matching the payment to the right account is the next thing that goes wrong. Email is a weak key (it changes, the case varies, and sometimes someone's paying for another person). I'd create the local order or payment attempt before sending anyone to checkout, pass an opaque internal reference through the provider's metadata field, and resolve it when the webhook comes back. Then check that the currency, amount and provider account match what you already recorded. If they don't, put it in a review state rather than pushing it through as a success. Keep both your own payment ID and the provider's transaction ID on the record, because support will start from whichever one the customer has in front of them.

After that, a few things usually follow: access gets granted, the order's marked paid, a receipt goes out. Any of those can fail after an earlier one worked, and a database transaction won't cover your email service or accounting system. So each step needs to be safe to repeat. Granting access twice should leave the same entitlement, and a retried message job shouldn't send a second receipt. An outbox helps here. In the same transaction that marks the payment accepted, write durable jobs for the downstream work, and let a worker run and retry them. I'd also keep "paid" and "confirmation email sent" as separate states, so a failed email doesn't make a good payment look unpaid. Something like:

  • event received and verified
  • matched to an internal record
  • accepted or held for review
  • entitlement updated
  • notification queued, sent or failed

Build the customer's message from your accepted internal record, not the raw webhook body, and queue it after the access update commits. Telling someone their access is ready while that job's still pending just creates a support ticket. Give each message a stable key tied to the payment and message type, and if support needs a "resend" button, have it create a new recorded attempt instead of bypassing the duplicate check.

For support, I'd want a view that searches by local payment ID, provider transaction ID or account reference and shows the amount, verified event history, current state, downstream jobs and the latest error. Keep retries narrow ("retry notification", not "run the payment workflow again"). Add a periodic reconciliation against the provider's transactions that doesn't depend on webhooks. And while building it, replay the same verified event twice and kill the process after each downstream step, then check the account, the message count and the payment state separately.

The workflow that starts when a payment clears mostly has to cope with parts of it running twice or stopping halfway. If the webhook is verified and deduplicated, matched by your own reference, and each downstream step is a recorded job that's safe to repeat, the customer gets the right access and one message, and support can see where things stopped. Those replay and kill tests are the check I'd run before trusting it with real payments.

Got a take on this?

Share it on X. I read the replies.

All blogs