Payment-gateway integration patterns
Taking money is the part of e-commerce you cannot afford to get wrong. A deep-dive on integrating a payment gateway correctly — tokenization, idempotency keys, webhooks as the source of truth, and modelling a payment as a state machine.
Of all the code in an e-commerce app, the payment integration is the part where bugs cost real money and erode the one thing a store cannot operate without: trust. It is also full of non-obvious traps that only surface in production, under concurrency, when a network call times out at the worst moment. Having integrated Stripe, Braintree, and others, here are the patterns that make payment code correct and robust — and the mistakes that make it dangerous.
Rule one: never touch card data
The first and most important rule is about what your code must not do. You do not want raw card numbers anywhere near your servers, because the moment they touch your infrastructure you fall into the full scope of PCI-DSS compliance — an enormous, expensive burden — and you become a target. The answer is tokenization: the card details go directly from the customer’s browser to the gateway (via the gateway’s JavaScript, e.g. Stripe Elements), which returns a token representing the card. Your server only ever sees that opaque token, never the number:
// in the browser — card data goes to Stripe, never to your server
stripe.createToken(cardElement).then(result => {
form.append(hiddenInput("stripe_token", result.token.id));
form.submit(); // your server receives a token, not a card number
});
# server — charge using the token; you never saw the card
Stripe::Charge.create(
amount: order.total_cents,
currency: order.currency,
source: params[:stripe_token],
)
This single architectural choice — card data never reaches you — drops you from “full PCI audit” into the lightest compliance tier and removes the worst class of breach risk. Treat it as non-negotiable.
Rule two: idempotency keys, or you will double-charge
Here is the failure that destroys naive payment code, and it is the same shape we met building a trading bot: you send a charge request, the network times out, and you do not know whether the charge succeeded. Retry blindly and you may charge the customer twice. The defence is an idempotency key — a unique key you generate per charge attempt and send with the request. The gateway uses it to guarantee the operation happens at most once; a retry with the same key returns the original result instead of charging again:
Stripe::Charge.create(
{ amount: order.total_cents, currency: order.currency, source: token },
{ idempotency_key: "order-#{order.id}-charge" }
)
Every modern gateway supports this, and you must use it. A charge without an idempotency key is a double-charge waiting for a flaky connection. Derive the key from something stable about the intent (the order), so the same logical payment always carries the same key.
Rule three: webhooks are the source of truth, not the HTTP response
This is the pattern that separates robust integrations from fragile ones. The synchronous response to your charge request is not a reliable record of what happened. The request can time out after the charge succeeded; a payment can be authorised now and captured later; a bank transfer or 3D-Secure flow completes asynchronously, minutes after the customer leaves. If you only update your order based on the immediate API response, you will miss state changes and get out of sync with the gateway.
The robust design treats the gateway’s webhooks as the authoritative event stream. The gateway calls your endpoint when a payment actually succeeds, fails, is refunded, or is disputed, and those events drive your order state:
def stripe_webhook
event = verify_signature(request) # ALWAYS verify the signature
case event.type
when "charge.succeeded" then mark_order_paid(event.data.object)
when "charge.failed" then mark_order_failed(event.data.object)
when "charge.refunded" then mark_order_refunded(event.data.object)
when "charge.dispute.created" then flag_dispute(event.data.object)
end
head :ok
end
Three disciplines make webhooks safe: verify the signature (the endpoint is public; an unverified webhook is an attacker telling you an order is paid), make handlers idempotent (gateways retry webhooks and may deliver the same event more than once), and acknowledge fast then do heavy work in a background job (a slow webhook response triggers more retries). The synchronous response is a hint for the user’s immediate experience; the webhook is the truth your data records.
Model the payment as a state machine
A payment is not a boolean; it is a lifecycle, and modelling it as one prevents a class of
bugs. A charge moves through states — pending → authorized → captured → refunded, with
branches to failed and disputed — and your order should explicitly track which state it is
in, with only legal transitions allowed:
class Payment < ApplicationRecord
# pending -> authorized -> captured -> refunded
# \-> failed \-> disputed
state_machine :status, initial: :pending do
event(:authorize) { transition pending: :authorized }
event(:capture) { transition authorized: :captured }
event(:refund) { transition captured: :refunded }
event(:fail) { transition [:pending, :authorized] => :failed }
end
end
Explicit states make the webhook handlers simple (each event triggers a transition), make illegal situations impossible to represent (“captured then back to pending” cannot happen), and give you an honest audit trail of what occurred. It also forces you to think about the asynchronous, multi-step nature of payments rather than pretending a charge is one atomic boolean event.
Store references, never instruments; handle failure as normal
Two more essentials. Store gateway references, not payment instruments — keep the charge ID, the customer token, the last four digits for display, but never the card. And treat failure as a normal path, not an exception: cards get declined constantly, for reasons you cannot see (insufficient funds, fraud rules, expired cards). A good integration surfaces a clear, recoverable message (“your card was declined, try another”) and leaves the order in a state the customer can retry from — not a 500 error and an order in limbo. The unhappy path is the common path in payments; design for it deliberately.
Verdict
Payment integration is where care matters most, because the failures are measured in money and trust. Five patterns make it correct: keep card data off your servers via tokenization (PCI and breach risk); use idempotency keys on every charge so a timeout never becomes a double-charge; treat webhooks — signed, idempotent, fast-acknowledged — as the source of truth rather than the synchronous response; model the payment as an explicit state machine so illegal states cannot occur and the asynchronous lifecycle is honest; and handle declines as a normal, recoverable path. Get these right and your payment flow is robust against the network failures and concurrency that will happen. Skip them and you have built, sooner or later, a way to charge a customer twice at the worst possible moment.