cf.completefrontend
My progress
SYSTEM DESIGN · #57

Design a secure payment form

The card-entry component a payment provider ships to merchants, like Stripe's Payment Element or Braintree Hosted Fields. The hard parts are keeping card numbers out of the merchant's page while still letting the merchant style it, reacting to every keystroke with formatting and brand detection across a frame boundary, and confirming a payment exactly once when 3-D Secure, wallets, and flaky networks get in the way.

The question

You work at a payment provider. Design the card form we give to merchants: a merchant adds our script and a few lines of code, and their customers can type a card number, expiry date, and security code on the merchant's own checkout page, styled to look like the rest of the site.

The card number must never reach the merchant's page or servers, so the merchant stays in the lightest PCI DSS scope. Walk me through how the form is built, how the merchant talks to it, how card data becomes something the merchant can charge, and what happens with 3-D Secure, Apple Pay, double clicks, and a network that drops while the payment is being confirmed.

Real-life examples: Stripe Payment Element and Card Element, Braintree Hosted Fields, Adyen Secured Fields, Checkout.com Frames (replaced by Flow in 2026)

Requirements: whose form is this?

Start by pinning down who you are. This guide is written from the payment provider's side: we build the secure card form, publish it as a script, and thousands of merchants embed it in their checkout pages. Stripe's Payment Element and Card Element, Braintree Hosted Fields, Adyen Secured Fields, and Checkout.com Frames (retired in June 2026 in favour of Checkout.com's Flow) are all this product. The merchant is our customer, and the shopper is our customer's customer.

That framing matters because the merchant's checkout page (the cart summary, the shipping address, the order total, the place-order button) is a different problem. The checkout-flow guide covers it: the checkout session, server-side pricing, idempotent order placement, picking a hosted payment page or embedded fields, decline messages, the 3-D Secure return URL, and the Content Security Policy of the checkout page. This guide opens the box that guide treats as a black box: the inside of the card form.

A good interviewer will let you pick the framing, but will expect you to say it out loud. If you design the merchant's page instead, you will spend the hour on things the interviewer did not ask about.

Wireframe of a merchant checkout page at shop.example with our embedded card form. At the top, a dashed wallet frame holds Apple Pay and Google Pay buttons. Below, the words or pay with card, then the merchant's own Email field. Then three dashed boxes, each one of our iframes: Card number showing 4242 4242 4242 4242 with a Visa logo, Expiry showing 12 / 28, and CVC showing 123. At the bottom, the merchant's own Pay 81.40 button. Notes on the right say dashed boxes are our iframes on js.pay.example, solid boxes are the merchant's page, and the parent gets state, never digits.shop.example/checkoutApple PayGoogle Payor pay with cardEmailana@mail.exampleCard number4242 4242 4242 4242VISAExpiryCVC12 / 28123Pay 81.40dashed boxes:our iframesjs.pay.examplesolid boxes:merchant pageshop.exampleparent gets state,never digits
To the shopper it is one form. To the browser it is the merchant's document with four of our documents inside it. The Email field and the Pay button belong to the merchant; everything that touches card data is a dashed box.

The questions that change the design

QuestionAnswer we assumeWhat it changes
Who embeds the form?Any merchant, from a one-person shop on a template to a large retailer with its own front-end team and its own Content Security Policy.The form must work on pages we do not control, next to scripts we have never seen.
Must card data stay out of the merchant's page?Yes. The merchant's page must never be able to read the card number or security code.The fields live in our own cross-origin iframes. This single answer drives most of the design.
One field or separate fields?Both: a single combined card field and split fields (number, expiry, security code) that the merchant places anywhere in their layout.Split fields mean several iframes that must coordinate with each other.
How much styling control?Fonts, colours, sizes, focus and error colours, placeholder text. No arbitrary CSS.We need a style object that crosses the frame boundary and is applied inside the frame.
Which payment methods?Cards of the major networks, plus Apple Pay and Google Pay buttons.Wallets have their own browser APIs and their own domain rules.
Which regions?Global, including the European Economic Area, where Strong Customer Authentication (SCA) rules, which require two independent checks of the payer for many online payments, make banks ask for 3-D Secure.The form must run a bank challenge inside the merchant's page and resume afterwards.
Who confirms the payment?The merchant's server creates a payment on our API. The browser confirms it with the card through our script.A clear split between what the browser may do with a publishable key and what needs the secret key.
What devices and networks?Mobile first. Many shoppers pay on a phone, on a train, with a weak signal.A small script, numeric keyboards, autofill, and confirmation that survives a dropped connection.

Functional requirements

  • A merchant loads one script from our domain, creates the form with a publishable key (a public key that identifies the merchant and is safe to put in a web page), and mounts it into an element on their page.
  • The shopper types a card number, expiry date, and security code (CVC). The number is formatted with spaces as they type, the card brand logo appears after the first digits, and errors appear at the right moment.
  • The merchant gets state, not data: whether each field is empty, complete, or invalid, the detected brand, and an error message. Never the digits.
  • On submit, the form sends the card to our servers and the merchant gets back a token or payment method id that their server can charge.
  • If the bank asks for 3-D Secure, the form shows the bank's challenge and continues when it is done.
  • Apple Pay and Google Pay buttons appear when the shopper's device supports them.
  • The merchant can style the fields to match their page and listen to events (ready, focus, blur, change).

Non-functional requirements

  • Isolation: no script on the merchant's page, including a malicious one, can read what the shopper types. This is the requirement everything else bends around.
  • Responsiveness: formatting and brand detection run on every keystroke inside the frame, well within one frame of 16 ms. Nothing that reacts to typing may wait for the network.
  • Fast to appear: the fields should be visible and typeable within about one second on a mid-range phone on 4G (our target, not a standard). The loader script should be small, with the heavy parts loaded only when the form is mounted.
  • Exactly-once confirmation: a double click, a retry, or a dropped connection must never charge the shopper twice.
  • Accessible: labels, errors, and focus order work with screen readers and keyboards even though the inputs live in other documents.
  • Compatible: current Chrome, Safari, Firefox, and Edge on desktop and mobile, including in-app browsers that block third-party cookies.

Out of scope, and where it lives

  • The merchant's checkout page, tax, and placing the order: see checkout-flow. The cart before it, with guest carts and price changes: see shopping-cart.
  • How 3-D Secure works between the card networks and banks: see amazon. Here we only cover how the form hosts it.
  • One-time codes typed into a bank's challenge: see otp-input.
  • Multi-page form state and form builders: see multi-step-form and form-builder.
  • Shopper accounts, sign-in, and the session that lets a merchant show saved cards: see auth-flow. Our form never handles sign-in; it shows saved cards only when the merchant's server says which customer is paying.
  • The general SDK pattern of a cross-origin iframe plus a postMessage bridge: see embeddable-widget. We build on it and focus on what payments add.
  • The backend: the card vault, the card network connections, settlement, and payouts.

Check your understanding · Exercise 1

Which answers from the merchant or interviewer would change the design of the card form itself? Select all that apply.

What makes this hard

A card form looks like three text inputs. Building one that a bank, a merchant, and an attacker would all accept is a different job. Five properties of the problem push every decision in the rest of this guide.

  1. It runs in a hostile house. Our code runs inside a page we do not own. That page may load 30 third-party scripts (analytics, chat, A/B testing, ads), any of which could be compromised. We must assume the parent page is hostile and still protect the card number.
  2. It is split across documents. Isolation means the inputs live in our iframes, on our origin. But the merchant wants the fields to look native, sit in their grid, follow their focus order, and report errors next to their own labels. Every one of those needs a message across a frame boundary.
  3. It is regulated. PCI DSS (the Payment Card Industry Data Security Standard) decides how much security paperwork and testing the merchant must do. Our architecture is the reason the merchant can fill in a short questionnaire instead of a long audit. A design change on our side can move thousands of merchants into a heavier scope.
  4. It reacts to every keystroke. Formatting, brand detection, and validation happen as the shopper types, and the caret must stay where the shopper expects it. Getting this wrong on a phone is the difference between a sale and an abandoned cart.
  5. It must charge exactly once. A confirm can involve our API, the card network, the shopper's bank, a 3-D Secure challenge in yet another iframe, and a redirect. Any of these can stall or fail. The shopper may click twice or reload. The money must move once.

Notice that the first three are about trust boundaries and the last two about user experience. Most of the interesting trade-offs are where they meet: splitting the form into many frames is safer and more flexible, but it makes autofill, focus, and accessibility harder. Keep that tension in mind; it comes back in nearly every chapter.

Think first

If the parent page is hostile, why not just ask merchants to keep their checkout page free of third-party scripts?

PCI DSS scope: the requirement that shapes the architecture

Every merchant that accepts cards must show its bank that it follows PCI DSS, the security standard run by the PCI Security Standards Council (PCI SSC). Small and mid-size merchants usually prove it with a Self-Assessment Questionnaire (SAQ).

Which questionnaire they fill in depends on one thing: how close their systems get to card data. The shortest questionnaire has a few dozen requirements; the longest has a few hundred, plus scans and penetration tests. Our product exists largely to keep merchants on the short one.

Start with the naive design to see why. The merchant renders a normal HTML form with an input named cardNumber, and posts it to their own server, which calls our API. It works on day one.

But now the merchant's server receives, processes, and transmits card numbers, so the merchant is in full scope (SAQ D): network segmentation, logging, quarterly scans, key management. And every script on the page can read input.value. For a small shop, that is fatal.

Three bars of growing height show PCI DSS self-assessment scope. The short bar is SAQ A, the lightest, for a redirect to a hosted page or provider iframe fields. The middle bar is SAQ A-EP, a longer list, for provider script with inputs in the merchant's DOM. The tallest bar is SAQ D, the full standard, for a merchant server that receives the card. An arrow across the top says more contact with card data means more requirements.more contact with card data, more requirementsSAQ AlightestSAQ A-EPlonger listSAQ Dfull standardredirect or iframesour frames hold inputsinputs in their DOMour script posts cardtheir server sees itcard in their logs
Scope follows contact with card data. Our iframe architecture is what lets a merchant stay on the shortest bar; inputs in the merchant's own page move them up a step even though the card still goes straight to us.

Decision How should card data get from the shopper to the provider?

Redirect to a hosted payment page

The merchant sends the shopper to a page on the provider's domain (like Stripe Checkout) with a 30x redirect or link.

The shopper pays there and is sent back to a return URL.

Pros

  • Lightest scope: SAQ A, and the new script criterion does not apply to full redirects.
  • The provider owns the whole page, so no merchant script runs next to the card fields.

Cons

  • The shopper leaves the merchant's site; the merchant has limited control over layout and flow.
  • Some merchants see a drop in conversion from the extra page and the change of domain (varies by store; measure it).

Provider fields in cross-origin iframes

The merchant's page loads the provider's script, which inserts iframes served from the provider's domain.

The inputs live inside the iframes; the parent only receives state and, at the end, a token.

Pros

  • SAQ A for the merchant, if all payment page elements come from a PCI compliant provider and the site is protected from script attacks.
  • The fields sit in the merchant's own layout and can match its design.

Cons

  • Scripts on the parent page can still attack around the frames (overlays, swapping the frame), which is why the extra eligibility criterion exists.
  • Hardest for us to build: several documents, a message protocol, focus and accessibility across frames.

Provider script draws fields in the merchant's DOM

Our script creates normal inputs in the merchant's page and posts the card straight from the browser to our API (direct post or JavaScript encryption).

Pros

  • Simple to style: it is just the merchant's CSS.
  • Card data still never touches the merchant's server.

Cons

  • The merchant's page controls the inputs, so the merchant moves to SAQ A-EP, a much longer questionnaire.
  • Any script on the page can read the inputs. One compromised analytics tag skims every card.

What we pick: Ship cross-origin iframe fields as the main product and a hosted page as the simple alternative. Iframes are the only option that gives merchants both their own layout and the lightest scope. The design work in the rest of this guide is the price of that combination. Never ship inputs in the merchant's DOM as a default: it silently moves every merchant who uses it into SAQ A-EP.

PCI DSS v4: why iframes alone are no longer the whole story

Magecart-style attacks (skimming scripts injected into checkout pages, covered in the security chapter) taught the industry that the parent page matters even when the card fields are in a frame. A malicious script on the parent cannot read inside our frame, but it can hide our frame and draw a fake form over it, or point the frame at a look-alike domain. PCI DSS v4.0 answered with two requirements for payment pages. They were future-dated: best practice until 31 March 2025, then mandatory.

  • Requirement 6.4.3 (script management): every script loaded and run on the payment page must be authorized, must have its integrity assured, and must be listed in an inventory with a written reason why it is needed.
  • Requirement 11.6.1 (change and tamper detection): a mechanism must alert staff to unauthorized changes to the security-impacting HTTP headers and the script contents of the payment page as the shopper's browser receives it, run at least weekly or at a frequency set by the merchant's targeted risk analysis.

Both apply to the page that hosts our iframe, not only to pages with raw inputs: that is the parent page an attacker would tamper with. For SAQ A merchants, the PCI SSC changed course in January 2025. The revised SAQ A for PCI DSS v4.0.1 removed 6.4.3 and 11.6.1 from the questionnaire and added an eligibility criterion instead: "The merchant has confirmed that their site is not susceptible to attacks from scripts that could affect the merchant's e-commerce system(s)."

PCI SSC FAQ 1588 (February 2025) then said a merchant can meet it in one of two ways: use techniques such as those in 6.4.3 and 11.6.1 (their own or a vendor's), or get confirmation from their PCI compliant provider that the embedded form, set up as instructed, includes techniques that protect the payment page from script attacks. The criterion applies to embedded forms such as iframes, not to full redirects.

Simplified. The merchant's bank (acquirer) decides which questionnaire applies; always check with it.
Merchant setupUsual questionnairePayment page script rules
Redirect or link to the provider's hosted pageSAQ ANot applicable to the merchant's page.
Provider iframe fields on the merchant's pageSAQ AEligibility criterion: the site must be protected from script attacks (FAQ 1588 options).
Provider script, inputs in the merchant's DOM, direct postSAQ A-EP6.4.3 and 11.6.1 apply to the payment page.
Merchant server receives the cardSAQ D6.4.3 and 11.6.1 apply, plus the full standard.

This turns into concrete product work for us. Our script must be loaded from our domain, never bundled or self-hosted by the merchant, so it is one known entry in their script inventory. We publish the exact list of origins our frames and script need, so merchants can write a strict Content Security Policy. Our frames send Content-Security-Policy: frame-ancestors only where it helps (more in the security chapter), and we document the setup the merchant must follow for our confirmation under FAQ 1588 to hold.

Check your understanding · Exercise 2

A merchant uses our iframe fields and qualifies for SAQ A. Which changes could push them into a heavier questionnaire? Select all that apply.

Hosted fields: frames, a controller, and a message protocol

We have decided the inputs live in iframes served from our origin, say https://js.pay.example. The same-origin policy then does the heavy lifting: a script on https://shop.example cannot read the DOM, the input values, or the JavaScript variables of a document from another origin. Everything the two sides share must go through window.postMessage, which sends a copy of a plain object to another window and lets the receiver check who sent it. The embeddable-widget guide covers this pattern for any third-party widget; here we look at what payments add.

Decision How many iframes should the form use?

One iframe for the whole form

A single frame holds number, expiry, CVC, and maybe postal code, laid out by us.

Stripe's single-line Card Element and its Payment Element work this way.

Pros

  • Browser autofill fills all card fields at once, because they are in one document.
  • Tab order, focus, and validation stay inside one document: simple and fast.
  • One frame to load and size.

Cons

  • The merchant cannot put the expiry field in a different column or interleave their own fields.
  • Our layout must adapt to every container width the merchant gives us.

One iframe per field, plus a hidden controller frame

Each input (number, expiry, CVC) is its own small frame placed by the merchant.

A hidden frame on our origin, the controller, holds shared state, talks to our API, and is the only frame that sends the card to the vault.

Braintree Hosted Fields and Stripe's split card elements put each field in its own frame, and Stripe.js also adds a hidden controller frame to the page.

Pros

  • Full layout freedom for the merchant: fields go anywhere, between their own inputs.
  • Each frame is tiny and does one job.

Cons

  • Autofill must be handled on purpose, because a browser may not fill fields across several cross-origin frames.
  • Tab order crosses frame boundaries; focus and screen reader behaviour need care.
  • More messages, more ways for one frame to be missing or late.

One iframe per field, each talking to the API on its own

No controller. Each field frame sends its own value to the vault and gets a partial token back.

Pros

  • No coordinating frame to build.

Cons

  • The server must stitch three partial tokens together, with races and expiry.
  • Cross-field rules (CVC length depends on the brand in the number field) need the frames to talk anyway.
  • Three network calls on submit instead of one.

What we pick: Offer the first two, built from the same field code. The single-frame form is the default because autofill and accessibility come almost free. Split fields are for merchants who need layout control, and they need a controller frame: one place for shared state, one network call to the vault, one owner of the card data. Reject the third: it moves the coordination problem to the server and makes it worse.

Architecture of the payment form in four zones. The merchant zone at shop.example holds our SDK pay.js and the merchant's checkout code, which calls the SDK. Our frames at js.pay.example hold the number, expiry, and CVC field frames, which send digits to a hidden controller frame on the same origin. The SDK sends commands to the field frames and gets state and ids back from the controller. Our servers at api.pay.example hold the API and the vault; the controller sends the card and gets a payment method id. The merchant server holds the secret key, creates the payment on our API, and gives the checkout code its client secret.Merchant: shop.exampleSDK pay.jsstate, never digitscheckout codemerchant's JSOur frames: js.pay.examplenumberexpiryCVCcontrollerhidden, owns statedigits stay on this origincommandsstate, idsOur servers: api.pay.examplevaultAPIpaymentscard in, pm_ id outMerchant serverorders APIholds sk_ secret keyclient secretcreate, sk_
Card digits only move inside the right-hand zones, all on our origins. The SDK runs in the merchant's page, so it is treated like any other script there: it gets change state and a pm_ id, never digits.

Who talks to whom

  • Merchant page to our SDK: plain function calls. The SDK is our script, running in the merchant's origin, so it is just as exposed as any other script there. It must never hold card data.
  • SDK to frames: postMessage with a channel id. The SDK sends commands (mount, update style, focus, confirm) and receives events (ready, change, focus, blur, resize, result).
  • Field frames to controller: postMessage between two documents on our origin, with targetOrigin set to our exact origin. Card digits travel only on this path.
  • Controller to our API: fetch over HTTPS with the publishable key. The response is a token or payment method id.

The SDK creates every frame with a URL like https://js.pay.example/field.html#ch=8f3c...&parent=https://shop.example. The channel id (ch) is a random value the SDK makes for this form instance. It ties messages to one form, so two forms on the same page (a card form and a saved-card form) do not mix up their events.

It sits in the URL fragment, which the browser does not send to our server, so it stays out of logs. The parent origin in the fragment tells the frame where to send its events.

ts
type Field = "number" | "expiry" | "cvc";
type Brand = "visa" | "mastercard" | "amex" | "discover" | "unknown";

// Frame (or controller) to the merchant's page
type ToParent =
  | { ch: string; v: 1; type: "ready"; field: Field }
  | { ch: string; v: 1; type: "change"; field: Field;
      empty: boolean; complete: boolean; brand?: Brand;
      error?: { code: "incomplete" | "invalid_number" | "expired"; message: string } }
  | { ch: string; v: 1; type: "focus" | "blur" | "submit"; field: Field }
  | { ch: string; v: 1; type: "resize"; field: Field; height: number }
  | { ch: string; v: 1; type: "wallets"; applePay: boolean; googlePay: boolean }
  | { ch: string; v: 1; type: "result"; requestId: string;
      status: "succeeded" | "processing" | "redirecting" | "failed";
      paymentId?: string; paymentMethodId?: string; errorCode?: string };

// Merchant's page to the frames
type FromParent =
  | { ch: string; v: 1; type: "style"; style: StyleObject }
  | { ch: string; v: 1; type: "focus" | "clear"; field: Field }
  | { ch: string; v: 1; type: "confirm"; requestId: string; clientSecret: string };
The message schema. Anything that crosses to the parent is state, never digits.

Three rules make the protocol safe. First, check the sender on every message: the frame accepts commands only when event.source === window.parent and event.origin equals the parent origin it was created for, the controller accepts digits only when event.origin is our own origin, and the SDK accepts events only from frames it created on our origin. Second, always pass an exact targetOrigin when posting, never "*", so the browser drops the message if the receiving window has been navigated elsewhere.

Third, design the schema so there is no message that could carry the card, even by mistake: no generic getValue, no debug echo. The brand and complete flags are enough for the merchant to build a good form.

ts
const params = new URLSearchParams(location.hash.slice(1));
const channel = params.get("ch")!;
const parentOrigin = params.get("parent")!;

window.addEventListener("message", (event) => {
  if (event.source !== window.parent) return;
  if (event.origin !== parentOrigin) return;
  const msg = event.data as FromParent;
  if (msg?.ch !== channel || msg.v !== 1) return;
  handleCommand(msg);
});

function emit(msg: ToParent) {
  window.parent.postMessage(msg, parentOrigin);
}
Inside a field frame: accept commands only from the real parent, on our channel.

Can a hostile parent lie about its origin in the fragment? It can put anything there, but it gains nothing: the only things we ever send to the parent are state flags and a payment method id, which the merchant's page was going to receive anyway. That is the point of designing the schema around state. The interactive lab below lets you send messages across each boundary and see what an injected skimmer on the parent can and cannot see.

Shopper
Attacker
Shopper types 4242 4242 4242 4242. frame to controller: card digits. frame to page: complete, visa. Digits cross only between two documents on js.pay.example. The parent learns that the number is complete and the brand.Merchant pageField frameControllerOur APIcard digitscomplete, visa
Digits cross only between two documents on js.pay.example. The parent learns that the number is complete and the brand.

Normal flow. Digits cross only between two documents on js.pay.example. The parent learns that the number is complete and the brand.

What the skimmer on the merchant page has collected so far:

  • change event: complete, brand visa

Sizing is one more message. The frame cannot change its own size in the parent's layout, so it measures its content with a ResizeObserver and posts resize with the height in CSS pixels. The SDK sets the iframe's height. Without this, a larger merchant font or a wrapped error line gets clipped inside a fixed-height frame.

Check your understanding · Exercise 3

Which of these protocol choices are safe for a field frame? Select all that apply.

Tokenization: turning a card into something the merchant can charge

The merchant never sees the card, yet their server must charge it. Tokenization bridges the gap: our controller frame sends the card details to our vault (a tightly locked-down service that stores card numbers), and the vault returns a stand-in value, a token, that means "this card" only to us. Stripe calls the result a PaymentMethod (pm_...); Braintree calls it a payment method nonce; Adyen returns encrypted card fields. The merchant passes the token to their server, and their server uses it with our secret API key.

Data flow of a card payment. The shopper types into the field frame. The field frame sends digits to the controller. The controller sends the card over HTTPS to the API and vault, which reply with a payment method id. The controller sends state and the payment method id down to the merchant page and SDK. The merchant page sends the id to the merchant server, which charges it with the secret key through our API.field frameformats, validatescontrollerone ownerAPI + vaultstores the carddigitscardpm_ idpage + SDKmerchant originmerchant serverhas sk_state, pm_ idpm_ idcharge, sk_typesshopperDigits travel only on the top row.
Two rows, two kinds of data. The top row carries card digits and stays on our origins. The bottom row carries only ids, state, and results, which is all the merchant's page and server ever need.

A stolen token is far less useful than a stolen card. It only works with our API, only with the merchant's secret key, and a single-use token is spent after one charge. That is why the parent page may receive it: a skimmer that grabs the token cannot charge it from the browser.

Decision Who drives the payment: the browser or the merchant's server?

Tokenize first, charge on the server

The browser calls createPaymentMethod, gets pm_123, and posts it to the merchant's server.

The merchant's server creates and confirms the payment with the secret key.

Pros

  • Simple to explain; the merchant's server is in charge of the amount from start to end.
  • Works for merchants who want to run their own checks before charging.

Cons

  • If the bank asks for 3-D Secure, the server has to send the shopper back to the browser to finish it, then confirm again: an extra round trip and more code.

Create the payment on the server, confirm in the browser

The merchant's server creates a payment (Stripe calls it a PaymentIntent) with the amount and gets a client secret.

The browser calls confirmPayment with the client secret. Our script tokenizes the card and confirms in one call, and handles any 3-D Secure step on the spot.

Pros

  • 3-D Secure and other extra steps happen inside the same call, while the shopper is on the page.
  • The amount is fixed on the server before the shopper types anything, so the browser cannot change it.

Cons

  • The merchant's server must create the payment before the form is shown, or on submit, which adds a call.
  • The client secret must be treated as sensitive: it lets anyone with the publishable key confirm that one payment.

What we pick: Support both, and make server-create plus browser-confirm the recommended path. Regulations like Strong Customer Authentication make the extra bank step common, and handling it inside one browser call is both simpler for merchants and better for shoppers. Keep tokenize-first for merchants who need to inspect the card (brand, country) before deciding the amount. Stripe offers both, plus a deferred variant that creates the PaymentIntent at submit time.

Sequence of the recommended confirm flow between the shopper, the page and SDK, the controller, our API, and the shop server. The shopper presses Pay. The page asks the shop server to create a payment, and the shop server replies with a client secret. The page asks the controller to confirm with the secret. The controller sends the card and the confirm to our API, which replies succeeded. The controller sends the result with the payment method id to the page, and the page shows thank you. Our API also sends a payment succeeded webhook to the shop server.ShopperPage, SDKControllerOur APIShop serverPaycreateclient secretconfirmcard + confirmsucceededresult, pm_thank youwebhook
The amount is fixed on the server before any card is typed, and the card goes from the controller to our API in the same call that confirms. The webhook tells the shop server the truth even if the shopper closes the tab before result arrives.
ts
const pay = await loadPay("pk_live_123");
const form = pay.cardForm({ style: theme });
form.mount("#card");

submitButton.addEventListener("click", async () => {
  submitButton.disabled = true;
  // 1. The merchant's server creates the payment and returns its client secret.
  const { clientSecret } = await fetch("/api/payments", {
    method: "POST",
    body: JSON.stringify({ cartId }),
  }).then((r) => r.json());

  // 2. Our frames tokenize the card and confirm, including any 3-D Secure step.
  const result = await form.confirm({ clientSecret, returnUrl: location.href });
  if (result.status === "succeeded") location.assign("/thanks?order=" + result.paymentId);
  else if (result.status === "processing") showPending(); // never invite a second payment
  else if (result.status === "failed") {
    showError(result.error.message);
    submitButton.disabled = false;
  }
  // "redirecting": the page is leaving for the bank; keep the spinner.
});
The merchant's side of the recommended flow. Our SDK names are illustrative.

Single-use and reusable tokens

A single-use token stands for one charge. Stripe's legacy card tokens (tok_...) can be used once, and Braintree's nonces are single-use and expire after a few hours. This limits the damage if one leaks.

A reusable credential is what a merchant needs to save a card for next time or for a subscription: it is created on our side by attaching the payment method to a customer record, only on the server, with the secret key. The card form only ever produces the single-use kind. Saving is a server decision, with the shopper's consent captured in the merchant's UI (a "Save this card" checkbox whose value goes to the merchant's server, not into our frame).

ValueWho sees itWhat it can do if stolen
Card number, expiry, CVCOur frames and our vault onlyEverything. This is what we protect.
Publishable key pk_...Public, in the merchant's pageCreate tokens, and confirm an existing payment only together with its client secret. Cannot move money on its own.
Single-use token or pm_...Merchant's page and serverCharge once, and only with the merchant's secret key.
Client secret of one paymentMerchant's pageConfirm that one payment for its fixed amount. Should not be logged or stored.
Secret key sk_...Merchant's server onlyFull API access. Never in the browser.

Check your understanding · Exercise 4

A skimmer script on the merchant's checkout page captures values as they pass through the merchant's JavaScript. Which captures are harmless or close to harmless? Select all that apply.

Inside the number field: formatting, brands, and validation

Now open a single field frame. It is one input, but it carries most of the user experience. The shopper types 16 digits on a phone keyboard, often while walking; every keystroke must format the number, detect the brand, update the CVC rule in a sibling frame, and decide whether to show an error. All of this runs inside our frame with no network call.

The right input element

html
<label for="num" class="sr-only">Card number</label>
<input id="num" name="cardnumber" type="text"
  inputmode="numeric" autocomplete="cc-number"
  autocorrect="off" spellcheck="false"
  aria-invalid="false" aria-describedby="num-error" />
<p id="num-error" role="alert"></p>

<!-- Sibling frames -->
<input autocomplete="cc-exp" inputmode="numeric" placeholder="MM / YY" />
<input autocomplete="cc-csc" inputmode="numeric" placeholder="CVC" />
Inside the number frame. A text input, not type=number.

type="number" is wrong for card numbers: it drops leading zeros, shows spinner arrows, accepts e, and cannot hold spaces. inputmode="numeric" gives the digit keypad on phones while keeping a text input. The autocomplete tokens cc-number, cc-exp, and cc-csc tell the browser's saved-card autofill which field is which.

Brand detection from the first digits

The first 6 to 8 digits of a card are its IIN or BIN (issuer or bank identification number). The first few are enough to tell the network, and the network decides the length, the grouping, and the CVC length. We detect locally for instant feedback, from a small table shipped in the frame.

Enough for instant feedback. The authoritative answer (including co-badged cards and new ranges) comes from the server's BIN table after tokenization.
BrandStarts withLengthGroupingCVC
Visa416 (some 13 or 19)4 4 4 43 digits
Mastercard51 to 55, 2221 to 2720164 4 4 43 digits
American Express34, 37154 6 54 digits
Discover6011, 644 to 649, 6516 to 194 4 4 43 digits

The 2221 to 2720 Mastercard range is the classic trap: many older regexes only check 5[1-5], and they reject valid cards. The industry also moved from 6-digit to 8-digit BINs in 2022, so the local table is a hint for the UI, not the truth. Treat unknown as allowed until the number is complete; do not block a card because our table is out of date.

Formatting as you type without losing the caret

The naive approach reformats input.value on every input event. It looks fine when typing at the end.

Then the shopper fixes a typo in the middle: they put the caret after the 6th digit and type, we rewrite the value, and the browser jumps the caret to the end. The next digit lands in the wrong place. The fix is to count digits before the caret, reformat, and put the caret back after the same number of digits.

ts
function onInput(el: HTMLInputElement) {
  const caret = el.selectionStart ?? el.value.length;
  const digitsBefore = el.value.slice(0, caret).replace(/\D/g, "").length;
  const brand = detectBrand(el.value);
  const digits = el.value.replace(/\D/g, "").slice(0, maxLength(brand));
  const formatted = group(digits, gapsFor(brand)); // "4242 4242 42"
  el.value = formatted;

  let pos = 0;
  for (let seen = 0; pos < formatted.length && seen < digitsBefore; pos++) {
    if (formatted[pos] !== " ") seen++;
  }
  el.setSelectionRange(pos, pos);
  report(digits, brand); // validate, then emit state to the parent
}

The same function handles paste: 4242-4242-4242-4242 or a number with spaces from a password manager is stripped to digits, cut to the brand's maximum length, and grouped. Backspace needs one extra rule: deleting the space after 4242 removes nothing that counts, so we treat a Backspace right after a gap as deleting the digit before it. Spot it with the beforeinput event's inputType of deleteContentBackward: Android keyboards often report an unidentified key in keydown, so a Backspace check there (which the lab below uses) is not enough on phones.

Validation: Luhn, expiry, CVC

  • Luhn check: card numbers end with a check digit. From the right, double every second digit, subtract 9 from any result above 9, and sum everything; a valid number sums to a multiple of 10. It catches every single wrong digit and most swaps of two neighbours. It does not tell you the card exists.
  • Expiry: a card is valid through the last day of its expiry month. 04 / 26 is still good on 30 April 2026. Reject past months, months above 12, and dates absurdly far in the future (we use 20 years; an estimate, not a rule). Use the shopper's local date: being off by a timezone on the last day is better than rejecting a valid card.
  • CVC: 3 digits, or 4 for American Express. The number frame tells the controller the brand, and the controller tells the CVC frame its new length. If the shopper typed 4 digits before the number, re-check the CVC when the brand arrives.
ts
function luhnValid(digits: string): boolean {
  let sum = 0;
  for (let i = 0; i < digits.length; i++) {
    let d = Number(digits[digits.length - 1 - i]);
    if (i % 2 === 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }
  return digits.length >= 12 && sum % 10 === 0;
}

When to show an error

Showing "Your card number is invalid" after the first digit is hostile: the number is not invalid, it is unfinished. The rule we use is late to complain, early to forgive.

An error appears on blur if the field is incomplete or invalid, or immediately when the input can no longer become valid (a full-length number that fails Luhn, a past expiry, a first digit no supported network uses). Once an error is showing, it clears on the very keystroke that fixes it. The input-validation lesson covers this pattern for forms in general.

State machine of one card field. Pristine goes to editing on the first key. Editing goes to valid when the number is complete and passes Luhn. Valid goes back to editing when a digit is edited. Editing goes to invalid on blur while incomplete, or at once when a full-length number fails Luhn. Invalid goes to valid on the keystroke that fixes it. A note says errors are late to show and early to clear.pristineeditingvalidinvalidaria-invalidfirst keycomplete, Luhn okedit a digitblur while incompleteor full length, Luhn failsfixing keylate to show,early to clear
There is no arrow from editing to invalid on a normal keystroke: an unfinished number is not an error. Each state change posts one change event with empty, complete, brand, and the error, if any.

Try it in the lab below. Type, move the caret, paste, and blur; watch the formatted value, the caret position, the brand, the Luhn result, and when the error appears. To stay small, the lab knows only the common lengths: 16 digits, and 15 for American Express.

The number grouped as nothing yet. Brand unknown (allowed for now), 0 of 16 digits, Luhn not checked until complete.unknown (allowed for now): CVC 3 digits, 0 of 16no error: unfinished is not invalid
Each box is one group of digits as the frame formats them for the detected brand: 4 4 4 4, or 4 6 5 for American Express.
  • Luhn check: not checked until complete
  • CVC field: 3 digits
  • Error: hidden until blur, unless the number can no longer become valid
  1. Type a test number, or use a button.

Autofill across frames

Saved-card autofill is the fastest way to pay on a phone, and split fields are where it breaks. With a single card frame, all three fields are in one document and browsers fill them together. With one frame per field, the browser sees three separate cross-origin documents.

Browsers are careful here, because a malicious frame could otherwise collect a card meant for another one. Chrome limits which cross-origin frames it will fill card numbers and security codes into. It has also proposed a shared-autofill permission that lets the parent mark trusted frames, but it is testable only behind a flag. Safari and Firefox behave differently again, and behaviour changes between versions.

So we design for the case where only the focused frame gets filled. One technique some providers use: the number frame also contains visually hidden cc-exp and cc-csc inputs. If the browser fills them along with the number, the number frame passes those values to the controller (same origin), which forwards them to the expiry and CVC frames. Then we test the matrix of browsers on every release, because this is the part most likely to break silently.

Local networks, co-badged cards, copy, and direction

A global form meets local card rules. The brand table above grows per region, and some rules change the UI, not only the copy. The locale-formatting lesson covers numbers and dates in general; these are the payment-specific parts.

  • Local networks: JCB in Japan, UnionPay (numbers starting 62) in China, Cartes Bancaires in France, and others. Some UnionPay numbers do not pass the Luhn check, so validators such as Braintree's card-validator skip Luhn for UnionPay by default. Unknown is not invalid.
  • Co-badged cards: a French card can be both Cartes Bancaires and Visa. EU rules let the shopper choose the network, so the number field shows a small brand picker once the server's BIN lookup reports two networks.
  • Postal code: shown or hidden based on the card's country, with the right keyboard: digits for a US ZIP, letters and digits for a UK postcode.
  • Copy: error messages, labels, and placeholders come from our translated strings for the locale the merchant passes, or the browser's language with auto. The merchant can override labels to match their page.
  • Direction: in right-to-left pages the frame mirrors its layout, but the card number, expiry, and CVC stay left to right (dir="ltr" on the inputs), because that is how they are printed on the card.

Keep all of this data in the frames, loaded with the frame's code, so a new network or a new translation ships to every merchant without them changing a line.

Check your understanding · Exercise 5

The shopper types 2223 0000 4841 0010. Which statements are true? Select all that apply.

Styling and accessibility across the frame boundary

Isolation has a cost the merchant feels at once: their CSS stops at the iframe. Their font, their input colours, their focus ring, their error colour: none of it reaches inside. And assistive technology sees two kinds of documents stitched together. This chapter is about making a set of foreign documents look and behave like one native form.

A style object, not a stylesheet

Decision How does the merchant style what is inside our frames?

Accept a CSS string or stylesheet URL

The merchant passes raw CSS; the frame injects it.

Pros

  • Total control; easy to explain.

Cons

  • CSS can leak data: selectors plus background: url(...) can send requests that reveal what is in the page.
  • Merchants come to depend on our internal class names, so we can never change our markup.
  • Our frame's CSP would have to allow arbitrary image and font origins.

A typed style object with an allowlist

The merchant passes { base: { color, fontFamily, fontSize }, invalid: { color }, '::placeholder': { color } }.

The frame copies only known properties with validated values into its own stylesheet.

Pros

  • Safe: no url(), no selectors the merchant controls.
  • We can change the markup freely; the object is the contract.

Cons

  • Less freedom; some designs need properties we do not allow yet.

Themes plus design variables

The merchant picks a theme (flat, night) and overrides variables like colorPrimary and borderRadius, plus a few rules for named parts.

Stripe's Appearance API for the Payment Element works this way.

Pros

  • Looks good with three lines of config; scales to a full form with tabs and labels.

Cons

  • A larger API surface to keep stable.

What we pick: Use a style object for single fields and themes plus variables for the full form, both validated against an allowlist. Raw CSS is a data leak and a compatibility trap. Every value is checked: colours must parse as colours, sizes as lengths, and nothing may contain url( or a semicolon.

  • Fonts: the frame cannot use a font the parent loaded. The merchant passes a font source (a stylesheet URL from an allowed host such as Google Fonts, or a font file URL), and the frame loads it with its own @font-face. Until it loads, use font-display: swap and a close system fallback so the field is usable at once.
  • State classes on the parent: the SDK adds classes like pay-field--focus, --invalid, --complete, and --empty to the merchant's container element. The merchant's own CSS then draws the border and focus ring around the iframe, in their exact style. This is the cheapest way to make split fields match the rest of the form.
  • Size: as described earlier, the frame reports its content height with resize messages, so larger fonts and error lines are never clipped.
  • Colour scheme: the merchant's theme may differ from the system theme, so the frame follows the style object, not only prefers-color-scheme.

Accessibility when the input lives in another document

Screen readers handle iframes well today: they read into the frame as if it were part of the page. What does not cross the boundary are ID references. A label for="num" in the parent cannot point at an input in our frame, and aria-describedby in the frame cannot point at an error message in the parent. So every frame must be accessible on its own.

  • Names: each input has a real label inside the frame (visually hidden if the merchant shows their own visible label). The merchant passes the label text, so it matches their language. The iframe element gets a title like "Secure card number input", which some screen readers announce when entering the frame.
  • Errors: the frame sets aria-invalid="true" and links the input to an error message inside the frame with aria-describedby. That satisfies WCAG 3.3.1 Error Identification (the error is identified and described in text). Messages say how to fix it, such as "Your card's expiry date is in the past. Check the month and year.", which is WCAG 3.3.3 Error Suggestion.
  • Announcing: put the message in a live region once, either inside the frame (default) or in the merchant's DOM (if the merchant renders errors from change events). Never both, or screen readers read every error twice. Make it an explicit option.
  • Clicking a merchant label: the SDK listens for clicks on the merchant's label and sends a focus command, so tapping "Card number" focuses our input as it would a native one.
Tab order on a checkout page with split card frames. One, the merchant's Email field. Two, the card number frame. Three, the expiry frame. Four, the CVC frame. Five, the merchant's Pay button. An arrow labelled Tab runs down the side. Notes on the right say each frame has its own label, aria-invalid, and error text inside, and to keep tab order, use no tabindex above 0 and give hidden helper inputs tabindex minus 1.shop.example/checkoutTab1. Email (merchant)2. Card number (frame)3. Expiry4. CVC5. Pay (merchant)each frame has:its own labelaria-invaliderror text insidekeep tab order:no tabindex over 0hidden helpers -1
The browser walks into each iframe at its place in the parent's DOM, so the order is right without extra code. What does not cross is ID references: every label and error must live in the same document as its input.

Tab order works without our help: browsers include iframe content in sequential focus navigation at the iframe's position in the parent's DOM. Tab from the merchant's email field enters the number frame, Tab again leaves it for the expiry frame, and so on to the merchant's Pay button.

Two things break it: a merchant using tabindex values above 0 on their own inputs, and a hidden autofill input inside a frame that can receive focus. Hidden helper inputs need tabindex="-1" and aria-hidden="true".

Programmatic focus, such as moving to the expiry field once the number is complete, goes through the controller. Be careful with auto-advance: it surprises screen reader users and people fixing a typo, so only advance when the field just became complete by typing, never after a paste or an edit in the middle. The focus-management and aria-patterns lessons go deeper.

Check your understanding · Exercise 6

A merchant reports that screen reader users hear "edit text, blank" on our CVC field and hear each error twice. Which fixes are right? Select all that apply.

Wallets and 3-D Secure inside the form

Two flows interrupt the simple "type card, press Pay" path. Wallets (Apple Pay, Google Pay) skip typing entirely; the browser or operating system shows its own payment sheet. 3-D Secure (3DS) adds a step after the shopper presses Pay, when the bank wants to check who they are. Both run partly outside our frames, and both must end in the same result event the merchant already handles.

Apple Pay, Google Pay, and the Payment Request API

  • Payment Request API: a W3C standard (new PaymentRequest(methods, details)) where the page describes what it wants to charge and the browser shows a native sheet. MDN lists support in Chromium browsers and Safari, not in Firefox. In practice it is a carrier for wallets: in Safari the method is Apple Pay, in Chrome it can be Google Pay.
  • Apple Pay on the web: Safari's Apple Pay JS (ApplePaySession) or Payment Request with the Apple Pay method. Every domain that shows the button must be registered with Apple, which checks a file served at /.well-known/apple-developer-merchantid-domain-association. Providers usually host the merchant identity and ask merchants to register each domain through the provider's dashboard or API. Since Safari 17, Apple Pay works inside a cross-origin iframe if the parent grants it with allow="payment".
  • Google Pay: Google's pay.js library shows the button and returns an encrypted payment token for our gateway. It works in most browsers, not only Chrome.

For us, the decisions are where the button lives and how it reaches the same confirm path. We render the wallet button inside our own frame (with allow="payment" set by the SDK when it creates the iframe), check availability first (ApplePaySession.canMakePayments(), Google Pay's isReadyToPay) and tell the parent through a wallets event, so the merchant can hide the "or pay with card" divider when no wallet is available.

When the shopper authorizes in the sheet, the wallet hands an encrypted token to our frame, the controller exchanges it for a payment method id, and from there it is the same confirm call as a card. One flow, one result event.

3-D Secure: frictionless and challenge

The amazon guide explains the 3DS2 protocol between merchant, card network, and bank. Here is what the form must do. When confirm reaches our API, the payment may come back as requires_action: the bank wants authentication. Two paths follow.

  1. Device data collection: our controller loads the bank's 3DS Method URL in a hidden iframe. The bank's page collects browser data (screen size, timezone, language) and posts back. The EMVCo spec gives it up to 10 seconds; if it does not finish, authentication goes on without it.
  2. Frictionless: the bank's risk engine decides the shopper is who they say they are. No UI. The shopper sees only a slightly longer spinner.
  3. Challenge: the bank wants proof, such as a one-time code or a push approval in the banking app. The controller has no UI, so it asks the SDK to draw a modal over the merchant's page. The modal holds one of our frames, with the bank's page (served by its Access Control Server, or ACS) nested inside. When the shopper finishes, the bank's page posts its result to our frame, and the controller asks our API for the payment status.
The EMVCo 3DS2 browser challenge sizes. We send the size that fits the viewport when we start authentication; the bank's page is built for it.
challengeWindowSizeWidth by height (px)When we pick it
01250 by 400Very narrow containers; rarely used.
02390 by 400Phones in a modal; the common default.
03500 by 600Desktop modal with room to spare.
04600 by 400Wide but short containers.
05Full screenSmall phones, or when the modal would not fit.
Sequence of 3-D Secure in the form between the shopper, the controller, our API, and the bank's access control server. The controller sends confirm to our API, which replies requires action. The controller loads the 3DS Method page in a hidden frame, and the bank replies done, or the controller gives up after 10 seconds. The controller asks our API to authenticate, and the API replies with a challenge in window size 02. The controller shows the bank page in a modal to the shopper. The shopper enters a code in the bank page. The bank page tells the controller the challenge is done. The controller asks our API for the status, which replies succeeded.ShopperControllerOur APIBank ACSconfirmrequires_action3DS Methoddone, or 10 sauthenticatechallenge, size 02bank page in modalenters codechallenge doneget statussucceeded
In a frictionless flow the authenticate step comes back approved and the four challenge steps are skipped: the shopper only sees a longer spinner. Either way the controller ends by asking our API for the status, never by trusting a message from the bank's frame.

Some payments still need a full-page redirect instead: some 3-D Secure setups, some wallets, and many local payment methods, such as bank redirects. Then the whole page leaves, and the merchant's returnUrl receives the shopper back with the payment id in the query string. The checkout-flow guide covers that return page. On our side, confirm must say clearly whether it resolved in place or is about to navigate, so the merchant does not show a spinner forever.

Check your understanding · Exercise 7

Which statements about wallets and 3-D Secure in our form are correct? Select all that apply.

Confirming exactly once: double clicks, idempotency, and dropped connections

The checkout-flow guide covers the merchant's side of this: a guard on the Pay button, an idempotency key for placing the order, and a confirmation page that asks the server. This chapter is about our side. Thousands of merchants call form.confirm(), and some of them will call it twice, retry it in a loop, or unmount our frames halfway. Our SDK, controller, and API must turn all of that into at most one charge.

The naive version: confirm posts a message, the controller calls our confirm endpoint, and any fetch error becomes "Payment failed". Now a shopper on a train presses Pay for 81.40. The request reaches our API, the bank approves, and the response is lost in a tunnel. The merchant shows "Payment failed, try again".

The shopper presses Pay again. If the merchant creates a new payment on every click, a common mistake, the shopper is charged twice. If not, the second confirm fails with a confusing "already succeeded" error. Both are our problem, because both start with us reporting a failure we did not know was a failure.

Decision Where do we stop a second confirm?

Leave it to the merchant

Document that the merchant must disable the Pay button while confirm is running.

Pros

  • No code on our side.

Cons

  • Every merchant must get it right. Many will not.
  • Pressing Enter in our field fires our submit event, which some merchants wire straight to confirm without checking the button.

A guard in the SDK

The SDK allows one confirm in flight per form. A second call while one runs returns the same promise.

The controller also refuses confirm unless the form is in the ready state.

Pros

  • Protects every merchant, whatever their button does.

Cons

  • Lives in one page: it cannot help after a reload or in a second tab.

The server as the guarantee

The payment object, created once by the merchant's server, can succeed only once.

Each confirm request carries an idempotency key: a retry with the same key gets the stored result instead of a second attempt.

Pros

  • Survives reloads, tabs, and retries; the only layer that can promise exactly once.

Cons

  • Needs the server-create flow. With tokenize-first, exactly once depends on the merchant's own server.

What we pick: Use all three, each for a different job. The server is the guarantee, the SDK guard stops a burst of clicks from becoming a burst of requests, and the merchant's disabled button tells the shopper something is happening. Never rely on the browser layers alone: a reload wipes them.

ts
let inFlight: Promise<ConfirmResult> | null = null;

export function confirm(opts: ConfirmOptions): Promise<ConfirmResult> {
  if (inFlight) return inFlight;
  const requestId = crypto.randomUUID();
  inFlight = request(controller, {
    type: "confirm", requestId, clientSecret: opts.clientSecret,
  }).finally(() => {
    inFlight = null;
  });
  return inFlight;
}
In the SDK. A double click gets the same promise; the request id doubles as the idempotency key.
State machine of the whole form. Incomplete goes to ready when all fields are complete. Ready goes to confirming on confirm. Confirming goes to requires action for 3-D Secure, which returns to confirming when done. Confirming goes to processing on a timeout, to succeeded when approved, and to failed when declined. Processing goes to succeeded when the status check says paid. Failed goes back to ready to retry with the card kept.incompletereadyrequires_action3DS or redirectfailedcard keptconfirmingprocessingask the serversucceededcompleteconfirm3DSdonetimeoutapprovedstatus: paiddeclinedretry
confirm is accepted only in ready, which is what makes the SDK guard work. A timeout leads to processing, which asks the server, so the shopper is never invited to pay again for a payment that may have gone through.

When the response never comes

A confirm that times out has three possible histories: it never reached us, it reached us and failed, or it reached us and succeeded. The browser cannot tell them apart. So the controller does two things, in order.

First it retries with the same idempotency key, a few times with backoff (the retries-and-backoff lesson covers the timing). This is safe: if the first attempt went through, the API replays the stored result. Stripe, for example, keeps idempotency keys for at least 24 hours and rejects a reused key with a different request body.

Second, if the network is still down, it asks instead of guessing: when the online event fires, and on a slow timer as well (the event only means a network interface came back, not that our API is reachable), it fetches the payment's status with the client secret.

Until it knows, the result we hand the merchant is processing, not an error. Our docs say what to show: "We are checking your payment. Do not pay again." A bank authorization normally answers in a few seconds; we give a confirm 30 seconds before we report processing (our estimate, tune it with real latency data).

Two more provider-side traps. The merchant's framework may re-render and remove our frames mid-confirm, so the SDK attaches the controller to document.body, outside the merchant's container, and keeps it alive until the confirm settles. And if the page reloads, nothing in the browser survives: the merchant's server reads the payment status on the next load, as checkout-flow describes.

Sequence of a confirm whose response is lost, between the page and SDK, the controller, and our API. The page sends confirm with request r1. The controller sends confirm to our API with idempotency key r1. Our API replies succeeded, but the reply is lost. The controller retries with key r1, and the retry fails because the device is offline. The controller tells the page the payment is processing. When the online event fires, the controller asks our API for the payment status, and the API replies succeeded. The controller sends succeeded with the payment id to the page.Page, SDKControllerOur APIconfirm, r1confirm, key r1succeeded, lostretry, key r1: offlineprocessingonline event firesget statussucceededsucceeded, pay_123
The retry uses the same key, so even if it had reached the API it would only replay the stored result. The merchant sees processing, then succeeded: never a failure followed by a second charge.

Check your understanding · Exercise 8

The controller sent a confirm, waited 30 seconds, and got no response. Which next steps are safe? Select all that apply.

Threats to the payment page, and what the provider can do

The frames stop one attack completely: a script on the merchant's page reading what the shopper types. Attackers know that, so they go around the frames.

The checkout-flow guide covers web skimming and a strict Content Security Policy from the merchant's side. Here we list the attacks against our form and what we, the provider, build against each. The clickjacking-and-iframes, csp, xss, and third-party-scripts lessons cover the mechanisms.

Map of threats around a merchant page with our card frame and SDK. On the left, a skimmer that tries to read typed keys is blocked by the other origin, and a fake overlay that draws its own form works until the script is removed. On the right, a swapped frame pointing to a look-alike domain is blocked by the merchant's frame-src, and card testing with our real frames and a bot is slowed by rate limits. Below, clickjacking with an invisible frame is limited by requiring a user gesture and a visibility check.shop.examplecard frameSDK pay.jsskimmerreads typed keysblocked: other originfake overlaydraws a fake formworks: remove scriptswapped srclook-alike frameblocked: frame-srccard testingreal frames, a botslowed: rate limitsclickjackinginvisible framegesture plus visibility check
Four of the five attacks are stopped or limited by the frames, the merchant's CSP, or our servers. The fake overlay is not, because it lives entirely in the merchant's page. That is why PCI asks merchants to control the scripts on the page that hosts our frames.
  • Skimmer on the parent (Magecart, formjacking): a compromised script on the checkout page. In 2018 one such script on British Airways' booking site sent the card details of hundreds of thousands of customers to a look-alike domain. Against our frames it cannot read inputs, so it draws a fake card form over ours, or hides our frame and shows its own. Our defence is limited, which is why PCI now asks the merchant to control the scripts on the page. What we add: change detection and script monitoring tools for merchants, and a strict published list of our origins.
  • Swapped frame: the skimmer changes the iframe's src to a look-alike domain, or loads a fake copy of our SDK that creates fake frames. The page looks identical. The defences are the merchant's: a CSP frame-src and script-src that only allow our exact origins, which stops the swap even if a skimmer runs.
  • Stolen results: the skimmer reads the token, the brand, or the client secret as they pass through the SDK. As the tokenization chapter showed, these are designed to be close to useless without the merchant's secret key.
  • Clickjacking: a hostile page loads our frame invisibly and tricks the shopper into clicking our button. For plain inputs the risk is small: the shopper still has to type a card. It matters for one-click buttons we render, such as a wallet button or "Pay with saved card". Those need a real user gesture, and a saved-card payment from an unknown page should require a fresh CVC or 3-D Secure. Chromium's IntersectionObserver with trackVisibility can report whether our frame is actually visible; other browsers do not support it yet, so treat it as an extra signal.
  • Malicious parent: a fraudster builds their own page around our real frames with a publishable key, to test thousands of stolen card numbers (card testing). Our frames work exactly as designed, so the defence is on our servers: rate limits per key, per IP, and per device, a CAPTCHA step when patterns look automated, and declines that do not reveal which check failed.

Who may embed our frames: frame-ancestors

Content-Security-Policy: frame-ancestors tells the browser which origins may put a page in a frame. For our field frames we cannot say 'none': being framed by merchants is the product.

Allowing any HTTPS origin is the simple choice; a stricter one sends a per-merchant list built from domains the merchant registered, like Apple Pay domain registration, so a phishing site with a stolen key cannot embed the fields at all. That costs a registration step for every merchant and every new domain. Our other pages are different: the dashboard, the login page, and the full-page hosted checkout send frame-ancestors 'none', because no one should ever frame them.

Decision Should merchants pin our script with Subresource Integrity?

One evergreen URL, no pinning

The merchant loads https://js.pay.example/v1/; we ship fixes to that URL continuously.

Stripe works this way and requires Stripe.js to be loaded from js.stripe.com.

Pros

  • Every merchant gets security fixes, new browser workarounds, and fraud signal updates the same day.
  • One entry in the merchant's script inventory.

Cons

  • The merchant cannot use integrity on the tag, because the file's hash changes with every release.
  • The merchant trusts our CDN and release process completely.

Versioned URLs with published hashes

Each release has its own immutable URL and an SRI hash in the release notes. Adyen's web SDK works this way.

The merchant adds integrity="sha384-..." and crossorigin="anonymous"; the browser refuses a file whose hash differs.

Pros

  • A tampered file on our CDN cannot run on the merchant's page.
  • A clean answer to PCI's integrity requirement for this script.

Cons

  • Merchants upgrade slowly, so old versions with old bugs stay in use for years.
  • We must keep every version working, or make breaking changes very carefully.

What we pick: Pick one per product and be clear about it. For a provider that changes fraud signals and browser workarounds weekly, the evergreen URL plus a strong release process (signed builds, staged rollouts, monitoring of the served file) is the better default. Offer versioned, hashed URLs for merchants whose security team requires SRI, with a published support window for each version.

Fraud signals come from the same script. Providers collect device and behaviour signals (browser features, how the page was reached, typing cadence in our fields) and feed them to a risk model such as Stripe Radar. Stripe recommends loading Stripe.js on every page of the site, not only checkout, so the model sees more of the visit.

How much any one signal is worth is not public, and the design does not depend on it. What matters is that the risk script runs in our origin or frames, never asks the parent for card data, and is listed in our privacy documentation for merchants.

Check your understanding · Exercise 9

A skimmer runs on a merchant's checkout page, which uses our iframe fields. The merchant has a strict CSP whose frame-src and script-src allow only our origins for payments. Which attacks still work? Select all that apply.

Architecture, data model, and interface

Time to put the pieces in one picture. The rule that decides every boundary: card digits exist only in documents on our origin. Everything the merchant's code can touch, including our own SDK, gets state and results. Each part below owns one job and has one thing it must never hold.

PartRuns inOwnsNever holds
SDK pay.jsMerchant's pageCreating frames, the channel id, the public API, state classes on containersCard data
Field framesOur origin, one per fieldOne input: formatting, brand, local validation, label, error textNetwork calls to the vault
Controller frameOur origin, hiddenForm state, cross-field rules, tokenize and confirm calls, retries, 3-D Secure stepsAnything from the merchant's DOM
Wallet frameOur originWallet buttons, availability checks, the wallet tokenThe merchant's amount logic
Challenge frameThe bank's origin, in our modalThe bank's 3-D Secure pageAnything of ours
API, vault, riskOur serversTokens, payment state, idempotency records, risk scoresSecrets in responses to the browser

Why a separate controller instead of making the number frame the boss? Because the merchant can destroy any field frame (a re-render, a layout change), and the payment in flight must not die with it. That is why the controller has no UI and sits outside the merchant's container, as the confirming chapter showed. Being the only frame that calls the vault also gives us one place to audit for where card data goes.

Data model: what lives where

ts
type FieldState = {
  field: Field;
  status: "pristine" | "editing" | "valid" | "invalid";
  empty: boolean;
  complete: boolean;
  brand?: Brand;                        // number field only
  error?: { code: string; message: string };
};

type FormState =
  | { status: "incomplete" | "ready" }
  | { status: "confirming" | "processing"; requestId: string }
  | { status: "requires_action"; requestId: string; action: "challenge" | "redirect" }
  | { status: "succeeded"; paymentId: string }
  | { status: "failed"; code: string; retryable: boolean };

// Only inside our frames, only in memory, never posted to the parent.
type CardDraft = { number: string; expMonth: number; expYear: number; cvc: string };
Where each piece of state lives, in four columns. Merchant page: a FieldState summary copy, a FormState mirror, and the channel id it creates. Field frames: the CardDraft in memory only, and the FieldState source. Controller: the FormState source, the CardDraft while tokenizing, and the request id used as the idempotency key. Our API: the Payment, which is the source of truth, and the idempotency key records kept 24 hours or more.Merchant pageField framesControllerOur APIFieldStatesummary copyFormStatemirrorchannelcreates itCardDraftmemory onlyFieldStatesourceFormStatesourceCardDrafttokenizingrequestIdidempotencyPaymentsource of truthkey records24 h or moreCardDraft never appears in the merchant column.
Highlighted boxes are sources of truth. CardDraft appears only in the two columns on our frames' origin, and only in memory; the merchant column holds copies of state and nothing else.
DataSource of truthCopiesLifetime
CardDraftField frames (memory)Controller, only while tokenizingUntil tokenized or the page closes. Never in storage.
FieldStateIts field frameController; a summary in the SDK for change eventsThe form's life
FormStateControllerSDK mirror, so confirm can refuse a second callThe form's life
Payment statusOur APINone that the UI trustsDays; asked again after any doubt
Channel idSDKEvery frame's URL fragmentOne form instance

Interface: the SDK and the API behind it

ts
const pay = await loadPay("pk_live_123", { locale: "auto" });
const form = pay.cardForm({
  layout: "split",                 // or "single"
  style, fonts, labels,            // validated allowlist, see styling
  announceErrors: "frame",         // or "merchant"
});
form.mount({ number: "#cc-number", expiry: "#cc-exp", cvc: "#cc-cvc" });

form.on("change", (e) => setPayEnabled(e.formComplete));
form.on("submit", () => payButton.click());   // Enter pressed in a frame

const result = await form.confirm({ clientSecret, returnUrl });
// { status: "succeeded", paymentId } | { status: "processing" }
// | { status: "redirecting" } | { status: "failed", error: { code, message } }
form.destroy();
The public SDK, as a merchant uses it. Names are ours, modelled on the common shape.
Call (from the controller)AuthReturns
POST /v1/payment_methodsPublishable key{ id: "pm_...", brand, last4, country }
POST /v1/payments/:id/confirm with Idempotency-KeyPublishable key plus client secret{ status, next_action? }, where next_action holds the 3-D Secure method and challenge URLs
GET /v1/payments/:id?client_secret=...Publishable key plus client secret{ status }, used after timeouts and challenges

Version the frame messages on purpose. The SDK and the frames ship from our origin, but a merchant's page may hold an SDK loaded an hour ago while a fresh frame loads today. Every message carries v, a frame accepts the current and previous version, and new fields are added, never renamed. The embeddable-widget guide goes deeper on versioning a postMessage SDK.

Check your understanding · Exercise 10

Which design choices keep card data out of the merchant's reach? Select all that apply.

Running it in the interview

This problem has more depth than an hour can hold, so the skill is choosing. Say the framing in the first minute: "I am the provider, building the embedded card form; the merchant's checkout page is a client of mine." Then spend most of the time where the trust boundary and the user experience meet: frames, the message protocol, tokenization, and confirming once.

MinutesWhat to doWhat to draw
0 to 5Framing, requirements, the PCI goal (SAQ A)Nothing yet; list the five hard properties
5 to 15Hosted fields: one frame or many, the controllerMerchant page, SDK, field frames, controller, API
15 to 25The message protocol and tokenizationMessage arrows labelled with what crosses; the confirm sequence
25 to 35Card input: formatting, brand, Luhn, error timing, autofillThe field state machine
35 to 45Exactly once: SDK guard, idempotency, lost responseThe form state machine with processing
45 to 55Threats, 3-D Secure, wallets, accessibilityThe threat map
55 to 60Trade-offs you would revisitNothing new

Say out loud what crosses each boundary every time you draw an arrow. "This arrow carries complete: true and the brand, never digits" is the sentence that shows you understand the design. Expect these follow-ups.

Follow-upA strong answer
Why not let the merchant's own inputs post straight to your API?That is the SAQ A-EP setup: every script on their page can read the inputs. Frames keep the merchant in SAQ A and keep skimmers out of the inputs.
How does the CVC field know it needs 4 digits?The number frame detects American Express and tells the controller; the controller tells the CVC frame. Both on our origin, with exact targetOrigin.
A shopper says they were charged twice. What could have happened?Most likely the merchant created a new payment per click, or retried with a new idempotency key after a timeout. Our side: SDK guard, same key on retries, processing instead of an error.
How do you ship a fix to 100,000 merchants?An evergreen script and frames on our origin, staged rollout, and versioned messages so old SDKs keep working with new frames.
Can the merchant's page see the last four digits?Only after tokenization, from the payment method, which is the same thing a receipt shows.

Summary

  • Frame it as the provider: you build the card form merchants embed. The merchant's checkout page is a client of yours and has its own design problem.
  • Card digits live only in cross-origin frames on the provider's origin. The merchant's page, including your own SDK, gets state (empty, complete, brand, error) and a token, never digits.
  • Iframe fields keep merchants on SAQ A, but since PCI DSS v4 the hosting page's scripts still matter: requirements 6.4.3 and 11.6.1, or the SAQ A script-attack eligibility criterion.
  • Use a hidden controller frame for split fields: one owner of shared state, cross-field rules, and the only network call carrying card data.
  • Check event.origin and event.source on every message, always post to an exact targetOrigin, and design a schema with no message that could carry the card.
  • Format by counting digits before the caret, detect brands from IIN ranges including Mastercard 2221 to 2720, run Luhn and expiry checks locally, and show errors late but clear them early.
  • Exactly once needs three layers: an SDK guard against double calls, idempotency keys on every retry, and a server payment object that can succeed only once. A timeout is processing, not a failure.
  • Skimmers go around the frames: fake overlays, swapped frames, card testing. Defend with exact origins for CSP, frame-ancestors on pages that must never be framed, server-side rate limits, and risk signals.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next