- Common pitfalls of a naive design
- Preventing duplicate charges with idempotency keys
- Modeling payments as an append-only ledger for auditability
- Handling asynchronous updates with webhooks and eventual consistency
- Operational best practices for production systems
- The user enters card details in the browser.
- Stripe’s JavaScript collects those details securely and sends them directly to Stripe, so your server never sees the raw card number. See https://stripe.com/docs/js.
- Stripe processes the payment and returns a response to your server.
- Your server marks the order as paid in the database and sends a receipt.

- Protects against retries and browser resubmits
- Ensures a single logical payment attempt maps to a single provider charge
- Client generates
idempotency_key(or the server generates and returns it to the client). - Server stores (
order_id,idempotency_key,created_at) before calling the payment API. - When receiving the same
idempotency_keyagain, look up and return the stored outcome.

status field can represent.
The need for a payment ledger
Payments progress through multiple states: initiated, authorized, captured, refunded, disputed, etc. Modeling a payment as a single mutable database row and overwriting a status loses history. When finance, customer support, or regulators ask what happened to a transaction, you must be able to reconstruct the full timeline.
Version three — an immutable ledger
Use an append-only event ledger for each payment. Instead of updating a single status repeatedly, append an event for every state change:
payment_initiated— user started the flowpayment_authorized— card issuer authorized the amountpayment_captured— funds capturedpayment_refunded— refund issueddispute_opened— a chargeback or dispute received

Design your payment table as an append-only event ledger. Store each event with its timestamp, source (e.g., client, Stripe webhook), event ID, and raw payload. Compute the current state from that ordered list of events rather than trusting a single mutable
status.- Verify the webhook signature to confirm the payload is from the provider. See https://stripe.com/docs/webhooks/signatures.
- Deduplicate events using the provider’s event ID to avoid double-processing.
- Append each verified webhook as an event in your immutable ledger.
- Make webhook processing idempotent and safe to re-run.
- Client sends an
idempotency_keywith each payment attempt. - Server writes a
payment_initiatedintent into the ledger before calling the payment processor. - The processor may return immediate responses and later send webhook events which your server verifies and appends as new events to the same ledger.
- The payment’s current status is derived from the full event history (replayed or queried).
Remember PCI and regulatory requirements: do not log raw card numbers, encrypt sensitive data at rest, and follow your payment provider’s guidance for compliance. Use provider-hosted fields (e.g., Stripe Elements) so your server never handles raw PANs.
- Keep a single canonical ledger per logical payment to make queries, reporting, and reconciliations straightforward.
- Use provider-side idempotency features in addition to your own keys when available.
- Monitor webhook delivery metrics and implement alerting for failed deliveries or processing errors.
- Stripe Payments overview: https://stripe.com/docs/payments
- Stripe webhooks: https://stripe.com/docs/webhooks
- Stripe JS integration: https://stripe.com/docs/js
- Webhook signature verification: https://stripe.com/docs/webhooks/signatures