Bolt Stripe webhook not firing? Here's how to find and fix it.
The card gets charged, the customer sees a success page — and then nothing. No order, no subscription, no access granted. That gap is almost always a Stripe webhook that didn't fire or got rejected. Here's a calm, ordered checklist to find which cause it is, and the exact fix for each.
If payment succeeds but no order is created, your app never received a clean checkout.session.completed webhook. The usual culprits: the endpoint isn't registered or points at a preview URL (#1), the signing secret doesn't match (#2), the handler verifies a parsed body instead of the raw bytes (#3, the #1 code bug), or a test/live mismatch (#4). Check the endpoint's recent deliveries in Stripe first — it tells you which one.
First, narrow it down
Before changing any code, look at what Stripe already recorded. Open the Stripe Dashboard → Developers → Webhooks, click your endpoint, and read the recent deliveries list. That one screen tells you which half of the problem you have:
- No deliveries at all → Stripe isn't sending. The endpoint is missing, the event type isn't selected, or you're in the wrong mode. See #1, #4, #7.
- Deliveries show a red 400 → Stripe sent it but your app rejected it on signature check. See #2 and #3.
- Deliveries show a red 500 → your handler ran but threw an error. See #6.
- Deliveries show a timeout → your function was asleep or too slow to answer. See #5.
1. The endpoint isn't registered, or points at the wrong URL
Stripe only sends webhooks to URLs you've explicitly registered. A common Bolt mistake is registering a preview URL (the temporary link Bolt gives you while building) instead of the live deployed function — or never registering one at all. When you deploy to your real domain, the preview URL stops serving the handler, so every event quietly fails or goes nowhere.
Fix: In the Stripe Dashboard → Developers → Webhooks, confirm an endpoint exists and its URL is your live deployed function — not a Bolt preview link and not localhost. It should look like your production domain or hosted function path, e.g. https://your-app.com/api/stripe-webhook. If you redeployed to a new URL, add the new endpoint (and remove the dead one).
2. The signing secret doesn't match
Every webhook endpoint in Stripe has its own signing secret that starts with whsec_. Your handler uses that secret to confirm the request genuinely came from Stripe. If the secret in your code doesn't match the one shown on that specific endpoint, every event fails signature verification and your function returns a 400 — so the order never gets created even though Stripe is delivering correctly.
Fix: Open the endpoint in Stripe → Developers → Webhooks, reveal its signing secret, and copy it into your app's STRIPE_WEBHOOK_SECRET environment variable. Re-deploy after changing it — secrets are read at deploy time. Remember each endpoint has a different secret, so if you have separate test and live endpoints you need both, set per environment.
whsec_ webhook secret and any sk_live_ key belong only in server-side environment variables. If one has leaked, roll it in the Stripe dashboard immediately.3. The handler verifies a parsed body instead of the raw request
This is the single most common code bug behind a failing webhook, and it's worth stating plainly: Stripe's signature is computed over the exact raw bytes of the request body. If your framework parses the JSON for you before you verify (most do, automatically), the body you hand to Stripe's verification call is a re-serialized object — not the original bytes — so the signature never matches and you get a 400 on every event.
Fix: Read the raw request body and pass it, untouched, to the verification function along with the stripe-signature header. Disable any automatic JSON body parser on that route. The pattern looks like this:
// Get the RAW body bytes — do not JSON.parse first
const rawBody = await readRawBody(req); // string or Buffer
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
rawBody, // raw bytes, untouched
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
// signature failed — return 400 so Stripe shows the error
return new Response("Webhook signature verification failed", { status: 400 });
}
// Only now is it safe to read the parsed event
if (event.type === "checkout.session.completed") {
// create the order / grant access here
}
return new Response("ok", { status: 200 });
400s, but the endpoint URL and secret both look correct. That combination — reaching your code, then failing verification — almost always means the body was parsed before the check.4. Test-mode and live-mode endpoints are mismatched
Stripe keeps test mode and live mode completely separate. They have their own API keys, their own products and prices, and — crucially — their own webhook endpoints and signing secrets. Events created in live mode are only ever delivered to live endpoints; test events only to test endpoints. A live site firing payments while your only registered endpoint is a test one (or vice-versa) does nothing at all, silently.
Fix: Check the mode toggle in the Stripe dashboard and make sure it matches the keys your deployed app is using. Register a webhook endpoint in each mode you operate in, and give your app the matching whsec_ secret for that mode. For production, that means a live-mode endpoint pointed at your live URL, using your live signing secret.
5. The function is asleep or times out before returning 200
Serverless functions cold-start. If your webhook handler isn't deployed, or it's asleep and the first request has to spin it up, or it does too much slow work before responding, it may not return its 200 in time. Stripe treats a slow or failed response as a failed delivery and retries — which can mean duplicate orders, or none at all if every retry also times out.
Fix: First confirm the function is actually deployed and reachable at the registered URL. Then make the handler return 200 as fast as possible: acknowledge the event immediately and do any heavy work (emails, provisioning, downstream calls) afterward or asynchronously. Make order creation idempotent — keyed on the Stripe event or session ID — so a retry can't double-fulfil.
6. The handler returns a non-200 (it throws, or a downstream call fails)
Stripe only considers a delivery successful if your endpoint returns a 2xx. If your handler throws an unhandled error, or a downstream operation fails — a database write rejected by a Supabase Row Level Security policy, a missing table, a null field — your function returns a 500, Stripe marks the delivery failed, and the order is never created.
Fix: Open the failed delivery in Stripe to see the exact response, then check your function logs for the underlying error. If it's an RLS block, have the webhook use the Supabase service role key (which bypasses RLS) server-side, or add a policy that permits the insert. Wrap fulfilment in error handling so a transient downstream hiccup returns a clear failure you can see and retry — not a silent crash.
7. The event type isn't selected on the endpoint
When you create a Stripe webhook endpoint, you choose which event types it receives. If checkout.session.completed (or whichever event your code listens for) isn't in that selection, Stripe never sends it — so your handler is fine, your URL is fine, your secret is fine, and still nothing happens because the one event you care about is filtered out.
Fix: Open the endpoint in Stripe → Developers → Webhooks and check its listening for list. Add checkout.session.completed (and any other events your code handles, such as invoice.paid or customer.subscription.updated for subscriptions). Save, then send a test event to confirm it arrives.
Charges going through with no orders to show for them? Let's fix it fast.
Send me what's happening and I'll get your Stripe webhook firing again — flat fee, quoted up front, reply the same day. Most webhook rescues are done within a day or two.
Get my webhook fixed → Flat fee · reply today · no retainer requiredHow to stop it happening again
A broken webhook is uniquely costly: payments keep succeeding, so nothing looks wrong from the outside, while every charge that lands without fulfilment is a refund or an angry email waiting to happen. Two habits catch it early:
- Monitor the Stripe webhook directly — a healthy stream of
200responses is your proof that orders are still being fulfilled. The moment deliveries start failing, you want to know in minutes, not when a customer complains. - Run a real synthetic checkout test on a schedule so a test payment exercises the whole path — create session, complete checkout, fire webhook, create order — and tells you it broke before any real customer hits it.
That's exactly what Backstop does — it runs your signup, login and checkout as real browser tests around the clock and watches your Stripe webhook, then alerts you in plain English the moment one breaks.
Catch the next break before your customers do.
Join the waitlist for Backstop — monitoring + offsite backup built for Bolt apps. We'll email you at launch, nothing else.
Frequently asked
Payment works but no order is created — why?
The payment completes at Stripe, but your app creates the order from a webhook that never arrived or was rejected. Stripe doesn't tell your app directly — it sends a checkout.session.completed event to an endpoint you registered, and your code creates the order when it receives that event. If the endpoint is missing, points at the wrong URL, or the event fails verification, the money is taken but nothing happens on your side. Start with the endpoint URL and the signing secret.
How do I check if my Stripe webhook is actually firing?
Open the Stripe Dashboard, go to Developers then Webhooks, and click your endpoint to see recent deliveries. A green 200 means Stripe delivered it and your app accepted it. A red entry shows the response code: a 400 usually means a signature or signing-secret mismatch, a 500 means your handler threw an error, and a timeout means the function was too slow or asleep. You can also press Send test event to fire a checkout.session.completed and watch your function logs in real time.
Why does my webhook return a 400 / signature verification failed?
Two causes account for almost all 400s. Either the signing secret in your function doesn't match the endpoint's whsec_ secret in Stripe, or your handler is verifying the signature against a parsed or re-serialized body instead of the raw request bytes. Stripe signs the exact raw payload, so any framework that auto-parses JSON before you verify will break the check. Read the raw body, pass it untouched to the verification call along with the stripe-signature header, and make sure the secret matches the specific endpoint and mode you're using.