Payments
Taking money safely: gateways, 3-D Secure, webhooks, idempotency, refunds and reconciliation.
In 30 seconds
A payment is a conversation between your shop, a payment gateway, the customer's bank and sometimes the customer's phone. Your job is to start that conversation safely, never touch raw card numbers, and record the true outcome even when messages arrive late, twice or not at all. Webhooks, idempotency keys and reconciliation jobs are what make that possible.
Why I use it
- I use these patterns because I have integrated many payment methods — bank card gateways, wallets, Apple Pay and Google Pay, Stripe and PayPal — and every one of them fails in its own way.
- Payments are where a small bug becomes a real loss: a customer charged twice, an order shipped without payment, a refund that never reached the bank.
- Good payment code is boring on purpose: explicit states, signed callbacks, retries that are safe, and a job that double-checks everything.
- Keeping card data out of your servers shrinks your PCI compliance work from a project to a questionnaire.
How it works
01Who is involved and how money moves
Picture four parties. The customer pays. The merchant (your shop) sells. The acquirer is the merchant's bank, usually reached through a payment gateway or provider (Stripe, Adyen, a local bank's virtual POS). The issuer is the customer's bank, which approves or declines. Card networks such as Visa and Mastercard carry messages between them. A card payment has two steps: authorisation (the issuer reserves the money) and capture (the money is actually taken). Many shops capture immediately; restaurants or stores with stock uncertainty may authorise first and capture when the order is confirmed. Settlement — the money arriving in your account — happens later, often a day or more, minus fees.
- Authorisation: "can this card pay 50.00?" — funds are held.
- Capture: "take the 50.00 now" — can be full or partial.
- Void: cancel an authorisation before capture; refund: return money after capture.
02Integration styles: redirect, hosted fields and SDKs
There are three common ways to collect card details. A redirect (hosted payment page) sends the customer to the gateway's page and back to your return URL — simplest and the smallest compliance scope, common with bank gateways. Hosted fields or embedded components (Stripe Payment Element, Adyen Drop-in) render card inputs inside iframes on your page, so it looks native while card data still goes straight to the provider. Mobile SDKs do the same in apps, and wallet apps can be opened through deep links or native modules. Whatever the style, the flow is the same: your server creates a payment session for an order with the amount and currency, the customer completes it with the provider, and your server learns the result from the provider — never from the browser alone.
// Server: create a payment session for an existing order (Stripe example)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function startPayment(order: { id: string; total: number; currency: string }) {
const intent = await stripe.paymentIntents.create(
{
amount: order.total, // integer minor units
currency: order.currency.toLowerCase(),
metadata: { orderId: order.id },
automatic_payment_methods: { enabled: true },
},
{ idempotencyKey: `order-${order.id}-attempt-1` },
);
return intent.client_secret; // the client confirms with this; no card data touches us
}
033-D Secure and strong customer authentication
3-D Secure (3DS) is the extra step where the issuer asks the customer to confirm a payment, usually in the banking app or with an SMS code. The current version, EMV 3DS (often called 3DS2), sends rich device and transaction data so many payments pass "frictionlessly" without a challenge, and only risky ones get one. In Europe, strong customer authentication (SCA) under PSD2 makes this mandatory for most online card payments; elsewhere it is optional but reduces fraud and usually shifts chargeback liability for fraud to the issuer. For you, 3DS means the payment is not instant: it may pause for a challenge, then continue. Your flow must handle a status like "requires action" and a customer who closes the tab halfway through.
04Webhooks, callbacks and idempotency
The browser redirect back to your site is not proof of payment: users close tabs, networks drop, and anyone can type a return URL. The source of truth is a server-to-server message — a webhook (the provider calls your endpoint) or a status query you make to the provider. Verify every webhook's signature, look up the payment by your own ID, and update the order inside a transaction. Providers retry webhooks, so the same event may arrive several times or out of order: store processed event IDs and make the handler idempotent, meaning running it twice has the same effect as running it once. Use idempotency keys on your own outgoing calls too, so a retried "create payment" or "refund" never charges or refunds twice.
// Webhook handler: verify, deduplicate, apply once
app.post("/webhooks/payments", express.raw({ type: "application/json" }), async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.header("stripe-signature")!, process.env.STRIPE_WEBHOOK_SECRET!,
);
await db.transaction(async (tx) => {
const seen = await tx.processedEvents.insertIgnore({ id: event.id }); // unique key
if (!seen.inserted) return; // duplicate delivery: already handled
if (event.type === "payment_intent.succeeded") {
const orderId = event.data.object.metadata.orderId;
await tx.orders.markPaidIfPending(orderId); // state machine guards the move
}
});
res.sendStatus(200); // answer fast; heavy work goes to a queue
});
05Payment states, "outcome unknown" and reconciliation
Keep a payments table separate from orders: one order can have several attempts. Give each attempt explicit states, such as created, pending, requires action, authorised, captured, failed, cancelled, refunded and partially refunded. Add one more that beginners forget: outcome unknown. It covers timeouts and crashes where you sent a request but never got an answer — the money may or may not have moved. Never guess. Mark it unknown, do not retry blindly with a new charge, and let a reconciliation job ask the provider for the real status. Reconciliation runs on a schedule: it finds payments stuck in pending or unknown, queries the gateway, fixes local state, and compares daily settlement reports with your ledger so every cent is accounted for.
- Retry a timed-out call only with the same idempotency key, or query status first.
- Alert on payments stuck in a non-final state for longer than expected.
- Store raw provider responses for disputes and debugging, without card data.
06Refunds, saved cards, wallets and PCI scope
Refunds go through the same discipline: create a refund record, call the provider with an idempotency key, and confirm the final status from a webhook or query, since bank refunds can take days. Support partial and per-item refunds, and never refund more than was captured. Saved cards work through tokenisation: the provider stores the card and gives you a token or customer ID you can charge later, so you keep only the brand, last four digits and expiry for display. Apple Pay and Google Pay are wallets that hand the provider an encrypted, device-specific token; they need domain verification on the web and merchant setup, but give fast, biometric checkout. PCI DSS is the card industry's security standard. If card data only ever touches the provider's pages or iframes, you qualify for the shortest self-assessment (SAQ A), though under PCI DSS 4.0.1 you must still protect the pages that load payment scripts.
Use it when
- Any shop, booking or subscription product that takes money online.
- You need several payment methods — cards, local wallets, Apple Pay, Google Pay, cash or POS — behind one consistent order flow.
- Orders must stay correct even when gateways time out or send duplicate callbacks.
Skip it when
- Never build your own card storage or processing; use a provider's tokenisation.
- For a very small store, a hosted checkout (Shopify, Stripe Checkout, a payment link) avoids most of this work.
- Avoid writing custom 3DS logic; let the provider's SDK or hosted page run it.
Alternatives
- Stripe / Adyen
Global providers with excellent APIs, hosted fields, wallets and webhooks; availability and fees vary by country.
- Local bank gateways
Often the only option for local cards and currencies; usually redirect-based with simpler, older APIs that need careful status checks.
- PayPal
Popular wallet with its own buyer protection; integrates as a redirect or buttons, with webhooks for final status.
- Merchant of record (Paddle, Lemon Squeezy)
They sell on your behalf and handle tax and compliance; good for digital products, less so for physical goods.
Best practices
- Treat the redirect as UX only; confirm payment from a verified webhook or a server-side status query.
- Make every webhook handler idempotent and store processed event IDs.
- Use idempotency keys on create-payment and refund calls.
- Model an "outcome unknown" state and resolve it with a reconciliation job, never a blind retry.
- Keep card data out of your systems with redirects, hosted fields and tokens.
- Separate payment attempts from orders and keep a ledger of every money movement.
- Log raw provider responses (without card data) and alert on stuck payments.
Common mistakes
- Marking an order paid because the user landed on the success URL.
- Not verifying webhook signatures, letting anyone mark orders as paid.
- Retrying a timed-out charge with a new request and charging the customer twice.
- Storing card numbers or CVV "temporarily" in logs, caches or a persisted cart.
- Forgetting that refunds can fail or stay pending for days.
Deep dive
07A ledger: double-entry thinking for money movements
A payments table tells you the state of each attempt; a ledger tells you where every cent went. In double-entry bookkeeping each movement is recorded twice, as a debit on one account and a credit on another, so the totals always balance. For a shop the accounts are simple: customer receivable, provider balance, fees, refunds and revenue. A captured payment of 50.00 with a 1.50 fee becomes entries that move 50.00 from the customer to the provider balance and 1.50 from the provider balance to fees. Refunds and chargebacks are new entries, never edits. When the daily settlement arrives, the provider balance should drop by exactly the payout amount. Any difference is a reconciliation finding with a precise date and reference, not a mystery in the accountant's spreadsheet.
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
journal_id UUID NOT NULL, -- groups the entries of one movement
account TEXT NOT NULL, -- customer, provider_balance, fees, refunds
amount BIGINT NOT NULL, -- positive = debit, negative = credit
currency CHAR(3) NOT NULL,
payment_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Every journal must balance to zero
SELECT journal_id FROM ledger_entries
GROUP BY journal_id, currency HAVING SUM(amount) <> 0;
08Webhook delivery semantics in practice
Providers deliver webhooks at least once, not exactly once, and not in order. Stripe, for example, retries failed deliveries in live mode for up to three days with exponential backoff, and states that event order is not guaranteed. Design for that. Read the raw request body, because signature verification fails if a framework parses and re-serialises it. Reject events whose signed timestamp is too old (Stripe's libraries default to five minutes) to block replays. Return 2xx quickly and push the work to a queue, since slow handlers cause timeouts and more retries. In the worker, do not trust the order of events: either fetch the latest object from the provider's API, or apply transitions through a state machine that ignores moves backwards, such as a late "processing" event after "succeeded".
- Deduplicate by event ID with a unique constraint.
- Exempt the webhook route from CSRF protection, but never from signature checks.
- Alert when the endpoint returns non-2xx responses, before retries run out.
09Disputes and chargebacks
A chargeback happens when a cardholder asks their bank to reverse a payment: fraud, an item not received, or "not as described". The provider debits the disputed amount plus a dispute fee from your balance and opens a dispute with a deadline for evidence. Evidence that wins is specific: delivery confirmation with the address, signed receipts, logs showing the customer's account and device, refund policy accepted at checkout, and prior communication. 3-D Secure authentication usually shifts liability for fraud disputes to the issuer, but not for "item not received" disputes. Card networks monitor merchants' dispute ratios, and staying above their thresholds leads to fines or losing card acceptance. Prevention matters more than winning: a clear statement descriptor, fast refunds for genuine complaints, and fraud screening on risky orders.
10Securing the payment page under PCI DSS 4.0.1
Even when card fields live in a provider's iframe, attackers target the page around it: a compromised script can replace the iframe with a fake form or overlay it (often called e-skimming or Magecart-style attacks). PCI DSS 4.0.1 requirement 6.4.3 asks for an inventory of every script on payment pages with a justification and an integrity method, and 11.6.1 asks for a mechanism that detects unauthorised changes to those pages and their security headers. In the January 2025 revision of SAQ A, these two items were replaced by an eligibility criterion: the merchant must confirm their site is not susceptible to attacks from scripts, and the Council's guidance says the 6.4.3 and 11.6.1 techniques are the way to show it. In practice: few scripts on checkout, a strict Content Security Policy, Subresource Integrity for static files and change monitoring.
# nginx: strict CSP on checkout pages (adjust to your provider's documented domains)
location /checkout {
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; connect-src 'self' https://api.stripe.com; img-src 'self' data:; report-to csp" always;
add_header Reporting-Endpoints 'csp="https://example.com/csp-reports"' always;
try_files $uri /index.php?$query_string;
}
Real-world scenarios
Orders paid at the bank but still pending in the shop
Problem
Several times a week customers send bank screenshots of successful payments while their orders remain "pending payment" and are later cancelled by the expiry job.
How to solve it
The webhook is either not arriving or failing silently. Check the provider's delivery log for non-2xx responses, then the usual causes: signature failures from a parsed body, CSRF middleware, a firewall blocking the provider, or a handler that times out. Fix the endpoint to verify the raw body and return 2xx after queuing. Then add the safety nets: a reconciliation job that queries the provider for every payment pending longer than a few minutes, run it before the expiry job cancels anything, and make the expiry job skip orders with a payment in an unknown or processing state. Alert on webhook error rates.
A wave of small declined payments at 3 a.m.
Problem
Hundreds of 1.00 orders with different cards hit the checkout overnight; most are declined. The provider warns about a high decline rate on the account.
How to solve it
This is card testing: fraudsters checking stolen cards through your checkout. Respond in layers. Immediately, rate-limit payment attempts per IP, per device, per account and per cart, and add a challenge (CAPTCHA or a bot-protection service) after repeated failures. Require login or a verified phone for checkout if the attack continues. Enable the provider's fraud rules, such as blocking when the CVC or postcode check fails, and set a minimum order value. Cancel the orders created by the attack and refund any successful ones, since they will become chargebacks. Afterwards, add monitoring on decline rate per hour so the next attack triggers an alert within minutes.
Adding a second payment provider
Problem
The business wants a local bank gateway next to Stripe for customers whose cards fail internationally. Payment code currently calls Stripe directly from the checkout controller.
How to solve it
Introduce a provider-agnostic payment interface first: createSession, getStatus, refund and parseWebhook, returning your own payment states rather than provider-specific ones. Wrap the existing Stripe code in an adapter behind that interface, and ship that refactor alone with no behaviour change. Then add the bank adapter, which will likely be redirect-based and need status polling instead of rich webhooks. Store the provider name on every payment attempt, route refunds to the provider that captured the money, and let reconciliation iterate over providers. A feature flag can route a percentage of customers or specific card countries to the new gateway while you watch success rates.
Cheat sheet
- Forward webhooks to a local server
stripe listen --forward-to localhost:4242/webhooks/payments- Trigger a test event
stripe trigger payment_intent.succeeded- Resend a past event to an endpoint
stripe events resend evt_123 --webhook-endpoint=we_123- Idempotent API call (curl)
curl https://api.stripe.com/v1/refunds -u sk_test_...: -H "Idempotency-Key: refund-order-1042-1" -d payment_intent=pi_123- Deduplicate webhook events
CREATE UNIQUE INDEX processed_events_uq ON processed_events (provider, event_id);- Find stuck payments
SELECT id FROM payments WHERE status IN ('pending','requires_action','unknown') AND updated_at < now() - interval '15 minutes';- Apple Pay domain verification file
https://example.com/.well-known/apple-developer-merchantid-domain-association- Stripe test card that requires 3DS
4000 0027 6000 3184 (any future expiry, any CVC)
In my real projects
- On a restaurant-chain ordering platform I integrated card payments with saved cards, Apple Pay, Google Pay, several local wallets (one through a custom native Android module), POS terminal and cash — nine payment methods behind one order flow.
- On a multi-tenant POS SaaS the payments module has gateway sessions, saved cards, a ledger, refunds including an "outcome unknown" state, callbacks and a reconciliation job, plus idempotency keys for partner orders.
- I have also integrated Stripe and PayPal with webhooks, a bank gateway with signed requests and pending-payment reconciliation, and per-item refunds through another card gateway.
Questions & answers
Q1Why can't you trust the success redirect?
The redirect happens in the customer's browser, which you do not control. The tab can close before it fires, the network can fail, and a user can simply open the success URL by hand. So the redirect only shows a friendly page. The real decision comes from the provider: a signed webhook or a server-to-server status query using your payment ID. Only that confirmation moves the order from pending to paid. If the webhook is late, the success page can poll your backend until the status is final.
Q2What is idempotency and why does it matter in payments?
An operation is idempotent when doing it twice has the same effect as doing it once. Payments are full of retries: providers resend webhooks, clients retry on timeouts, queues redeliver jobs. Without idempotency, a retry can charge twice or refund twice. On outgoing calls I send an idempotency key tied to the order and attempt, so the provider returns the original result. On incoming webhooks I store event IDs with a unique constraint and skip duplicates, and state-machine guards prevent applying the same transition twice.
Q3How do you handle a payment request that timed out?
A timeout means the outcome is unknown: the charge may have succeeded. I mark the attempt as outcome unknown and do not create a new charge. If the provider supports it, I retry with the same idempotency key, which safely returns the original result. Otherwise I query the payment status by my reference. A reconciliation job also picks up anything still unknown after a few minutes. The customer sees "we're confirming your payment" instead of an error that tempts them to pay again.
Q4What is 3-D Secure and how does it affect your flow?
3-D Secure is an authentication step run by the customer's bank, usually a banking-app confirmation or a one-time code. EMV 3DS sends device and transaction data so low-risk payments pass without a challenge. In Europe, PSD2 strong customer authentication makes it mandatory for most card payments; elsewhere it reduces fraud and usually shifts fraud chargeback liability to the issuer. In code, it means a payment can enter a requires-action state and complete asynchronously, so the flow must rely on webhooks and handle abandoned challenges.
Q5How do you reduce PCI DSS scope?
By making sure card data never touches your servers. Use a hosted payment page, or hosted fields and SDK components that render inside the provider's iframes, and store only tokens for saved cards. Then a typical e-commerce merchant can use SAQ A, the shortest self-assessment questionnaire. Under PCI DSS 4.0.1 you still have to confirm your site is protected against script attacks that could affect the payment page, so control third-party scripts, use a strict Content Security Policy, and keep dependencies updated.
Q6What does a reconciliation job do?
It is a scheduled safety net that makes your records agree with the provider's. It selects payments that are pending, requiring action or outcome unknown for longer than expected, queries the gateway for each, and applies the real status through the same idempotent code path as webhooks. A second part compares the provider's settlement or transaction report with your ledger by date and flags mismatches: missing payments, amount differences, refunds without records. Anything it cannot fix automatically becomes an alert for a human.
Senior-level questions
S1How do you keep the order state and the payment provider consistent without distributed transactions?
I accept that they are two systems and design for eventual consistency with clear ownership. The provider owns the truth about money; my database owns the truth about orders. Every outgoing call carries an idempotency key, so retries are safe. Every incoming signal — webhook, status query or reconciliation result — goes through one idempotent function that maps provider states to mine and applies state-machine transitions. When I cannot know the outcome, I record "unknown" instead of guessing. A reconciliation job closes the loop on a schedule. The trade-off is that the system is briefly inconsistent, measured in seconds or minutes, so the UI must show honest intermediate states such as "confirming payment" rather than pretending everything is instant.
S2Authorise and capture later, or capture immediately?
Immediate capture is simpler and fits shops that ship what is in stock or sell digital goods. Separate authorisation and capture fits cases where the final amount or availability is uncertain: weighed goods, restaurants that may reject an order, marketplaces waiting for a seller, or hotels. Capturing only what you deliver avoids refunds, which cost fees and take days to reach the customer, while voiding an authorisation releases the hold quickly. The costs are complexity and expiry: authorisations expire, commonly after about seven days for online card payments, depending on network and provider, so you need a job that captures or voids in time, and partial captures must be supported by your provider and gateway. I choose per business flow, not globally.
S3How would you design refunds for a system with promotions, wallets and several payment methods?
A refund is first a business calculation, then a set of money movements. The calculation uses the order's snapshots: the line's paid amount after its allocated discount, its tax, and any delivery fee policy. Then the amount is split across the original tenders in a defined order — usually back to the card first, then the wallet or store credit — never more than each tender captured. Each part becomes its own refund record with an idempotency key and is executed against its provider; wallet parts are ledger entries. Cashback granted on the order is reversed proportionally. Partial failures are expected, so the refund has its own state and reconciliation. The difficult part is agreeing the business rules up front, especially for discounts that depend on the order total.
Glossary
- Acquirer
- The merchant's bank or processor that accepts card payments and settles funds to the merchant.
- Issuer
- The customer's bank that issued the card and approves or declines each payment.
- Authorisation
- The issuer reserving funds for a payment without moving them yet.
- Capture
- Actually taking previously authorised funds; can be full or partial.
- Settlement
- The payout of captured funds, minus fees, from the provider to the merchant's bank account.
- Chargeback
- A payment reversal initiated by the cardholder's bank, usually with a fee and an evidence deadline.
- Liability shift
- Moving responsibility for fraud losses from merchant to issuer, typically after successful 3-D Secure authentication.
- Tokenisation
- Replacing card details with a token that only the provider can turn back into a charge.
- Idempotency key
- A unique value sent with a request so that retries return the first result instead of repeating it.
- SAQ A
- The shortest PCI self-assessment, for merchants who fully outsource card data handling to a compliant provider.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.