Embedding Stripe in a Headless WooCommerce and Next.js Store: What Actually Matters
To put Stripe inside a headless WooCommerce and Next.js store, you take ownership of the payment flow that the official plugin normally hides. Your server creates a PaymentIntent, the browser collects the card through Stripe's Payment Element so raw card numbers never reach your code, the customer confirms, and a signed webhook, not the message the browser happens to show, is what marks the matching WooCommerce order paid. The secret key stays on the server; only the publishable key ships to the browser. Get those four pieces right, keys, PaymentIntent, Payment Element, and webhook, and the rest is detail. What follows is the field-tested version, with each load-bearing claim tied to Stripe's own documentation so you can check it rather than trust a screenshot.
The short version
- You own the payment lifecycle now. The official Stripe plugin renders checkout inside WordPress; once Next.js owns the front end, creating the PaymentIntent, confirming it, and closing the loop back to the order become your responsibility.
- Keys have a public half and a private half, and confusing them is the expensive mistake. Only the publishable key belongs in the browser; the secret or restricted key and the webhook signing secret never leave the server.
- The card never touches your server. Stripe's Payment Element captures it in the browser and hands your code a PaymentMethod, which is what keeps your PCI scope small.
- The webhook is the truth, not the customer's screen. A redirect or a dropped connection can leave the browser saying the wrong thing; the signed
payment_intent.succeededevent is what should flip the order to paid. - Authorize-then-capture is supported, but the hold has an expiry. Read
capture_beforefrom Stripe instead of guessing a number of days, or the authorization lapses and the money is gone. - Prove the entire lifecycle in test mode before a live key exists. Test cards and the Stripe CLI replaying real events at a local endpoint surface the failures long before a paying customer does.
Why does going headless move the payment into your hands?
The official Stripe extension for WooCommerce renders its checkout inside WordPress, which is exactly right when WordPress also draws your storefront. Take the front end headless, with Next.js rendering the store and WooCommerce demoted to a commerce backend behind it, and that arrangement no longer holds. You reach a fork. One path keeps the checkout step on the WooCommerce side and hands the shopper off to it. The other owns the payment directly in Next.js through Stripe's APIs and leaves WooCommerce as the system of record for products and orders. We took the second road, and everything below assumes it.
The trade deserves to be named plainly. You win a checkout that matches your brand and your front end down to the pixel, and in exchange you accept the payment lifecycle the plugin used to manage on your behalf. That is not a reason to avoid headless; it is a reason to treat the payment layer as its own small, carefully built system rather than an afterthought bolted onto the storefront. If you are still weighing whether a headless front end earns that responsibility at all, when a Next.js storefront pays off makes the wider case for the architecture.
Which Stripe keys belong in the browser, and which never leave the server?
Stripe issues two kinds of keys, and the distinction is structural rather than cosmetic. Per the keys documentation, the publishable key (pk_test_..., pk_live_...) is the only one safe to expose to a browser. The secret key (sk_...) has unrestricted power over your account and must stay on your server. Stripe also offers restricted keys (rk_...) scoped to specific resources, and it now recommends reaching for those over a raw secret key on new work.
In Next.js this collapses to a single discipline: the NEXT_PUBLIC_ prefix is what makes an environment variable visible to the browser, so exactly one key gets it.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYfor the client.STRIPE_SECRET_KEY, or a restricted key, for the server only.- Separate test and live keys, since an object created in one mode is invisible to the other.
Every request authenticates with a key over HTTPS, and plain HTTP fails by design. Treat a leaked secret key with the same alarm you would treat a leaked database password, because the blast radius is identical.
How does the browser collect a card without it touching your code?
The browser side is built on Stripe.js and the Payment Element, one UI component that renders cards and dozens of other methods, validates input, and surfaces errors on your behalf. In a React or Next.js app you mount it through the official @stripe/stripe-js and @stripe/react-stripe-js libraries. The sequence is: create a PaymentIntent on the server, pass its client_secret down, render the element, and confirm.
const stripe = useStripe();
const elements = useElements();
await stripe.confirmPayment({
elements,
confirmParams: { return_url: 'https://yourstore.com/checkout/complete' }
});
What makes this safe to run in a browser at all is tokenization. The element captures the sensitive digits and represents them to your systems as a PaymentMethod, the modern successor to a single-use Token, so the raw card number is Stripe's problem and never yours. That is the whole reason your compliance surface stays manageable: the most sensitive data in the transaction lives in the one place you are deliberately not storing, a boundary we lay out in the PCI compliance for a custom checkout guide.
Two details from the docs earn their keep here. The client_secret lets the client read and confirm exactly one PaymentIntent, so Stripe insists you keep it on a TLS page, never log it, and never drop it into a shared URL. And the return_url is not decoration: redirect-style methods and many authentication steps send the customer away and back, so your completion page has to read the PaymentIntent from the query string and check its status rather than assume the trip ended in success.
What is a PaymentIntent, and why create it on the server?
A PaymentIntent is the object that tracks one payment from the first attempt through any authentication to its final result. You build it server-side with the amount in the smallest currency unit, the currency, and your method configuration, then return only the client_secret to the browser.
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
Its status field is the spine your UI and your order logic hang off. A PaymentIntent walks through requires_payment_method, requires_confirmation, requires_action (the authentication step lives here), processing, and succeeded, with canceled reachable before it finishes. The habit that keeps a headless checkout honest is reading that status back from Stripe rather than inferring an outcome from whether a function happened to return without throwing.
How do you authorize a card now and capture the money later?
Plenty of businesses need to hold a card at checkout and take the money later, when the item ships or a booking is confirmed. Stripe supports this directly, under placing a hold on a payment method. Create the PaymentIntent with capture_method: 'manual'; after authorization it rests at requires_capture; later you capture it, optionally for less with amount_to_capture, and a partial capture releases the balance for you.
The part that catches teams out is expiry. A hold does not last indefinitely, its window depends on the card network, and Stripe hands you the deadline as capture_before on the charge. Miss it and the authorization is released while the PaymentIntent slides to canceled. The rule from production is blunt: never hardcode an assumed number of days, read capture_before and shape your fulfillment timing around the value Stripe gives you.
Why is the webhook the real source of truth, not the browser?
This is where headless integrations most often quietly break. The result the customer sees is a courtesy, not a fact. The fact is the webhook Stripe delivers to your server, and to trust it you verify its signature. Per the signature documentation, Stripe signs each event with a secret (whsec_...), sends a Stripe-Signature header, and expects you to confirm it with the library's constructEvent.
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
}
The failure that burns almost everyone is body parsing. Stripe verifies against the exact UTF-8 bytes it sent, so any framework that helpfully turns the JSON into an object before you check the signature has already broken it. In an App Router route handler you sidestep that by reading req.text() for the raw payload. Three further rules travel with this: answer with a 2xx fast and do the slow work afterward so Stripe does not time out and retry; record each event.id and skip anything you have already processed, because at-least-once delivery makes duplicates normal; and subscribe only to the events you actually act on, typically payment_intent.succeeded and payment_intent.payment_failed. The same defensive posture is worth applying to every webhook you receive, which is why we keep a standalone reliable webhooks playbook.
How do you reconcile Stripe back to the WooCommerce order?
Here is the piece the official extension would otherwise handle for you. Because Next.js runs the checkout, your webhook handler is the thing that closes the circle. When a verified payment_intent.succeeded lands, you call the WooCommerce REST API to move the matching order to paid, and only then set fulfillment in motion. The trick that makes reconciliation always possible is tying the two records together at creation: stamp the WooCommerce order id into the PaymentIntent metadata up front, so either system can find its counterpart later. Treat the webhook, never the browser redirect, as the event that changes order state. This is the same clean-integration discipline behind why connecting your stack beats copy and paste, and the judgment about where custom code belongs is the one we unpack in when you need custom code rather than another plugin.
How do you take a headless Stripe checkout live, step by step?
The order of operations matters as much as the code, because each step proves the one before it.
- Load test keys and confirm the split. Put the publishable key behind
NEXT_PUBLIC_and the secret key in a server-only variable, then verify from the browser console that the secret is genuinely absent from the client bundle. - Stand up the PaymentIntent endpoint. A server route that accepts a cart, computes the amount in minor units on the server (never trusting a total sent from the client), and returns a
client_secret. - Render the Payment Element and confirm. Wire the element to that
client_secret, set a realreturn_url, and drive one payment with a Stripe test card end to end. - Add the webhook handler with signature checking. Read the raw body, verify against the signing secret, and make the paid-order update idempotent from the first line rather than as a later patch.
- Replay events with the Stripe CLI. Forward real test events to your local endpoint and confirm success, failure, and duplicate deliveries all behave, so you are testing Stripe's actual payloads rather than your own mock.
- Reconcile against a real WooCommerce order. Create a test order, run the payment, and watch the webhook flip its status and trigger fulfillment with the metadata link intact.
- Swap in live keys behind a smoke test. Only once every path above is green do you rotate to live credentials and run a single small real charge, refund included, before opening the doors.
What should you check before flipping to live keys?
- Is the secret key confirmed absent from every client bundle and network response?
- Does the server compute the charge amount itself, ignoring any total the browser sends?
- Is the amount expressed in the smallest currency unit, with no stray decimal?
- Does the completion page read PaymentIntent status from Stripe rather than assuming the redirect meant success?
- Is the webhook handler verifying the signature over the raw body?
- Does it return
2xxquickly and deduplicate onevent.id? - For manual capture, does your timing key off
capture_beforeinstead of a fixed number of days? - Have you watched a refund, a decline, and an authentication challenge all behave in test?
Common pitfalls
Trusting the browser instead of the webhook. The most expensive headless payment bug is treating the client's success screen as confirmation and fulfilling on it. A dropped connection, a closed tab, or an authentication step that never completed can leave the browser reporting a win that Stripe never recorded, and now you have shipped against money that will not arrive.
Letting the framework parse the webhook body. A verifier that looks correct but rejects every real event is almost always reading a re-serialized body instead of the original bytes. The signature is computed over what Stripe sent, character for character, so any middleware that touches the payload first quietly guarantees failure.
Hardcoding the capture window. Teams assume a hold lasts "about a week," build fulfillment around that guess, and lose captures when a particular card network's window is shorter. The deadline is handed to you; ignoring it in favor of an assumption is a self-inflicted loss.
A concrete case. A store we reviewed had moved its front end to Next.js and wired Stripe in a weekend. It worked in every demo, because every demo used a fast connection and a card that sailed through. In production, a slice of orders were being marked paid the instant the browser returned from the redirect, before any webhook arrived. Most of the time the payment did land and nobody noticed the gap. The trouble showed up in the edge cases: customers who abandoned during a 3D Secure challenge, or whose bank ultimately declined, still generated a "paid" order and, in a few instances, a shipped product. The fix was not clever. We moved the paid transition into a signature-verified webhook handler, tied each PaymentIntent to its order through metadata, made the update idempotent, and deleted the browser-side shortcut entirely. The reconciliation drift stopped the day the truth moved from the customer's screen to Stripe's signed event.
How we build the payment layer so it stays calm
Payments earn more caution than almost anything else, so the process is the product, not a footnote. We prove the entire lifecycle in test mode before a live key exists, ship a fixed-scope first phase (usually one clean card payment, authorized, captured, and reconciled to a WooCommerce order) before wallets or saved methods get layered on, and demo every path, success, failure, refund, and authentication, on staging before real cards are involved. You own the Stripe account, the code, and the data throughout, with no lock-in, which is what makes the integration safe to trust and cheap to extend. This is not theory for us: the headless LeO-Optic store runs on Next.js and WooCommerce with full payments, the exact architecture described here, and we build money-sensitive systems like customs-invoice.com where being wrong is costly. If you are taking a WooCommerce store headless and want the payment layer done so it is correct, owned, and quiet 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 also on your list, the companion piece on the Revolut Merchant API on Vercel covers wallets, 3D Secure, and the sync versus async trap.
FAQ
Should checkout live on the WooCommerce side or in Next.js?
Either can work, and the choice is really about how much you value a fully branded, front-end-native checkout. Handing off to WooCommerce for the payment step is less work and lets the plugin manage the lifecycle, but the shopper leaves your Next.js experience at the moment that matters most. Owning the flow in Next.js keeps the customer inside your design end to end and gives you exact control, at the cost of taking responsibility for PaymentIntents, confirmation, and reconciliation yourself. For a store that went headless specifically to control the experience, owning the payment is usually the consistent choice.
Is it safe to handle card payments in the browser?
Yes, because your code never actually handles the card. Stripe's Payment Element captures the sensitive details inside Stripe-hosted fields and returns a PaymentMethod token, so the raw number travels from the customer to Stripe without passing through your server or your JavaScript. That tokenization is precisely what keeps the dangerous data out of your systems and your PCI obligations modest. The rule is simply to keep the publishable key in the browser and the secret key on the server, and to let Stripe's element do the collecting.
Why does my Stripe webhook signature keep failing?
Almost always because something parsed the request body before you verified it. Stripe checks the signature against the exact bytes it transmitted, so if your framework deserializes the JSON and your handler then reads a re-encoded version, the hashes will never match. In a Next.js App Router route you avoid this by reading req.text() to get the untouched payload and passing that to constructEvent. Confirm too that you are using the signing secret for the correct endpoint and mode, since a test secret will not validate a live event.
How do I authorize a card now and charge it later with Stripe?
Create the PaymentIntent with capture_method: 'manual'. After the customer authorizes, it holds at the requires_capture status, and you capture it when you are ready to ship or confirm, optionally for a smaller amount that automatically releases the remainder. The one thing you must respect is the expiry: Stripe returns a capture_before timestamp on the charge, the hold lapses after it, and the window length depends on the card network, so build your capture timing around that value rather than a fixed assumption.
What actually connects Stripe to my WooCommerce orders in a headless build?
Your webhook handler does, and metadata is the glue. When you create the PaymentIntent, write the WooCommerce order id into its metadata so the two records point at each other. Then, when a verified payment_intent.succeeded event arrives, your handler calls the WooCommerce REST API to mark that specific order paid and starts fulfillment. Nothing about the browser session updates order state; the signed event does. That single discipline is what keeps the two systems reconcilable even when a customer closes the tab at the wrong moment.
Do I need to test in Stripe's live mode to be confident?
No, and you should not start there. Test mode plus Stripe's published test cards will exercise success, decline, refund, and authentication paths, and the Stripe CLI can forward genuine event payloads to your local endpoint so you are validating against real webhook shapes rather than invented ones. Objects never cross between test and live, so nothing you do in test can touch a real card. The only thing reserved for live mode is a final, deliberate smoke test: one small real charge and refund to confirm the production keys and endpoints are wired correctly before you open to customers.
Have a project in mind?
Let's turn it into custom software that moves your business forward.