Payment Element Integration

A hosted payment element is the seam between your checkout UI and the regulated world of card data, and getting that seam right decides both your PCI scope and your authorization rate. This page sits under Frontend Checkout UX & Dunning Recovery Flows and covers the production-grade integration: how the element mounts into an isolated iframe, how the PaymentIntent lifecycle maps to subscription state, how Strong Customer Authentication (SCA) routing is handled without tanking conversion, and how the asynchronous webhook that confirms the charge is reconciled against a double-entry billing ledger exactly once.

The hard part is not mounting the element — it is that the element confirms a payment synchronously in the browser while the authoritative truth arrives asynchronously over a webhook. Treat the client response as a UI hint and the webhook as the source of truth, and most of the race conditions below disappear. For the framework-specific component wiring, see Implementing Stripe Elements with React for seamless checkout; for token storage after the first charge, see Secure Card Vaulting & Tokenization.

Prerequisites

A hosted-element integration rests on a few pieces being in place: a PSP account with hosted fields, a server endpoint that never sees a PAN, an idempotency store, the webhook secret, and a CSP that allows the PSP origins. The stack lists them before the checklist.

Payment element prerequisites A PSP account, a PAN-free server endpoint, an idempotency store, the webhook secret, and a permissive CSP underpin the integration. Hosted element checkout PSP account hosted fields Intent API no PAN Idempotency per attempt Webhook secret verify sig CSP PSP origins
Five foundations — the PAN-free intent endpoint and the webhook secret are what keep the integration compliant and authoritative.

The idempotency store deserves a decision up front, because the choice constrains how you recover from partial failures. A Redis key with a TTL is cheap to write but evaporates: if the key expires after 24 hours and a stalled client retries the same checkout attempt on day two, you create a second intent for attempt_id and risk a double charge. A Postgres table with a unique constraint on (customer_id, attempt_id) never forgets, survives a cache flush, and lets the same row carry the resulting payment_intent_id so the retry can be answered from your own database without a PSP round trip at all. The trade-off is write latency — a synchronous row insert on the checkout hot path versus a sub-millisecond Redis SETNX. Most teams land on Postgres for correctness and accept the few extra milliseconds, because a duplicated charge is a chargeback, a support ticket, and a trust dent, while a slightly slower intent creation is invisible next to the 80–250 ms PSP round trip it precedes.

The CSP line is where hosted-element integrations most often break in production without any error your monitoring will catch, because a blocked frame simply renders nothing. The element loads a script from the PSP’s script origin and mounts an iframe from a separate frame origin, and 3DS step-ups inject a third origin belonging to the issuer’s ACS. A CSP that lists script-src and frame-src for the PSP but omits connect-src will mount the field yet fail the confirmPayment call, because the element opens an XHR back to the PSP to tokenize. Enumerate all three directives, pin them to the documented PSP hostnames rather than a wildcard, and add a report-uri so a missing directive surfaces as a violation report instead of a silent blank box during a checkout.

Choosing which server owns the amount

Decide before writing a line of client code which service is authoritative for the charge amount. The browser must never send the amount to your intent endpoint — an attacker who controls the client can rewrite amountMinor to 100 and pay one cent for an enterprise plan. The endpoint receives only customer_id and attempt_id, looks up the priced cart or invoice server-side, and derives the integer minor-unit total itself, tax included. This is the single most important boundary in the whole integration and it is invisible in the happy-path demo, because in a demo the amount you send is the amount you meant. Treat any amount that crosses the network from the browser as an untrusted display hint only.

Architecture & Data Flow

The element runs inside an iframe served from the PSP’s domain, so cardholder data never enters your DOM, your bundle, or your server logs. Your server creates a PaymentIntent, returns its client_secret to the browser, and the element confirms against that secret. The browser receives an optimistic status; the durable status arrives later as a signed webhook that you verify, deduplicate, and apply to the ledger.

Payment element confirmation flow The browser confirms a PaymentIntent in an isolated iframe; the authoritative result returns asynchronously as a signed webhook that is deduplicated before the ledger is updated. Browser + hosted iframe Your API: create intent PSP / card network Signature + idempotency Double-entry ledger Subscription state machine client secret webhook advance cycle
The synchronous confirm returns a hint; the signed webhook is the source of truth and is deduplicated before it touches the ledger.

Inputs are the line items and a customer reference. Processing is intent creation, client confirmation, and asynchronous reconciliation. Outputs are a settled ledger entry and an advanced subscription cycle. The element never produces money state on its own — it produces a signal that your backend confirms.

The client_secret is worth understanding precisely, because a surprising amount of the security model rests on it. It is a bearer token scoped to exactly one intent, of the form pi_..._secret_..., and it authorizes the browser to confirm and read the status of that single PaymentIntent and nothing else. It cannot list customers, cannot create refunds, and cannot see any other intent. That narrow scope is why it is safe to hand to the browser while your secret key never leaves the server. It does mean the secret should be treated like a short-lived credential: return it over TLS, do not log it, and let the intent’s own lifecycle expire it. If you cache the client secret to speed up a re-render, cache it in memory for the life of the page, never in localStorage, where a subsequent XSS payload could confirm the charge on the victim’s behalf.

The asynchronous seam has a subtlety that catches teams migrating from a synchronous gateway: the webhook and the client confirmation are two independent observers of the same event, and either can win the race. The card network can authorize, the PSP can fire payment_intent.succeeded, and your webhook handler can commit a ledger row before the confirmPayment promise resolves in the browser — especially on a slow mobile connection where the client is still waiting on the 3DS iframe to tear down. Your architecture must be indifferent to the order. The ledger keys on the event id, the subscription state machine is idempotent under repeated active transitions, and the client, when it finally resolves, reads current state from your API rather than asserting it. Build for the webhook arriving first and the common case where the client arrives first costs you nothing.

Where the money state actually lives

It is tempting to treat the PSP dashboard as your ledger, and for a small integration you can. At scale that coupling hurts: the PSP models a payment, not a subscription cycle, not a proration, not a revenue-recognition schedule, and not the credit note you issued last month. Your ledger_entry table is the system of record for money that moved; the PSP is the system of record for whether the card network moved it. The webhook is the reconciliation point between those two records. Keeping them distinct is what lets you answer “what did this customer_id owe and pay in Q3” from one Postgres query instead of paginating a PSP export, and it is what makes the double-entry invariant — debits equal credits per transaction — enforceable in your own database rather than hoped-for in someone else’s.

Implementation Walkthrough

The five steps split cleanly along the synchronous/asynchronous seam: create the intent and confirm client-side (the hint), handle the SCA challenge, then verify-dedupe-apply the webhook and reconcile any timeout (the truth). The diagram highlights that seam.

Sync hint versus async truth The browser confirm and SCA challenge produce an optimistic hint; the signed webhook, deduplicated and applied to the ledger, is the authoritative truth. Synchronous (browser) confirm + 3DS = optimistic hint 1 Create intent (idempotency) 2 Mount + confirm 3 SCA Asynchronous (server) signed webhook = authoritative 4 Verify + dedupe + apply 5 Reconcile timeouts
Steps 1-3 are the optimistic hint; steps 4-5 are the authoritative truth — never advance state on the hint alone.

1. Create the PaymentIntent with an idempotency key

The idempotency key must be deterministic for a given checkout attempt so a retried request returns the same intent instead of creating a second one. Derive it from the cart/attempt id, not a random UUID generated per request.

There is a sharp edge in how PSPs scope idempotency keys that determines what “same attempt” is allowed to mean. Stripe replays the entire original response for a given key for 24 hours, including the original request parameters — if you reuse intent:${attemptId} but pass a different amount, you do not get an updated intent, you get the first response back verbatim and a silent parameter mismatch warning. That is usually what you want: a network retry of the identical request is a no-op. But it means the key must change whenever the intent’s inputs legitimately change. If the customer edits their cart and the total moves from 4900 to 5900 minor units, that is a new attempt and needs a new key, or you will confirm the old amount. The clean rule is to hash the key from the attempt id and the priced amount and currency, so an amount change naturally rotates the key while an identical retry does not.

The setup_future_usage: 'off_session' flag in the example is doing quiet but load-bearing work for a subscription product. It tells the PSP to set up the payment method for later merchant-initiated charges at the moment of this first authorization, which is what lets the renewal in three weeks run without the cardholder present. Omit it and the vaulted method may be flagged for cardholder-initiated use only, and the first off-session renewal returns authentication_required because no prior SCA consent was captured. Capturing that consent on the attended first charge — where the user can complete a 3DS challenge — is dramatically cheaper than trying to recover it later through an email round trip to a customer who has already forgotten they subscribed.

// POST /api/checkout/intent  — server-side, never sees a PAN
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createIntent(customerId: string, attemptId: string, amountMinor: number) {
  const intent = await stripe.paymentIntents.create(
    {
      amount: amountMinor,            // integer minor units (cents)
      currency: 'usd',
      customer: customerId,
      setup_future_usage: 'off_session', // vault for recurring charges
      automatic_payment_methods: { enabled: true },
      metadata: { attempt_id: attemptId },
    },
    { idempotencyKey: `intent:${attemptId}` }, // ✅ same key → same intent
  );
  return { clientSecret: intent.client_secret, paymentIntentId: intent.id };
}

2. Mount the element and confirm client-side

The element mounts against the client secret. Confirmation uses redirect: 'if_required' so single-page routing survives a 3DS challenge instead of a hard navigation.

const elements = stripe.elements({ clientSecret });
const paymentEl = elements.create('payment', { layout: 'tabs' });
await paymentEl.mount('#payment-container');

async function confirm() {
  const { error, paymentIntent } = await stripe.confirmPayment({
    elements,
    confirmParams: { return_url: `${origin}/checkout/return` },
    redirect: 'if_required',
  });
  if (error) return { ui: 'declined', message: error.message };   // ✗ hard decline
  if (paymentIntent?.status === 'requires_action') return { ui: 'challenge' }; // ⚠️ 3DS
  return { ui: 'optimistic_success' }; // not final — wait for webhook
}

3. Handle the SCA challenge inline

A requires_action status means the issuer requested a 3DS step-up. Render the challenge inline; on completion the PSP re-evaluates the intent and emits a webhook. Do not advance subscription state on the client result — wait for payment_intent.succeeded.

The economics of SCA routing are worth internalizing because they trade directly against authorization rate. Every 3DS challenge you show adds friction, and challenge abandonment in the wild runs anywhere from 5% to 20% depending on region and device. Under PSD2 the issuer, not you, decides whether to challenge, but you can request an exemption for low-risk transactions — Stripe’s automatic_payment_methods will attempt Transaction Risk Analysis and low-value exemptions on your behalf when the acquirer supports them. The lever you hold is which transactions you flag as candidates for frictionless flow versus which you deliberately step up. For a recurring SaaS charge under the low-value threshold (roughly EUR 30) on an established customer_id with prior successful payments, letting the exemption ride is almost always correct. For a first charge from a new account on a high-value plan, accepting the challenge protects you from liability shift on a fraudulent card. The mistake is treating 3DS as a binary you either always or never invoke; it is a per-attempt risk decision.

When the challenge is abandoned or fails, the intent does not vanish — it returns to requires_payment_method or stays requires_action, and it is now your responsibility to either resurrect or retire it. Resurrecting means keeping the same intent and prompting the user to retry, which preserves the metadata and any exemption evaluation. Retiring means letting it lapse and treating the next attempt as fresh. Whichever you choose, surface an honest UI state: a spinner that never resolves because the client is waiting on a webhook that will never come for an abandoned challenge is one of the most common ways a checkout page appears “hung” to users who did nothing wrong.

Reading the decline code, not just the failure

A declined charge is not one event, it is a taxonomy, and the decline_code distinguishes a retry-worthy soft decline from a dead end. insufficient_funds and card_velocity_exceeded are transient — the same card may succeed tomorrow, which is exactly what the dunning schedule exists to exploit. stolen_card, pickup_card, and lost_card are terminal and should never be retried; retrying them can flag your merchant account for suspicious activity. do_not_honor is the frustrating middle: generic, issuer-side, and sometimes cleared by a single retry with a fresh network path. Persist the decline_code alongside the failed attempt so the recovery logic downstream can branch on it rather than blindly re-charging every failure on the same fixed cadence.

4. Verify and deduplicate the webhook

The webhook is the authoritative event. Verify the signature, then dedupe on the provider event id with a unique constraint so retries are no-ops. This is the same idempotent webhook handler pattern used across the backend.

export async function handleWebhook(rawBody: Buffer, sig: string) {
  const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.WH_SECRET!);

  await db.transaction(async (tx) => {
    const inserted = await tx.query(
      `INSERT INTO processed_events (event_id) VALUES ($1)
       ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
      [event.id],
    );
    if (inserted.rowCount === 0) return;          // ✅ already processed → no-op

    if (event.type === 'payment_intent.succeeded') {
      const pi = event.data.object;
      await tx.query(
        `INSERT INTO ledger_entry (ledger_entry_id, invoice_id, amount_minor, currency, kind)
         VALUES (gen_random_uuid(), $1, $2, $3, 'charge')`,
        [pi.metadata.invoice_id, pi.amount_received, pi.currency],
      );
      await tx.query(
        `UPDATE subscription SET status='active', current_period_end = $2
         WHERE subscription_id = $1`,
        [pi.metadata.subscription_id, computeNextPeriodEnd(pi)],
      );
    } else if (event.type === 'payment_intent.payment_failed') {
      await enqueueDunning(tx, event.data.object); // ⚠️ recovery path
    }
  });
}

5. Reconcile the optimistic client result

If the browser saw optimistic_success but no webhook arrives within the expected window, poll the intent status server-side and reconcile. Never charge again on a timeout — re-confirming the existing intent is idempotent at the PSP, re-creating an intent is not.

Edge Cases & Failure Modes

The edge cases cluster around the sync/async race (orphaned intents, webhook-before-confirm), duplicate delivery, and SCA/tax timing. The map groups them so the defense — the idempotency key, event-id dedup, or server-side recompute — is obvious.

Payment element edge cases Timing races need idempotency keys and a sweep, duplicate webhooks need event-id dedup, and SCA/tax timing needs server-side recompute before intent creation. Timing race orphaned intent webhook before confirm → idem key + sweep Duplicate PSP retries out-of-order → dedup on event id SCA / tax exemption mis-apply tax changes post-auth → recompute before intent
Three categories — timing, duplication, and SCA/tax — each defended by a boundary already in the schema.
Failure scenario Mitigation
Network timeout during confirm leaves an orphaned intent Deterministic idempotency key + a sweep job that polls requires_payment_method intents older than 15 min
Webhook arrives before the synchronous confirm returns Ledger keyed on event id, not request order; client result is advisory only
Duplicate webhook delivery (PSP retries) ON CONFLICT DO NOTHING on processed_events.event_id
Issuer mis-applies an SCA exemption → unexpected decline Capture the decline code; retry with request_three_d_secure: 'any'
Browser autofill overrides hosted-field masking Autofill cannot reach the iframe — keep card inputs inside the hosted element, never mirror them
Tax amount changes between intent creation and confirm Recompute tax server-side before intent creation; never adjust post-authentication
3DS challenge abandoned by the user Intent stays requires_action; expire it after the session TTL and surface a retry CTA

The webhook-before-confirm race in detail

Of all these failure modes, the webhook arriving before the client confirm resolves is the one that most often ships broken because it does not reproduce on a fast developer laptop. The defense has three parts working together, and removing any one reintroduces the bug. First, the ledger write is keyed on event.id, so the order in which the two observers arrive is irrelevant — whichever commits first wins and the second is a no-op. Second, the subscription transition to active is written as an idempotent upsert, so applying payment_intent.succeeded twice leaves status='active' and the same current_period_end, never a double-advanced cycle. Third, the client, on resolving, does not trust its own paymentIntent.status to render “you’re subscribed” — it calls your API, which reads the ledger the webhook already wrote. Wire those three and the race becomes a non-event; wire only the first and a user can watch a success screen while their subscription row still says incomplete because the client won the race and asserted nothing.

There is a nastier cousin: out-of-order webhook delivery. PSPs guarantee at-least-once delivery, not ordered delivery, so payment_intent.payment_failed for a retried attempt can land after payment_intent.succeeded for the successful one on the same subscription. If your failure handler naively flips the subscription to past_due, a late-arriving failure event can knock a paying customer into dunning. Guard against it by checking the intent’s current authoritative status before acting on a failure — or better, ignore payment_failed for any intent whose id already has a charge ledger entry. The event id dedup does not save you here because these are genuinely different events; you need a state check, not just a uniqueness check.

Refunds, disputes, and the reverse path

The happy path moves money in; a complete integration also handles it moving back out, and those webhooks travel the same handler. A charge.refunded or charge.dispute.created event must write a reversing ledger entry rather than deleting the original charge row — the double-entry model never mutates history, it appends a compensating transaction so the audit trail stays intact for revenue recognition. A dispute additionally freezes the disputed amount and can, if lost, claw back funds you already recognized, so the ledger entry for a dispute should be distinguishable in kind from an ordinary refund. Handling these on day one costs a few extra branches in the webhook switch; retrofitting them after finance discovers the ledger and the PSP balance have silently diverged costs a reconciliation project.

Performance & Scale

The two hot paths are intent creation (one PSP round trip, mitigated by preloading the script) and the webhook handler (kept to a short transaction). At scale, batch the reconciliation sweep rather than polling each intent — PSP read rate limits, not your DB, are the bottleneck. The diagram shows the two paths.

Payment element scale paths Intent creation is one PSP round trip with a preloaded script; the webhook handler keeps a short indexed transaction; the reconciliation sweep is batched to respect PSP rate limits. Intent create 80-250ms round trip preload script Webhook handler short transaction indexed event id Reconcile sweep 5-min batch, not per-intent PSP rate limit bound
Preload to hide intent latency, keep the webhook transaction short, and batch the sweep to respect PSP rate limits.

Intent creation is one PSP round trip (80–250 ms); cache the publishable key and preload the PSP script so the element mounts before the user reaches the field. The webhook handler is the hot path under load — keep its transaction short (insert event, one ledger row, one subscription update) and index processed_events(event_id) and ledger_entry(invoice_id). At 100k checkouts/day, batch the reconciliation sweep on a 5-minute cron rather than polling each intent; PSP rate limits (Stripe ~100 read req/s) make per-intent polling the bottleneck, not your DB.

The single most damaging performance mistake is doing real work inside the webhook HTTP handler before returning 200. PSPs treat a slow or failed acknowledgement as a delivery failure and retry with exponential backoff, so a handler that takes 8 seconds to send a receipt email, update a search index, and call an analytics API will get re-delivered, and now the same event is being processed two or three times concurrently. The dedup constraint saves correctness, but you are burning capacity racing yourself. The discipline is to make the synchronous handler do exactly two things — verify the signature and durably enqueue the event — and return 200 in single-digit milliseconds. Everything else (email, indexing, analytics, provisioning) happens in a downstream consumer that reads from that queue at its own pace. This also decouples your webhook uptime from your side effects: if the email provider is down, the payment still records and the receipt retries later.

Preloading without paying for it on every page

Preloading the PSP script hides intent latency, but loading it on pages where no one will check out wastes a render-blocking request and leaks the PSP’s own telemetry across your whole site. The balance is to preload only on the routes one hop away from checkout — the pricing page, the cart, the upgrade modal trigger — using a <link rel="preconnect"> to the PSP origins plus a lazy script import fired on the first intent-to-purchase signal (a click on “Upgrade”, a focus on the plan selector). By the time the checkout route mounts, the script is warm in cache and the element mounts in tens of milliseconds instead of waiting on a cold fetch. Measure this with the real metric that matters — time from route mount to the element being interactive — not the script’s own load time, because a script that loaded early but blocked on a slow connect-src still gives you a blank field.

What actually breaks first

When teams load-test this integration, the first thing to fall over is rarely the database and never the element. It is the outbound PSP call inside intent creation exhausting the connection pool or hitting the write rate limit during a traffic spike — a launch, a Black Friday promo, a marketing email that lands 50k people on the pricing page in ten minutes. Because intent creation is synchronous and on the checkout path, a PSP slowdown becomes a queue of held requests, then thread starvation, then a cascading timeout that takes down unrelated endpoints sharing the pool. Isolate the PSP client behind its own bounded connection pool and a circuit breaker, so a PSP degradation sheds checkout load gracefully with a “try again in a moment” instead of dragging the whole API down with it.

Testing Strategy

The tests use PSP magic cards to force each branch, replay a webhook twice to prove idempotency, forge a signature to prove rejection, and drive a mock clock for the orphan sweep. The panel lists them before the detail.

Payment element tests Magic cards force 3DS and declines, a replayed webhook yields one ledger row, a forged signature is rejected, and a mock clock tests the orphan sweep. Magic cards 3DS / decline each branch Replay webhook twice one ledger row Forgery wrong secret rejected Mock clock 15-min orphan sweep testable
The replay test is the headline — it proves the webhook path is idempotent under PSP retries.

Drive the PSP in test mode with magic card numbers: 4000002500003155 forces a 3DS challenge, 4000000000000341 attaches but fails on charge, 4000000000009995 returns insufficient_funds. Test the webhook path deterministically by replaying captured event JSON through the handler twice and asserting exactly one ledger row — that proves idempotency. Forge a signature with the wrong secret and assert the handler rejects it. Use a mock clock for the reconciliation sweep so the 15-minute orphan threshold is testable without waiting. Assert that an out-of-order delivery (payment_failed after succeeded for the same intent) does not corrupt state.

Frequently Asked Questions

Why trust the webhook over the synchronous confirmation result? The client result can be lost (tab closed, network dropped) or spoofed, and it does not survive a 3DS redirect cleanly. The signed webhook is verifiable, retried until acknowledged, and authoritative. Use the client result only to update the UI optimistically.

How does the hosted element affect my PCI-DSS scope? Because card data is entered into an iframe served by the PSP, raw PANs never touch your servers, dropping you to SAQ A (or SAQ A-EP if you customize the surrounding page). You still owe the SAQ A controls — TLS, access management, and keeping your checkout page free of code that could exfiltrate the iframe.

When should tax be calculated relative to confirmation? Always before intent creation. Compute the final amount including tax server-side, create the intent for that exact amount, then confirm. Post-authentication tax adjustments force partial refunds or credit notes and complicate revenue recognition.

Do I need idempotency keys if I already dedupe webhooks? Yes — they protect different boundaries. The idempotency key prevents duplicate intent creation on a retried checkout request; webhook deduplication prevents duplicate ledger application on retried event delivery. You need both.