Design a mobile payment flow — tokenization, PCI scope, and failure handling
A senior walkthrough of how a mobile client can accept card payments without ever touching a raw card number — tokenized SDK fields, 3-D Secure, idempotent retries, and reconciliation.
LEARN BY DOINGPut your intuition to work3 steps
Design a mobile payment flow — tokenization, PCI scope, and failure handling
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarifying the requirements
Before drawing any boxes, I'd pin down what kind of payment this is, because 'add a payment flow' hides a lot of very different problems. Is this a one-time card-not-present purchase, or does it need to save a card for subscriptions and one-tap reorders? Which markets — because the EU's PSD2 requires Strong Customer Authentication via 3-D Secure 2 on most transactions, while a US-only app can often skip that friction? Do we need to support Apple Pay and Google Pay alongside raw card entry? Is this a single merchant taking payments, or a marketplace that needs to split funds between a platform fee and a seller payout? And what's the refund and dispute story, since chargebacks touch the same architecture from the other direction? Those answers decide whether I'm designing a thin client around a payment processor's SDK or a much heavier ledger and split-payment system.
#The one rule that shapes everything: keep the PAN off your servers
The single most important architectural decision in any mobile payment flow is that the raw card number — the PAN — should never reach the app's own backend, or even sit in the app's own memory longer than it takes to hand it to a processor's SDK. Every major processor (Stripe, Braintree, Adyen) ships mobile SDKs that render the card fields themselves, either as native secure input components or a processor-hosted web view, and those fields tokenize the card directly against the processor's PCI-certified infrastructure over TLS. The app never sees the PAN — it only ever receives back an opaque, single-use token or a reusable payment-method reference. That one design choice is what collapses your own PCI DSS compliance burden from a full SAQ D audit (imagine hosting card data yourself) down to an SAQ A or SAQ A-EP self-assessment, because the card data path never touches infrastructure you control.
#Architecture: token in, charge out
With that constraint in place, the flow looks like this: the mobile client renders the processor's secure card field and calls its SDK to tokenize, receiving back a payment-method token. The app sends that token — never the PAN — to its own backend along with the amount and a client-generated idempotency key. The backend calls the processor's server-side API to create a payment intent using that token, and the processor is the one that decides whether the transaction needs a 3-D Secure challenge. If it does, the backend hands a client secret back to the app, which asks the processor's SDK to present the challenge UI (biometric confirmation or an OTP, often inside a web view the SDK manages). Once the issuer confirms, the processor settles the charge and fires a webhook at the backend — and that webhook, not the mobile client's optimistic assumption, is the actual source of truth for whether money moved.
#Apple Pay and Google Pay as a shortcut
Wherever the platform allows it, I'd push users toward Apple Pay or Google Pay before raw card entry, because both sidestep the PAN problem even more thoroughly. The device's secure element stores a device account number rather than the real PAN, biometric authentication happens entirely on-device, and the merchant receives an encrypted payment token that the processor decrypts — the app itself never has access to any card-equivalent data at all. It's also lower-friction for the user, which matters directly for conversion, so from a product standpoint it's usually the default path rather than a secondary option buried in settings.
#Idempotency and failure handling
Mobile networks drop mid-request constantly, and a payment is the one place where a naive retry can cost a user real money. The client has to generate an idempotency key before the first attempt and send the same key on every retry of that logical payment, so that if the request actually reached the processor and succeeded, a retry after a timeout returns the original result instead of creating a second charge. I'd model the payment itself as a small state machine — pending → requires_action → succeeded / failed — persisted by the processor, and treat the mobile client as a viewer of that state rather than the owner of it: on any ambiguous outcome (timeout, app killed mid-flow, network partition), the right move is to query the payment's current status by its intent ID, not to assume failure and let the user tap pay again. I'd also persist an in-flight order locally before the request goes out, so that if the app is killed mid-payment, the next launch can reconcile that pending order against the backend instead of silently losing track of it.
#Reconciliation: the ledger is the truth, not the app
A payment isn't done when the app shows a success screen — it's done when the backend's ledger and the processor's records agree. I'd rely primarily on webhooks for that agreement, but never exclusively, because webhooks can be delayed, deduplicated incorrectly, or dropped during an incident; a background reconciliation job that polls the processor for any payment stuck in a non-terminal state past a threshold (say, five minutes) closes that gap. The ledger itself should be append-only and double-entry — every charge, refund, and fee recorded as a pair of offsetting entries — so a reconciliation mismatch is a query away from being found rather than something that surfaces three months later as a finance discrepancy.
#Tradeoffs I'd name
Building your own card-collection UI instead of using the processor's hosted fields buys you pixel-perfect design control but drags your own app back into full PCI scope — almost never worth it. 3-D Secure reduces fraud and chargeback liability but adds a challenge step that measurably hurts conversion, so I'd only force it where regulation requires it (EU/UK) and let the processor's risk engine decide elsewhere. And betting entirely on webhook delivery is simpler to build than a polling-based reconciliation job, but it trades a small amount of engineering effort for the risk of silently stuck payments — for anything involving real money, I'd always pay that extra engineering cost.