Migrate to RunxBuild today!! Deploy More while Spending Less

Calculate your savings
unxBuild

Netlify and Stripe: Checkout, a Serverless Function, and Webhooks Done Properly

Sean

Platform Writer

Sep 20, 2026
9 min read

Stripe Checkout works on a static Netlify site by keeping the secret key server-side: a Netlify Function creates the Checkout session using STRIPE_SECRET_KEY, the browser redirects to Stripe’s hosted checkout page using only the returned session ID, and a separate webhook function verifies Stripe’s signature and confirms the payment before you consider the order real. Skipping the webhook and trusting the redirect back to your success page is the most common mistake, because that redirect can be replayed or spoofed; the webhook is the only step that actually confirms money moved.

Netlify and Stripe: Checkout, a Serverless Function, and Webhooks Done Properly

Most Netlify-and-Stripe tutorials stop exactly at the redirect to Stripe’s checkout page, which looks like a finished integration and is not. Nothing in that flow confirms payment succeeded; a user can hit the success URL without paying, and if your fulfillment logic runs on that page load, you have a hole. Here is the complete loop, ending where fulfillment actually happens.

Table of contents

The secret key rule that makes this a serverless-function problem

Stripe’s secret key can create charges, refunds and Checkout sessions, and it can never reach the browser bundle. That single rule is why a static site cannot talk to Stripe’s session-creation API directly from client-side JavaScript; it needs a server-side step in between, which on a static host means a serverless function.

The publishable key is the opposite: safe in the browser by design, used only to redirect to the Checkout session Stripe already created. Keep the two straight; putting the secret key in a client-exposed variable is the single most damaging mistake in this setup.

Creating the Checkout session

A Netlify Function reads the cart or product details from the request, creates a Checkout session server-side with the secret key, and returns the session URL for the browser to redirect to.

// netlify/functions/create-checkout.js
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

exports.handler = async (event) => {
  const { priceId, quantity } = JSON.parse(event.body);

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: priceId, quantity }],
    success_url: `${process.env.URL}/success`,
    cancel_url: `${process.env.URL}/cancel`,
  });

  return { statusCode: 200, body: JSON.stringify({ url: session.url }) };
};

Using a priceId created in the Stripe dashboard, rather than accepting a price from the client request, closes the obvious hole where a modified request could set its own amount. The browser only ever sends which product, never how much it costs.

The redirect, and why the success page alone proves nothing

The frontend calls the function, gets the session URL, and redirects the browser to it. Stripe hosts the entire payment form; your site never touches card details, which is also why this pattern keeps you out of most PCI compliance scope.

const res = await fetch("/.netlify/functions/create-checkout", {
  method: "POST",
  body: JSON.stringify({ priceId: "price_123", quantity: 1 }),
});
const { url } = await res.json();
window.location.href = url;

After payment, Stripe redirects the browser to your success_url. That page loading is not proof of payment. A user who navigates to that URL directly, or whose payment failed after an initial redirect, would also land there. Fulfillment, sending the download, granting access, emailing the receipt, cannot key off this page render.

The webhook: the step that actually confirms payment

A second function, registered as a webhook endpoint in the Stripe dashboard, receives an event whenever a Checkout session completes, verified by signature so you know it genuinely came from Stripe.

// netlify/functions/stripe-webhook.js
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

exports.handler = async (event) => {
  const sig = event.headers["stripe-signature"];
  let stripeEvent;
  try {
    stripeEvent = stripe.webhooks.constructEvent(
      event.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return { statusCode: 400, body: `Webhook Error: ${err.message}` };
  }

  if (stripeEvent.type === "checkout.session.completed") {
    // fulfill the order here: send email, grant access, record it
  }

  return { statusCode: 200, body: "ok" };
};

STRIPE_WEBHOOK_SECRET comes from the webhook’s settings in the Stripe dashboard, separate from the API secret key. Signature verification is what stops anyone from posting a fake payment completed event straight to your endpoint.

Environment variables and the raw body gotcha

STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET belong in Netlify’s environment variable settings, never committed. One frequent breakage: signature verification needs the exact raw request body, and some frameworks or middleware parse the body to JSON before your function sees it, which changes the bytes Stripe signed against and makes every signature check fail. Confirm your function receives the unmodified raw body before debugging anything else about the webhook.

Test the webhook locally before trusting it in production. The Stripe CLI can forward live events to a local function with stripe listen --forward-to localhost:8888/.netlify/functions/stripe-webhook, which triggers real signed events against your handler without needing a real card or a deployed URL. Trigger a specific event on demand with stripe trigger checkout.session.completed to confirm the fulfillment branch actually runs.

  • checkout.session.completed is the event that confirms payment for a one-time Checkout session; this is the one most integrations fulfill on.
  • checkout.session.expired fires if a customer abandons checkout, useful for cleaning up any reserved inventory.
  • payment_intent.payment_failed covers a card that was declined after the session started, worth logging even if you take no action on it.

Where this pattern runs out

This static-plus-functions shape is genuinely fine for a simple product or a handful of price points. It starts straining once you need persistent order records queryable later, inventory that decrements per sale, customer accounts, or subscription state beyond what Stripe’s own dashboard conveniently shows you. At that point the project needs a real backend with a database behind it, not more functions bolted onto a static site.

A Node service on RunxBuild with a managed Postgres database beside it holds that order and customer state directly, with the webhook handler as one route in a normal backend rather than an isolated function. The Checkout and webhook code above moves over largely unchanged; what changes is having somewhere durable to write the result.

How this fits the rest of the stack

If the store is starting to outgrow functions and needs actual order records, price the service and the database together rather than adding infrastructure piece by piece: the RunxBuild hosting calculator puts both on one page. Node services on RunxBuild covers moving the webhook handler into a real backend, and the dashboard is where that service and its database connect.

Useful related references:

FAQ

Can I use Stripe Checkout on a static site?

Yes, with one serverless function to create the Checkout session server-side, since the secret key can never reach the browser. The static frontend calls the function, gets a session URL, and redirects to Stripe’s hosted checkout page, which handles the actual card entry.

Do I need a webhook for Stripe Checkout?

Yes, for anything you fulfill automatically. The success page redirect alone does not prove payment happened; a webhook verified with Stripe’s signature is the only step that confirms the session actually completed before you send a download or grant access.

Why does my Stripe webhook signature verification keep failing?

Almost always the raw request body has been altered before your handler sees it, often by middleware that parses it to JSON first. Signature verification needs the exact bytes Stripe signed; confirm your function receives the unmodified raw body.

Is it safe to put the Stripe price amount in the frontend request?

No. Create the price in the Stripe dashboard ahead of time and send only its ID from the client. If the amount itself comes from the request body, a modified request can set its own price.

When does a static site plus Stripe functions stop being enough?

Once you need persistent order records, decrementing inventory, or customer accounts beyond what Stripe’s dashboard shows. At that point a real backend with a database handles the webhook and the order state directly, rather than adding more isolated functions.

#netlify stripe#stripe checkout netlify#netlify functions stripe#stripe webhook netlify#static site stripe integration