Skip to main content

07 - Headless Payments Integration: Stripe Elements, Payment Intents and European SCA

Connect Stripe to a headless storefront without trusting the browser: server-set amount, signed-webhook confirmation and protection from duplicate events.

What do you think about this article?

At a Glance

Connect Stripe to a headless storefront without trusting the browser: server-set amount, signed-webhook confirmation and protection from duplicate events.

Headless Commerce 12 min

How to connect Stripe to a headless storefront without trusting the browser: amount decided by the server, 3D Secure authentication handled by Stripe, order confirmation entrusted to verified webhooks, and duplicate or out-of-order deliveries under control.

stripe payment intents SCA PSD2 stripe webhook idempotency headless payments

The problem: the browser lies, the network drops, events arrive twice

A payment in a headless storefront passes through four places: the customer’s browser, your server, Stripe and the bank. Each can make a mistake or be hostile. The browser can be tampered with and declare an amount of one cent; the network can drop after the bank has charged; Stripe can deliver the same notice more than once. The integration that works in a demo and loses money in production can be recognised by four assumptions: “the browser tells the truth about the total”, “if the client says success, the order is paid”, “every message on the webhook comes from Stripe”, “every event arrives once only, in order”. This article takes the four apart, in order, with code you can rerun.

Which API to use: Payment Intents is not always the answer today

Before the technique, an update that many tutorials do not report. On the Accept a payment page (consulted on 2026-11-12) Stripe recommends the Checkout Sessions API with the Payment Element for most integrations, and describes Payment Intents as the choice that requires more code: you need it when you have a complex payment flow that Checkout Sessions does not cover. Checkout Sessions handles tax calculation, discounts and shipping on its own, and some features are available only there. The Payment Intents page presents them as the API for complex flows with a state that changes during the payment lifecycle.

In a headless storefront the choice depends on where the truth about the order lives. If the commerce backend (Medusa, Saleor, Shopify) already creates the order and calculates taxes and shipping, you just need a payment for a known amount and Payment Intents are linear. If you want Stripe to calculate lines, taxes and discounts, consider Checkout Sessions. The rest of the article applies to Payment Intents, but the two rules that matter (amount from the server, confirmation from the webhook) apply to both. As for regulation: Stripe reports that Payment Intents are ready for the Strong Customer Authentication in force since 14 September 2019, while the old Charges API is not; exemptions are applied by Stripe and the issuing bank, not by your code, and which ones apply to you is a question for a consultant.

First mistake to avoid: the amount comes from the server

The flow opens with a call from the browser to your server, which creates the Payment Intent and returns its client secret: a key that lets the browser read and confirm that payment, but not change its amount. In the listing, the browser sends only ordineId; the amount is read from the order saved by your backend, in whole cents, and the request carries an idempotencyKey built on the order.

There is a trap on the amount: “in cents” holds for the euro, not for every currency (the yen has no decimals). The rule and an executed listing are in Adapter, Decorator and Facade.

// app/api/checkout/payment-intent/route.ts - Next.js App Router, server
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { caricaOrdine } from '@/lib/ordini'; // YOUR access to the orders database

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // secret key: on the server only

export async function POST(req: Request) {
  const { ordineId } = await req.json();

  // The amount does NOT come from the browser: it is read from the order saved on the server.
  const ordine = await caricaOrdine(ordineId);
  if (!ordine || ordine.stato !== 'pending') {
    return NextResponse.json({ errore: 'invalid order' }, { status: 400 });
  }

  const intent = await stripe.paymentIntents.create(
    {
      amount: ordine.totaleCents, // integer, in the smallest unit of the currency (cents for the euro)
      currency: 'eur',
      automatic_payment_methods: { enabled: true },
      metadata: { ordineId: ordine.id },
    },
    // A repeated request (network, double click) returns the same intent
    { idempotencyKey: `intent-${ordine.id}` },
  );

  return NextResponse.json({ clientSecret: intent.client_secret });
}

This block was not run: it requires the stripe library, a secret key and a database; it is a schema to adapt, and @/lib/ordini is your access to orders. The two choices that matter are in the code. First, amount comes from the order, not from the request: the naive version accepts an amount chosen by the client. Second, the idempotency key: the documentation on idempotent requests explains that it lets you repeat a request after a connection error without creating a second object, and the Payment Intents page recommends building it on the cart or order identifier. Without it, a double click or a retry after a timeout creates two payments for the same order.

Second mistake: declaring an order paid from the client

In the browser, the Payment Element collects the data and stripe.confirmPayment confirms it. If the bank requires authentication (3D Secure), the library opens the window or redirects to the bank’s page and then returns to the return_url. With the redirect: 'if_required' option, described in the Stripe.js reference, the redirect happens only for payment methods that require it; the same page warns that in that case you must handle confirmations with and without redirect separately.

// components/PaymentForm.tsx - Client Component
'use client';
import { useState } from 'react';
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';

export function PaymentForm({ ordineId }: { ordineId: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [errore, setErrore] = useState<string | null>(null);
  const [inCorso, setInCorso] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return; // Stripe.js not loaded yet
    setInCorso(true);

    const { error, paymentIntent } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${location.origin}/checkout/esito?ordine=${ordineId}` },
      redirect: 'if_required', // redirect only for the methods that require it
    });

    if (error) setErrore(error.message ?? 'Payment failed');
    // Here the order is NOT marked as paid: the webhook on the server decides.
    else if (paymentIntent?.status === 'succeeded') location.assign(`/checkout/esito?ordine=${ordineId}`);
    setInCorso(false);
  }

  return (
    <form onSubmit={onSubmit}>
      <PaymentElement />
      {errore && <p role="alert">{errore}</p>}
      <button disabled={!stripe || inCorso}>Pay</button>
    </form>
  );
}

This block was not run either (it needs the React project with Stripe’s libraries). Look at the central comment: the client does not mark the order as paid. If it did, a customer who closes the tab an instant before the return, or a user who calls the endpoint by hand, would leave the state inconsistent with the bank. The browser shows the outcome to the user; whoever changes the state of the order is the server, when it receives from Stripe the event that says what really happened.

Third mistake: trusting the webhook without verifying it

The webhook endpoint is a public address: anyone can send it a message pretending to be Stripe. That is why every delivery carries the Stripe-Signature header, with a timestamp and one or more v1 signatures. The webhook documentation recommends the official libraries, but also describes manual verification in four steps: separate the timestamp and the signatures; build the string timestamp + "." + unmodified body; compute the HMAC-SHA256 with the endpoint secret; compare in constant time with each v1 and then check that the timestamp falls within the tolerance. The listing implements them one by one and was run with Node 24.

import { createHmac, timingSafeEqual } from 'node:crypto';

// Manual verification of Stripe-Signature, following the four steps of the documentation.
export function verificaStripe(corpoGrezzo: string, intestazione: string, segreto: string, adesso = Date.now() / 1000, tolleranzaSec = 300) {
  // Step 1: split on "," and then on "="; keep only t and v1 (ignore v0, secret, destination, payload...)
  const voci = intestazione.split(',').map((v) => v.split('=') as [string, string]);
  const t = voci.find(([k]) => k === 't')?.[1];
  const v1 = voci.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || v1.length === 0) return 'incomplete header';

  // Steps 2 and 3: HMAC-SHA256 of "<t>.<unmodified body>" with the endpoint secret
  const atteso = createHmac('sha256', segreto).update(`${t}.${corpoGrezzo}`).digest();

  // Step 4: constant-time comparison with EVERY v1; timingSafeEqual throws if the lengths differ
  const valida = v1.some((firma) => {
    const bytes = Buffer.from(firma, 'hex');
    return bytes.length === atteso.length && timingSafeEqual(bytes, atteso);
  });
  if (!valida) return 'invalid signature';
  if (Math.abs(adesso - Number(t)) > tolleranzaSec) return 'timestamp outside tolerance (possible replay)';
  return 'ok';
}

if (import.meta.main) {
  const segreto = 'whsec_for_testing';
  const corpo = '{"id":"evt_1","type":"payment_intent.succeeded"}';
  const t = Math.floor(Date.now() / 1000);
  const firmaDi = (ts: number, c: string) => createHmac('sha256', segreto).update(`${ts}.${c}`).digest('hex');

  console.log('correct signature     :', verificaStripe(corpo, `t=${t},v1=${firmaDi(t, corpo)},v0=ignored`, segreto));
  console.log('two v1 (rotation)     :', verificaStripe(corpo, `t=${t},v1=${'0'.repeat(64)},v1=${firmaDi(t, corpo)}`, segreto));
  console.log('modified body         :', verificaStripe(corpo.replace('evt_1', 'evt_2'), `t=${t},v1=${firmaDi(t, corpo)}`, segreto));
  console.log('reformatted body      :', verificaStripe(JSON.stringify(JSON.parse(corpo), null, 2), `t=${t},v1=${firmaDi(t, corpo)}`, segreto));
  const vecchio = t - 3600;
  console.log('valid, 1h old         :', verificaStripe(corpo, `t=${vecchio},v1=${firmaDi(vecchio, corpo)}`, segreto));
  console.log('truncated signature   :', verificaStripe(corpo, `t=${t},v1=abcd`, segreto));
}

// Output:
// correct signature     : ok
// two v1 (rotation)     : ok
// modified body         : invalid signature
// reformatted body      : invalid signature
// valid, 1h old         : timestamp outside tolerance (possible replay)
// truncated signature   : invalid signature

Each of the six cases in the output corresponds to a rule in the documentation. The case with two v1 exists because, when you rotate an endpoint’s secret, Stripe signs with both for a period and it is enough for one to be valid. The “reformatted” body has the same JSON content with different whitespace and fails: that is why the code must read the raw body (in Next.js with req.text(), not req.json()). The valid but one-hour-old signature is rejected by the timestamp check: the default tolerance of the official libraries is 5 minutes, and setting it to zero disables the check. The line that checks the length before timingSafeEqual is there because that function throws an error if the two buffers have different lengths: the truncated signature of the last case shows a clean rejection instead of a 500. In production use the official library; this code serves to understand what it does.

Fourth mistake: believing events arrive once, in order

Again according to the webhooks page, Stripe retries delivery for up to three days, with increasing waits, in live mode: if your endpoint answers with an error or times out, the same event comes back. It also does not guarantee that events arrive in the order in which they were generated, and recommends tracking event identifiers to recognise duplicates instead of using the created field. Another obligation: return a 2xx quickly, before doing slow logic.

// Stripe may deliver the same event more than once and does not guarantee delivery order.
type Evento = { id: string; tipo: 'payment_intent.payment_failed' | 'payment_intent.succeeded'; ordineId: string };
type Stato = 'pending' | 'paid' | 'failed';

const giaVisti = new Set<string>(); // in production: a table with a UNIQUE constraint on event_id
const ordini = new Map<string, Stato>([['ord_1', 'pending']]);

export function applica(e: Evento): string {
  if (giaVisti.has(e.id)) return `${e.id}: duplicate, ignored`;
  giaVisti.add(e.id);

  const stato = ordini.get(e.ordineId);
  if (stato === undefined) return `${e.id}: unknown order`;
  if (stato === 'paid') return `${e.id}: order already paid, no regression`; // a late "failed" does not cancel a paid order

  ordini.set(e.ordineId, e.tipo === 'payment_intent.succeeded' ? 'paid' : 'failed');
  return `${e.id}: order -> ${ordini.get(e.ordineId)}`;
}

if (import.meta.main) {
  const successo: Evento = { id: 'evt_B', tipo: 'payment_intent.succeeded', ordineId: 'ord_1' };
  const fallito: Evento = { id: 'evt_A', tipo: 'payment_intent.payment_failed', ordineId: 'ord_1' };
  // The first card attempt failed, the second succeeded, but the events arrive reversed and one of them twice
  for (const e of [successo, successo, fallito]) console.log(applica(e));
  console.log('final state:', ordini.get('ord_1'));
}

// Output:
// evt_B: order -> paid
// evt_B: duplicate, ignored
// evt_A: order already paid, no regression
// final state: paid

The listing’s scenario is plausible: the first card attempt fails, the second succeeds, and the events show up reversed with a duplicate. The output shows three defences. The giaVisti set discards the duplicate; in production it is a table with a uniqueness constraint on the event ID, because an in-memory set does not survive a restart. The state check prevents a late failure event from sending an already paid order back to “failed”. The final state is paid. The limit: this model has three states; a real order has refunds, disputes and partial payments, and each adds transition rules to be defined at the drawing board.

Where your commerce platform comes in

If you chose one of the platforms in the series, first check whether it already has a payment integration: Saleor uses payment apps with its own transactions (see the article on Saleor), Medusa has a dedicated module with providers (article on Medusa) and Shopify handles payment in its own checkout. Building your own flow with Stripe, as here, makes sense when the backend does not give you a ready-made module or when you need control it does not offer. In all other cases, writing less payment code is the most prudent choice. For the connection with the cart and the server-side total calculation, start again from the article on the Next.js storefront. When the flow works, also measure the speed of the payment page with the thresholds in the article on Core Web Vitals and SEO.

What you can do now

You can choose between Checkout Sessions and Payment Intents with the right criterion, create a payment whose amount and idempotency depend on the server, keep order confirmation out of the browser, manually verify the signature of a Stripe webhook (and why the raw body is needed) and protect the order from duplicates and out-of-order events. In the chapter on content commerce the series moves from payment to content.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects