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

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.

Implementation Walkthrough

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.

// 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.

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

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

Performance & Scale

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.

Testing Strategy

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.