CodePark/ questions / Classic design problems
START PRACTICINGPRACTICE
Backend question · System design & architecture › Classic design problems

Design the payment path of a checkout so a customer is never charged twice, even with retries, double clicks and timeouts to the payment provider.

Give every payment attempt a unique idempotency key, and make the server return the saved result for any repeated key instead of charging again.

Step 1: the client. Before it submits, the client creates a payment intent with a unique idempotency key. An idempotency key is a unique ID for one payment attempt. Sending the same key twice must have the same effect as sending it once.

Step 2: the payment service. It stores the key in a table with a unique constraint. A unique constraint means the database refuses a second row with the same key. Each row holds:

  • the key
  • a hash of the request
  • a status

When a duplicate request arrives with the same key, the service returns the stored result. If the first attempt is still in progress, the service returns a conflict or waits. It does not charge again.

Step 3: the payment provider. When the service calls the provider, it passes an idempotency key there too. Providers such as Stripe support this. Because of the key, a retried call cannot create a second charge.

Step 4: timeouts. A timeout means the outcome is unknown, not failed. The money may have moved, or it may not. So mark the payment unknown. Then ask the provider for the status instead of charging again.

Step 5: checking afterwards.

  • Record every money movement in an append-only ledger. Append-only means you only add new rows and never edit or delete old ones.
  • Use provider webhooks to confirm the final status. A webhook is a call the provider makes to your server when something changes.
  • Run periodic reconciliation: compare your records with the provider's reports to catch anything you missed.
  • Downstream steps, such as fulfilment (packing and shipping the order), consume payment events idempotently. This means handling the same event twice does no harm.
  • The client sends an idempotency key, and the database stores it with a unique constraint.
  • A duplicate request gets the stored result, and a request still in progress gets a conflict or a wait.
  • The service also passes an idempotency key to the payment provider.
  • A timeout means the result is unknown, so query the status and do not charge again.
  • A ledger, webhooks and reconciliation catch anything the earlier steps missed.