Stripe Webhook Not Triggering in an AI-Built App

Checkout succeeded, but the app never granted access. See why a Stripe webhook is not triggering, and the checks for signature, payload, 405, and retries.

By Muhammad Saif and Aashan Khan · Techtribe co-founders

Checkout looked finished. Stripe shows a succeeded payment. The customer is on the success page, or they closed the tab on the way there. Your app still shows unpaid. That is what people mean when they search for a Stripe webhook not triggering in an AI-built app: the money moved, and the product never learned.

This note is for founders whose generated checkout already takes live cards. It is not a Stripe tutorial and it is not a local CLI walkthrough. We cover what a webhook is supposed to do, why agents ship a handler that never fires in production, the failures that show up as "not triggering," "not working," "no webhook payload was provided," and "405 method not allowed," and the order to check them in before you prompt the agent again.

The wider repair for a generated product is in fix a vibe-coded app. Silent payment bugs sit next to that in when a Replit or v0 app breaks in production. Access-control holes are a different class, written up in vibe coding security vulnerabilities.

What a Stripe webhook is supposed to do

Stripe does not wait inside your app for a bank to confirm a card. Checkout, Payment Intents, invoices, disputes, and subscription renewals finish on Stripe's side. Your app finds out because Stripe sends an HTTPS POST to a URL you registered. Stripe's webhook guide is explicit about the contract: the URL must be a publicly reachable HTTPS address, the handler must accept POST with a JSON body, and you must verify that the request came from Stripe using the raw body, the Stripe-Signature header, and the endpoint's whsec_ signing secret.

Verification is not optional polish. Stripe computes the signature over the exact bytes it sent. If your framework parses JSON first, reorders keys, or converts the body to a string and back, the check fails. Stripe documents this as a hard rule: do not manipulate the raw body, or signature verification fails. Manage webhook endpoints is the page to keep open while you debug a live destination.

After a valid event, the handler should return a 2xx quickly. Stripe's own setup notes say to return success before slow work — updating accounting, sending mail, calling a model — because a long handler looks like a failed delivery. Failed deliveries are retried. Stripe automatically retries undelivered events for up to three days. Events you can list for recovery are limited to the last thirty days. That retry window is why a "not triggering" report and a "customer was charged twice" report are often the same bug on different days.

The Dashboard is the other half of the contract. You create an event destination, pick event types, paste the URL, and copy the signing secret that starts with whsec_. Stripe will send only the event types you subscribed to. A handler that waits for checkout.session.completed will sit silent if the destination was created for payment_intent.succeeded alone, or the other way around. The charge still exists. Your row does not.

Why generated apps miss the delivery

An agent is optimized to make Checkout open and return a session URL. That demo does not require a public HTTPS endpoint, a registered destination, a signing secret in server-only env, or a second account paying with a real retry. The happy path is: click Pay, land on /success, write paid in the database from the page load. It works in test mode with one founder and one event.

Reviews of AI-generated Stripe code keep finding the same three holes. VibeDoctor's write-up lists them in order: the handler trusts any POST and skips constructEvent, the checkout session takes a price from the browser, and sk_live_ lands in a NEXT_PUBLIC_ variable or a client bundle. Any one of those can present as "the webhook is not triggering." An unverified handler that you disabled after it failed signatures looks idle in the Dashboard. A secret in the browser is a different incident, but it often arrives in the same generated file as the broken route.

A second pattern is documented in idempotent Stripe webhooks for AI-built products: generated checkouts either fulfill from the success redirect, or insert a row on every checkout.session.completed without recording the event id. Testing sends one event, once, in order. Live Stripe delivers at least once, can deliver out of order, and will retry for days if you time out. The first week of real cards is when "not triggering" and "triggering twice" show up as a pair.

This is not theoretical. A published rescue of a Cursor-built marketplace processed more than $200,000 through a webhook that never checked signatures, sometimes timed out, and then accepted Stripe's retries as new orders. About 15% of successful payments never landed in the database. Some customers were charged twice. The repair was about $15,000. The account is here. Test-mode charges succeed, so the gap stays quiet until live cards, retries, refunds, and disputes.

What "not triggering" looks like from the customer's side

The customer paid. Your Stripe Dashboard shows succeeded. One of these is true in the app:

  • The success page loaded and still says unpaid, pending, or shows a spinner that never resolves.
  • The customer closed the tab before /success and never received access, even though Stripe captured the funds.
  • Access appeared, then a duplicate charge or a second welcome email arrived hours later.
  • A subscription is active in Stripe and expired in your table, or the reverse.
  • Support cannot answer "did we grant this order?" from your database. They open two dashboards and guess.

The Dashboard's event destination will show deliveries with response codes. A wall of timeouts, 4xx, or 5xx is not a mystery. A wall of 200s with no row in your table means the handler returned success without writing, or it wrote to a different project than the one you are looking at. Both happen in generated apps: the agent points Checkout at live Stripe and the webhook at a preview URL that no longer exists.

Six reasons the handler never runs

Work these in order. The first items are configuration. The later items are the code the agent wrote.

1. The URL Stripe has is not the app you think it is. Stripe will only POST to a publicly accessible HTTPS URL. HTTP, a locked preview, a Vercel deployment that rotated, or localhost copied from a CLI session will not receive live events. Agents register whatever hostname was in the prompt that day. Open the event destination and read the URL. Hit it yourself with a POST. If you cannot reach it from the public internet, Stripe cannot either. Local stripe listen forwarding is a development tool; it is not your production destination. Stripe's own guide separates those two paths on purpose.

2. You subscribed to the wrong events. Checkout, Payment Element, invoices, and Billing each emit different types. A handler that only switches on checkout.session.completed will not see a Payment Intent that never created a Checkout Session. A destination that only enables payment_intent.succeeded will not notify you when a subscription is canceled. The charge in the Payments tab does not imply that your handler's if branch ran. Compare the event type on the successful payment with the enabled_events list on the destination, then with the switch in code.

3. The route does not accept POST: 405 method not allowed. Google autocomplete for this failure is exact because frameworks generate it constantly. App-router file conventions, a GET export with no POST, a middleware that redirects unknown methods, or a page route where an API route should be, all return 405. Stripe's contract is POST. A 405 in the destination log means Stripe found a server and the server refused the method. The agent often mounts the handler on a page because "webhook" was in the prompt and pages are what it knows how to ship.

4. The body is gone before verification: no webhook payload was provided. Next.js, Express json() globally, and serverless wrappers that parse the body for you consume the stream. Stripe then cannot verify the signature, or your runtime throws because the payload is empty. Stripe's handler notes say verification needs the JSON payload, the signature header, and whsec_, and that any manipulation of the raw body fails the check. VibeDoctor calls out the same trap: express.json() before the webhook route makes constructEvent fail every time, which tempts the agent to delete verification so the demo works. In production that looks like deliveries that never become orders.

5. The signing secret is the wrong secret. Dashboard keys start with pk_ (publishable, fine in the browser), sk_ (secret, server only), and whsec_ (this endpoint's signing secret). Agents mix them. Putting sk_live_ into constructEvent fails verification. Putting whsec_ in the Stripe client constructor fails API calls. Putting either in NEXT_PUBLIC_ publishes it. VibeDoctor's table is the one to keep: whsec_ is server-only, and if it leaks, an attacker can forge events that your handler will treat as paid. A failed signature on every attempt is recorded as a failed delivery. From the founder's chair that is "not triggering."

6. The handler is too slow, then Stripe retries. Stripe asks you to return 2xx before slow work. Generated handlers send email, call OpenAI, generate a PDF, and write four tables in the same request. The platform times out. Stripe retries for up to three days. If the first attempt actually wrote the row before the timeout, the retry writes it again. If the first attempt died before the commit, the customer paid and has no access until a later retry happens to succeed — or until retries exhaust and you have a succeeded Payment Intent with no app state. ImadDhin's note is the practical split: accept and verify, record the event id under a unique constraint, return 200, then do mail and generation in a job keyed by the same id.

A seventh failure sits next to these even when the webhook runs: the success URL is treated as payment. Stripe Checkout's success_url is a place to say thank you. It is not a signed event. Fulfillment from that page misses everyone who closes the tab, and it can be opened without a charge if the route does not load the session from Stripe on the server. Both Stripe's security guidance and the AI-app write-ups above say the same sentence: do not unlock access from the redirect.

Signature, payload, and 405 in the logs

When you open Workbench or the Webhooks tab, read the last delivery before you change code.

  • 405. The URL is reachable and the method is wrong. Fix the route shape. Do not add a GET handler "so you can test in the browser." Stripe will not GET. A browser GET returning 200 teaches you nothing about delivery.
  • 400 with a signature error. The URL is right and the body is being read as something other than the bytes Stripe signed, or whsec_ does not match this destination. Test and live destinations have different secrets. CLI forwarding prints a different secret again. Using the CLI secret against a Dashboard destination, or the test secret against live events, fails every time. Rotate nothing until you have matched secret to destination.
  • Empty body / "no webhook payload." The platform parsed the request already. Configure the route to receive the raw body. In a serverless function that means reading the request as text and passing that buffer to constructEvent. If verification is currently skipped so the route "works," put it back; a 200 on an unverified POST is how the $200,000 marketplace leaked.
  • Timeout or 5xx. The handler does too much, or the database the agent wired is unreachable from production. Return 200 only after the event id is stored. Return 5xx if storage failed, so Stripe retries. Returning 200 on an internal failure is how events disappear while the Dashboard looks healthy.

Stripe can list events with delivery_success=false for the last thirty days if the destination was down. Use that after you fix the route, not as a substitute for fixing it. Their recovery example also tells you to ignore events you have already processed and still return 200, because automatic retries continue even after you replay by hand.

What to check before the next live charge

Fix in the order that can move money.

  1. Read the destination, not the chat. HTTPS URL, live mode, event types, signing secret for this destination. Confirm the deployment that URL hits is the git commit you think it is.
  2. POST only, raw body, constructEvent, whsec_. A 405 or a signature failure stops here. Do not skip verification to "just get events through."
  3. Fulfillment is not the success page. The success page may show a receipt. Access, credits, and emails wait for a verified event.
  4. Record the event id uniquely, then write absolute state. paid is a status you set, not a counter you increment. A second delivery of evt_... must no-op. A second event for the same Checkout Session must find the existing order. ImadDhin's split — event id plus business key — is the one that survives retries and out-of-order subscription updates.
  5. Slow work leaves the request. Mail, PDF, model calls, Slack. If it can exceed your platform timeout, it does not belong in the webhook handler.
  6. Prices are not taken from the client. This is adjacent to "not triggering" and shows up in the same generated checkout. A unit_amount from req.body is how a $99 plan sells for $1. Server-side Price ids, as in Stripe's integration patterns and the VibeDoctor checklist, belong in the same repair.
  7. sk_live_ and whsec_ are not in the browser bundle. If they ever were, rotate both, then ship the handler. A leaked signing secret means anyone can POST a checkout.session.completed that your code will honor.
  8. Prove it with retries. Stripe CLI or Dashboard resend, twice. One order. Close the tab before /success. Access still appears. Cancel in the Dashboard. Access disappears. Force a signature mismatch. Rejected. Those tests are cheaper than the next live dispute.

If you cannot name which of the eight is true for your app, stop adding features on the payment path. Hire a developer to fix a Lovable app is the decision rule for when prompting has stopped helping. Payments are usually that moment, because the next prompt will "fix" the webhook by writing paid on the success page again.

When the handler is the symptom

Sometimes the webhook code is fine and the product around it is not. The destination points at a preview. The database the handler writes is a different Supabase project than the one the UI reads. RLS blocks the service role the webhook is not using, so the insert returns success in logs you are not looking at. Auth middleware sits in front of /api/stripe and demands a user session Stripe does not have. Localization middleware redirects the POST to a locale prefix and Stripe follows it into a 405.

Those are production defaults, not Stripe bugs. They match the split in our outage note: the generated screen holds, the wiring around it does not. Check environment variables in the host, not in .env.local on your laptop. Check that the webhook's database URL and the app's database URL are the same string. Check that the route is excluded from CSRF and from session middleware. Stripe's docs mention CSRF frameworks blocking legitimate events for this reason.

If refunds, disputes, invoice.paid, and customer.subscription.deleted have no branches, you do not have a billing system. You have a one-event demo. Live products cancel, fail, and charge back. Those events must set absolute state the same way checkout.session.completed does, or support will spend the next quarter in two dashboards.

If this is your app, send the repo.

A 30-minute scope call. We read what is live, name what is dangerous, and quote a fixed scope.

How Techtribe takes a Stripe webhook repair from here

On the scope call we look at the live destination, the last failed deliveries, and the handler in your repo. We tell you whether the URL, the method, the signature, or the write is the failure, and whether a fixed repair is enough or the checkout needs to move off the success page entirely. You leave with a scope and a price. Nothing is billed for the call.

When the core purchase flow is sound, the work is verification, idempotent writes, and the event types you actually sell. Working screens stay. When there is no server-side price, no signing secret, and no place that is safe to listen, the honest scope is a Production MVP or a Dedicated Team on your repo. Prices and what each includes are on the packages page.

Bring the Stripe Dashboard, the repo, and one payment that succeeded in Stripe and did not unlock the product. That single event is enough to see whether the webhook is missing, refusing POST, failing signatures, or writing twice.

Questions

Why is my Stripe webhook not triggering after checkout?

Stripe did charge the card. Your app never received a valid POST, or it rejected the POST before it wrote the order. Typical causes are an HTTP URL, the wrong event type, a 405 on GET-only routes, a parsed body that fails the signature, or a handler that times out so Stripe marks the delivery failed.

Does the Checkout success URL mean the customer paid?

No. The success redirect is a browser page. It can be skipped, closed early, or opened without a charge. Stripe's documented source of truth is a verified webhook event, not the page the user lands on after Checkout.

What is the difference between the secret key and the signing secret?

The secret key starts with sk_live_ or sk_test_ and belongs only on the server. The webhook signing secret starts with whsec_ and is unique to that endpoint. Agents often paste the secret key into constructEvent. Verification then fails on every delivery, which looks like the webhook is not triggering.

Why does Stripe retry the same webhook?

Stripe delivers each event at least once. If your endpoint times out, returns an error, or never answers, Stripe retries automatically for up to three days in live mode. A handler that inserts a row on every attempt will duplicate access, email, and credits unless you record the event id first.

Book a free call

Have a vibe-coded app in production?

30-minute free call. We'll read what you have, name what is dangerous, and quote a fixed scope.

  • You talk to a founder, not a salesperson.
  • You leave with a recommended package and a rough plan.
  • Prefer email? howdy@techtribedev.com