Pulse
7IT Solutions
← All articles
Payments

Embedding Stripe in a Headless WooCommerce and Next.js Store: What Actually Matters

Lior Aharonov Lior Aharonov 9 min read

Payments are the one integration where a small mistake is not a bug report, it is lost money and lost trust at the exact moment a customer decided to buy. Doing it inside a standard WooCommerce theme is mostly solved by the official plugin. Doing it in a headless setup, where Next.js renders the storefront and WooCommerce sits behind it as the commerce backend, is a different exercise, because you are now responsible for the wiring the plugin used to hide. This is a walk through the pieces that matter, built from actually shipping one, with each technical claim grounded in Stripe's official documentation so you can verify it rather than take my word for it.

The headless decision changes where payment lives

The official Stripe extension for WooCommerce renders its checkout inside WordPress. That is exactly what you want when WooCommerce also renders your storefront, and it is not what you have when Next.js owns the front end. In a headless build you face a fork: either keep the checkout step on the WooCommerce side and hand off to it, or own the payment flow yourself in Next.js using Stripe's APIs directly and keep WooCommerce as the system of record for products and orders. We took the second path, and most of what follows assumes it. The trade is real and worth naming up front: you gain a checkout that matches your brand and your front end exactly, and you take on the responsibility for getting the payment lifecycle right. The rest of this article is about meeting that responsibility without nasty surprises. For why a headless front end is worth it in the first place, see when a Next.js storefront pays off.

Keys and authorization: the part you must not get casual about

Stripe gives you two kinds of keys, and the distinction is not cosmetic. Per the keys documentation, the publishable key (pk_test_..., pk_live_...) is the only key that is safe to expose in the browser. The secret key (sk_...) has unrestricted access to your account and must never leave your server. Stripe also offers restricted keys (rk_...) with per-resource permissions, and its current guidance is to prefer those over a raw secret key for new work.

In Next.js this maps cleanly to one rule: only the publishable key gets the NEXT_PUBLIC_ prefix that makes a variable visible to the browser. The secret or restricted key, and your webhook signing secret, stay as plain server-only environment variables.

  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for the client.
  • STRIPE_SECRET_KEY (or a restricted key) only on the server.
  • Keep separate test and live keys; objects created in one mode are invisible to the other.

Every Stripe API call authenticates with the key over HTTPS. Plain HTTP requests fail by design. Treat a leaked secret key the way you would treat a leaked database password, because it is the same class of mistake.

The UI layer: Stripe.js and the Payment Element

The browser side is built on Stripe.js and the Payment Element, a single UI component that renders cards and many other methods, validates input, and handles errors for you. In a React or Next.js app you use the official @stripe/stripe-js and @stripe/react-stripe-js libraries. The flow is: create a PaymentIntent on the server, pass its client_secret to the client, mount the Payment Element, and confirm.

const stripe = useStripe();
const elements = useElements();

await stripe.confirmPayment({
  elements,
  confirmParams: { return_url: 'https://yourstore.com/checkout/complete' }
});

Two details from the docs are easy to miss and important. First, the client_secret lets the client read and confirm that one PaymentIntent, so per Stripe you must keep it on a TLS page and never log it or put it in a URL you share. Second, the return_url is not optional in spirit: redirect-based methods and many authentication steps send the customer away and back, so your completion page must read the PaymentIntent from the query string and check its status rather than assuming success.

Tokens and PaymentMethods, without the card touching your server

The reason this is safe to do in the browser at all is tokenization. Sensitive card details are captured by Stripe's elements and represented to your systems as a token or, in the current model, a PaymentMethod, so raw card numbers never hit your server and your PCI scope stays small. A Token is a single-use representation of card data; a PaymentMethod holds reusable payment details and combines with a PaymentIntent to actually take the payment. For a new headless build, the path Stripe documents is the Payment Element plus PaymentIntents, with PaymentMethods created by Stripe.js on the client. The practical upshot is simple and reassuring: the part of the system handling the most sensitive data is the part you are not storing.

The backend: one PaymentIntent, created server-side

A PaymentIntent tracks a single payment from creation through to success, including any authentication steps. You create it on the server with the amount in the smallest currency unit, the currency, and your method configuration, then return only its client_secret.

const paymentIntent = await stripe.paymentIntents.create({
  amount: 5000,            // 50.00 in the smallest currency unit
  currency: 'usd',
  automatic_payment_methods: { enabled: true }
});
// send paymentIntent.client_secret to the client

The status values are worth knowing because your UI and your order logic key off them: a PaymentIntent moves through requires_payment_method, requires_confirmation, requires_action (this is where authentication happens), processing, and succeeded, with canceled available before it completes. Reading status from Stripe, rather than inferring it from whether a function returned, is the habit that keeps a headless checkout honest.

Authorize now, capture later

Plenty of businesses need to authorize a card at checkout and capture the money later, when the item ships or the booking is confirmed. Stripe supports this directly, documented under placing a hold on a payment method. You create the PaymentIntent with capture_method: 'manual'; after authorization it sits at status requires_capture; later you capture it, optionally for a smaller amount with amount_to_capture, and a partial capture releases the remainder automatically.

The detail that bites people is expiry. An authorization does not last forever, the window depends on the card network, and Stripe tells you exactly when it ends through the capture_before value on the charge. Capture before that, or the hold is released and the PaymentIntent moves to canceled. The lesson from production: never hardcode an assumed number of days, read capture_before and build your fulfilment timing around it.

Webhooks: the part that quietly breaks

Here is where headless integrations most often go wrong, and where the official guidance is worth following to the letter. The result the customer sees in the browser is not the source of truth. The source of truth is the webhook Stripe sends your server, and to trust it you must verify its signature. Per the signature documentation, Stripe signs each event with a secret (whsec_...) and sends a Stripe-Signature header, and you verify it with the library's constructEvent.

The single most common failure is body parsing. Stripe is explicit: the body must be the exact UTF-8 string it sent, with nothing added, removed, reordered, or re-encoded. Any framework that helpfully parses JSON before you verify will break the signature. In a Next.js App Router route handler you read the raw body with req.text():

export async function POST(req) {
  const body = await req.text();                 // raw, unparsed body
  const sig = req.headers.get('stripe-signature');
  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  if (event.type === 'payment_intent.succeeded') {
    // mark the WooCommerce order paid (idempotently)
  }
  return Response.json({ received: true });        // return 2xx quickly
}

Three more rules from the docs that production will test: return a 2xx quickly and do heavy work afterward so Stripe does not time out and retry; guard against duplicates by recording each event.id and ignoring ones you have already handled, because retries and at-least-once delivery are normal; and subscribe to the events you actually act on, typically payment_intent.succeeded and payment_intent.payment_failed.

Reconciling with WooCommerce

This is the headless-specific piece the official extension would otherwise handle. Because Next.js owns checkout, your webhook handler is what closes the loop: when payment_intent.succeeded arrives and verifies, you update the corresponding WooCommerce order through its REST API to paid, and only then trigger fulfilment. Tie the Stripe PaymentIntent to the WooCommerce order id in metadata at creation so the two systems can always be reconciled, and treat the webhook, not the browser redirect, as the event that changes order state. This is the same clean-integration discipline we describe in why connecting your stack beats copy and paste, and it is exactly the kind of judgment behind when you need custom code rather than another plugin.

How we ship this without drama

Payments deserve more caution than almost anything else, so the way we build is the point, not a footnote:

  • Everything in test mode first, end to end. Test keys, test cards, and the Stripe CLI to replay real webhook events at a local endpoint, so the whole lifecycle is proven before a live key exists.
  • A fixed-scope first phase. Usually one clean card payment, authorized, captured, and reconciled to a WooCommerce order, before adding wallets, saved methods, or manual capture.
  • Demos on a staging environment. You watch a payment succeed, fail, and get refunded, including the redirect and authentication paths, before anything touches real cards.
  • You own the keys, the code, and the data. It is your Stripe account and your codebase, with no lock-in, which is what makes the integration safe to trust and to extend.

Proof, not promises

This is not theory for us. The headless LeO-Optic store runs on Next.js and WooCommerce with full payments, which is precisely the architecture described here, and we build rules-heavy, money-sensitive systems like customs-invoice.com where being wrong is expensive. The patterns above are the ones that survived contact with real orders.

If you are taking a WooCommerce store headless and want the payment layer done so it is correct, owned, and calm under load, tell me about your stack and your checkout and I will give you a straight read on the cleanest first phase. If Revolut is on your list too, the companion piece on the Revolut Merchant API on Vercel covers wallets, 3D Secure, and the sync versus async trap.

Have a project in mind?

Let's turn it into custom software that moves your business forward.