Payments integration
In this series (15 parts)
- Backend system design scope
- Designing RESTful APIs
- Authentication and session management
- Database design for backend systems
- Caching in backend systems
- Background jobs and task queues
- File upload and storage
- Search integration
- Email and notification delivery
- Webhooks: design and security
- Payments integration
- Multi-tenancy patterns
- Backend for Frontend (BFF) pattern
- GraphQL server design
- gRPC and internal service APIs
Rule zero of payments: never roll your own. Do not store card numbers. Do not build a billing engine from scratch. Do not process payments directly with card networks. Use a payment processor like Stripe, Adyen, or Braintree, and build your integration around their APIs.
This article covers how a modern Stripe-style payment integration works, why idempotency keys are non-negotiable, how webhooks drive the asynchronous side of payments, and how to keep your system out of PCI-DSS scope as much as possible. For broader resilience thinking, see reliability patterns.
The checkout payment flow
A typical e-commerce checkout has multiple moving parts. The user submits their card details, the payment processor authorizes the charge, your system confirms the order, and the processor eventually settles the funds to your bank account. This does not happen in a single synchronous API call.
sequenceDiagram participant Browser participant Frontend participant Backend participant Stripe as Payment Processor participant Bank as Issuing Bank Browser->>Frontend: enter card details Frontend->>Stripe: create PaymentMethod (client-side SDK) Stripe-->>Frontend: pm_abc123 Frontend->>Backend: POST /checkout (pm_abc123, order details) Backend->>Backend: validate order, compute total Backend->>Stripe: create PaymentIntent (amount, pm, idempotency_key) Stripe->>Bank: authorize charge Bank-->>Stripe: authorized Stripe-->>Backend: PaymentIntent (status: requires_capture) Backend->>Backend: reserve inventory Backend->>Stripe: capture PaymentIntent Stripe-->>Backend: PaymentIntent (status: succeeded) Backend->>Backend: confirm order, emit order.completed Backend-->>Frontend: order confirmation Stripe->>Backend: webhook: payment_intent.succeeded Backend->>Backend: reconcile, update records
Complete checkout payment flow. Card details never touch your server. The PaymentMethod token is created client-side, keeping your system out of PCI scope.
The key insight: card details go directly from the browser to the payment processor via their client-side SDK. Your backend only ever sees a token (pm_abc123). This is called tokenization, and it is the single most important decision for PCI-DSS scope reduction.
Authorize vs capture
Most payment processors support a two-step flow: authorize first, capture later. Authorization puts a hold on the funds but does not transfer them. Capture actually moves the money.
Why separate them?
- Inventory check between auth and capture. Authorize the payment, then check if the item is in stock. If it is not, release the authorization instead of processing a refund.
- Fraud review. Hold the payment while your fraud system evaluates the transaction. Capture only if it passes.
- Delayed fulfillment. Authorize at order time, capture at shipping time. This is standard for physical goods.
Authorizations expire. Stripe gives you seven days by default, but card networks may release the hold sooner. If you need longer, re-authorize periodically.
Idempotency keys
Network failures during payment API calls create the worst possible ambiguity: did the charge go through or not? Without idempotency keys, retrying a failed request might charge the customer twice.
An idempotency key is a unique string you send with every mutating API call. If the processor has already seen that key, it returns the original response instead of processing a new request.
POST /v1/payment_intents
Idempotency-Key: checkout_ord_12345_attempt_1
{
"amount": 4999,
"currency": "usd",
"payment_method": "pm_abc123",
"capture_method": "manual"
}
Rules for idempotency keys:
- Derive from the business entity. Use the order ID or checkout session ID, not a random UUID. This ties the key to the intent. If the same order retries, it gets the same key.
- Include an attempt counter if you change parameters between retries. Changing the amount with the same key is an error on most processors.
- Store the key and the processor’s response. Your local record of what happened should match the processor’s record exactly.
- Keys expire. Stripe expires idempotency keys after 24 hours. Do not rely on them for deduplication beyond that window.
Entity-derived idempotency keys virtually eliminate duplicate charges. Random UUIDs help but miss retries from different code paths for the same order.
Webhooks for async payment events
Payments are inherently asynchronous. A charge might succeed instantly, require 3D Secure authentication, or take days to settle via bank transfer. You cannot rely solely on synchronous API responses to know the final state of a payment.
Subscribe to webhook events from your processor:
| Event | Action |
|---|---|
payment_intent.succeeded | Confirm the order, send receipt |
payment_intent.payment_failed | Notify user, offer retry |
charge.refunded | Update order status, adjust analytics |
charge.dispute.created | Flag for review, gather evidence |
invoice.payment_failed | Retry subscription billing, notify user |
Your webhook handler must be idempotent. The processor may send the same event multiple times. Use the event ID to deduplicate.
Always reconcile. Even if your synchronous API call returned success, wait for the webhook confirmation before considering the payment truly complete. The webhook is the source of truth.
Refunds and partial captures
Refunds look simple from the outside but have operational complexity:
- Full refund: reverse the entire charge. The processor handles the actual money movement back to the customer’s card or bank account.
- Partial refund: refund a portion of the charge. Common for returning one item from a multi-item order.
- Partial capture: if you authorized 75 (one item was out of stock), capture only 25 authorization is released.
Model refunds as first-class entities in your database. Each refund should reference the original payment, have its own status lifecycle, and be idempotent (use refund idempotency keys).
CREATE TABLE refunds (
id TEXT PRIMARY KEY,
payment_id TEXT NOT NULL REFERENCES payments(id),
processor_refund_id TEXT,
amount_cents INTEGER NOT NULL,
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending',
idempotency_key TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Refunds take time. Card refunds typically appear on the customer’s statement within 5 to 10 business days. Your support team needs to know this so they can set accurate expectations.
PCI-DSS scope reduction
PCI-DSS (Payment Card Industry Data Security Standard) defines requirements for any system that stores, processes, or transmits cardholder data. Full PCI compliance (SAQ D) involves hundreds of controls, regular audits, and significant engineering investment.
The goal is to reduce your scope to the minimum. The tokenization approach described above gets you to SAQ A or SAQ A-EP, which is dramatically simpler:
| Approach | PCI scope | What you handle |
|---|---|---|
| Redirect to hosted page | SAQ A | Nothing touches your servers |
| Client-side SDK (Stripe Elements) | SAQ A-EP | Token only touches your backend |
| Server-side card processing | SAQ D | Full PCI compliance required |
Practical steps for scope reduction:
- Never log card numbers. Not even partially. Not even masked. Your logging pipeline should have explicit filters for PAN patterns.
- Use the processor’s client-side SDK. Stripe Elements, Adyen Web Components, etc. Card details go directly to the processor.
- TLS everywhere. Your checkout page must be served over HTTPS. This is table stakes.
- Content Security Policy. Restrict which scripts can run on your checkout page to prevent XSS attacks that could skim card data.
- Separate your checkout service. If your payment-handling code lives in an isolated service with minimal dependencies, only that service needs PCI assessment.
Handling disputes and chargebacks
A chargeback happens when a customer contacts their bank to reverse a charge. The processor notifies you via webhook (charge.dispute.created), and you have a limited window (usually 7 to 21 days) to submit evidence.
Build a dispute response system:
- Automatic evidence gathering: when a dispute webhook arrives, pull the order details, delivery confirmation, IP address logs, and any communication with the customer.
- Dashboard for your team: present the evidence and let your support team add context before submitting.
- Tracking and analytics: monitor dispute rates by product, region, and customer segment. High dispute rates (above 0.75%) can get your merchant account terminated.
flowchart LR Webhook["dispute.created webhook"] --> Gather["Gather Evidence"] Gather --> Order["Order details"] Gather --> Delivery["Delivery proof"] Gather --> Logs["Activity logs"] Gather --> Comms["Customer communication"] Order --> Review["Support Review Dashboard"] Delivery --> Review Logs --> Review Comms --> Review Review --> Submit["Submit to Processor"] Submit --> Won["Dispute Won"] Submit --> Lost["Dispute Lost"]
Automated evidence gathering pipeline for chargeback disputes. Fast response times correlate with higher win rates.
Subscription billing patterns
Recurring payments add a state machine on top of one-time charges:
- Trial: no charge, user exploring the product.
- Active: recurring charges succeed.
- Past due: a charge failed, grace period begins.
- Canceled: user or system ended the subscription.
- Paused: user requested a temporary hold.
Let the processor manage the billing cycle. Stripe Billing, for example, handles invoice generation, payment retries (dunning), proration for plan changes, and tax calculation. Your job is to react to webhook events and update your application state accordingly.
The most common mistake is building subscription state in your database and trying to keep it in sync with the processor’s state. Instead, treat the processor as the source of truth and use webhooks to update your local state. If there is ever a conflict, the processor wins.
Testing payments
Never test against production payment APIs with real cards. Use the processor’s test mode:
- Test card numbers: Stripe provides card numbers like
4242 4242 4242 4242that simulate success,4000 0000 0000 0002for declines, and others for specific scenarios. - Webhook testing: use the processor’s CLI to send test webhook events to your local development environment.
- Clock simulation: for subscription testing, Stripe’s test clocks let you advance time to simulate billing cycles without waiting days.
Write integration tests that cover the happy path, declined payments, 3D Secure flows, webhook processing, and refunds. Payment bugs are the most expensive bugs in your system.
What comes next
With payments covered, the next article explores multi-tenancy patterns: how to design a system that serves multiple customers from a shared infrastructure while keeping their data isolated and their performance predictable.