Design a Payment System, stage 10 of 13: break it
Thirty-seven payments stuck in processing
Every component behaved as designed: the provider retried as documented, your handler rejected what it could not verify, and the attempts stayed in processing instead of guessing. Now the system needs a way to finish what messages could not.
System so far· 7 parts
Select a component to see what it is responsible for and which state it owns.
- 1Buyer's browser → Payment provider: Card entry and 3-D Secure in hosted fields
- 2Buyer's browser → Checkout API: Pay (Idempotency-Key header); poll status
- 3Checkout API → Payment provider: Create payment with the attempt's key
- 4Checkout API → Postgres: Claim attempt; conditional transitions
- 5Payment provider → Webhook receiver: Signed events: at least once, unordered
- 6Webhook receiver → Postgres: Dedupe by event id; forward-only transition
- 7Fulfillment worker → Postgres: Claim outbox rows; record completion
- 8Fulfillment worker → Email provider: Send receipt with idempotency key
- Request / response
- Asynchronous
What you need to know
Messages can't guarantee resolution: a webhook endpoint broken for longer than the provider's retry window loses those events permanently. Reconciliation asks the source of truth directly and fixes what the messages missed. See Reconciliation.
A reconciler has no special powers. It learns a fact, then submits it through the same transitions as the API and webhooks, so it can't conflict with them.
Check
The reconciler asks the provider about an attempt that has been processing for 20 minutes. The provider has no record of it. What should happen?Two kinds of reconciliation catch different things:
- Targeted, every few minutes: old
processingattempts, looked up one by one. - Full, daily: the provider's settlement report compared with your ledger in both directions: charged but not recorded, and recorded but not charged.
One metric would have caught the broken endpoint on day one: the age of the oldest processing attempt.
- Targeted, every few minutes: old