← All field notes
App Security

Paid Order Never Reached Your CRM? A Webhook Checklist for AI-Built Apps

Stripe took the money but the order never landed in your CRM. Nine places the chain breaks, in the order to check them, plus what a free outside check can and cannot tell you.

Matthew TurleyNovember 5, 20268 min read

A customer paid. Stripe shows the charge. Your CRM has no record of it, so nobody follows up and nothing gets fulfilled. When a paid order never reaches your CRM, the break is almost always in one of two places: the webhook from Stripe to your server never arrived or was rejected, or your server accepted it, told Stripe "OK", and then lost the error when it tried to write to the CRM.

Do not theorize. Take one real missing order and trace it through the chain below, in this order. Each step takes a few minutes and tells you which half of the chain is broken.

Step 0: pick one missing order and get its payment ID

Open the charge in Stripe and copy the payment ID (it starts with pi_ or ch_). Every check below is about that one payment. A single concrete failure beats a general audit, because it tells you which hop dropped the order.

1. Did Stripe send an event to your endpoint at all?

In the Stripe dashboard, open Developers, then Webhooks, and select your endpoint. Look for the event tied to your payment and read its delivery attempts.

  • No endpoint listed. You registered the endpoint in test mode only. Test and live endpoints are separate objects with separate signing secrets. Create the live one.
  • Endpoint listed, no event for this payment. The endpoint is not subscribed to the event type your handler expects. A checkout flow usually needs checkout.session.completed. Subscribing to payment_intent.succeeded only will miss some flows and fire on others.

2. What status did your server return?

Each delivery attempt shows the HTTP status your server answered with. This one field splits the problem in half.

  • 4xx or 5xx or a timeout. The failure is in your receiving code or in something in front of it. Go to steps 3 and 4.
  • 200 or another 2xx. Stripe did its job and your server said it handled the event. The loss is inside your handler. Skip to step 5.

3. Is your signature check rejecting real events?

A handler that verifies the signature returns 400 when verification fails. In a live environment the usual causes are:

  • The environment still holds the signing secret from stripe listen (the local CLI) or from the test endpoint, not the live endpoint's secret.
  • The body was parsed as JSON before verification. Stripe signs the raw bytes, so any re-serialization breaks the check. In a Next.js route handler, read the body with await req.text() and pass that string to the verifier.
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const raw = await req.text() // raw body, not req.json()
  const sig = req.headers.get('stripe-signature')!
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response('bad signature', { status: 400 })
  }
  // ...store the event, then process it (see step 5)
  return new Response('ok', { status: 200 })
}

Do not "fix" a failing signature check by removing it. An unverified webhook endpoint lets anyone post a fake "payment succeeded" event. The Stripe key leak check covers that risk from the other direction.

4. Is something in front of the endpoint blocking or redirecting it?

Stripe needs a plain 2xx from the exact URL you registered. Common blockers:

  • A redirect from http to https, or from www to the apex domain. A redirect is not a 2xx, so the delivery counts as failed. Register the final URL.
  • Login middleware or a deployment-protection setting that treats the webhook as an anonymous visitor and answers with a login page.
  • A firewall or bot-protection rule that challenges server-to-server POSTs.

Send a test event from the dashboard to the live endpoint and read the status it returns.

5. Do you answer 200 before the work is done?

This is the most common silent loss. The handler returns 200 right away, then calls the CRM in a background promise. Two things go wrong:

  • On serverless hosting, the function can be stopped once the response is sent, so the CRM call never completes. Use the platform's supported way to finish work after the response, or do the work before responding.
  • The background call fails, the catch logs to a console nobody reads, and Stripe never learns, so it never retries.

The sturdier pattern is to write the event to your own table first, return 200, and process from that table with a status you can query.

create table stripe_events (
  id text primary key,          -- the Stripe event id: also your idempotency key
  type text not null,
  payload jsonb not null,
  status text not null default 'received', -- received | synced | failed
  error text,
  received_at timestamptz not null default now()
);

Now "paid but not in the CRM" becomes a query: select * from stripe_events where status <> 'synced'.

6. Is the CRM call itself failing?

Read the actual response from the CRM, not just whether the code threw. Typical causes:

  • An expired or rotated API key.
  • A required CRM field is empty, often the customer email, because Checkout was never set to collect it or the field was read from the wrong place on the event.
  • A rate limit or a validation error that returns a 4xx the code treats as success.

7. Does your "already processed" check lock out the retry?

Stripe can deliver the same event more than once, so handlers dedupe on the event ID. The trap is marking the event as processed before the CRM write succeeds. The first attempt fails, the flag is already set, and every retry is skipped as a duplicate. Set the flag only after the write succeeds, or use the status column above.

8. Did the retry window close on you?

Stripe retries a failed delivery in live mode for up to 3 days with exponential backoff1. After that the event is no longer retried automatically. In test mode it retries 3 times over a few hours1, which is why a flaky handler can pass every test run. If your endpoint was broken for longer than the window, the missed orders have to be recovered by hand: list the events or charges from the window and replay them through the handler.

9. Add a reconciliation check so you find out first

Webhooks are a delivery system, not a guarantee, and Stripe's own guide tells you to handle duplicates and not rely on event order2. Add a daily job that lists yesterday's paid charges from the Stripe API and compares them with your CRM. Anything in Stripe and not in the CRM goes to your inbox. It is a small script, and it turns "a customer emailed us" into "we caught it the next morning".

What the free check and the $47 scan can and cannot tell you

Be clear about the limits so you do not get false comfort.

The free Leak Check runs seven checks from the outside in under 60 seconds. Its payment-path check only looks for a Stripe or checkout reference on your live page. It cannot send a signed event to your endpoint and it cannot see your CRM, so a green result there does not mean orders are syncing. What it does cover that matters on a money path: it flags a Stripe or other secret key sitting in your client bundle.

The $47 Complete Scan goes further on the outside view: anonymous read and write attempts against the common tables, storage buckets, auth endpoints and exposed config. If your orders or payments table accepts an anonymous write, a stranger can create fake paid records. It is still an outside view, not a check of your webhook handler.

Next step

Run steps 0 to 2 on one missing order today. That alone tells you whether to look at Stripe, at your endpoint, or at the CRM write. Then, to see what a stranger can see on your live app, run the free Leak Check. If you want the handler and the policies behind it read from the inside, the written review is the Ship Check.

Related: your app took the payment but no order showed up and Stripe webhook works in test, fails in production.

Sources

Market figures above are marked with a number that links here. Our own prices are marked as ours and come from our published pricing.

  1. 1. Set up a webhook endpoint (Workbench), Stripe, checked November 5, 2026. Stripe documentation: in live mode Stripe attempts event delivery for up to 3 days with exponential backoff; in test mode it retries 3 times over a few hours.
  2. 2. Receive Stripe events in your webhook endpoint, Stripe, checked November 5, 2026. Stripe's webhook guide: verify the signature against the raw request body, return a 2xx quickly, handle duplicate deliveries, and do not depend on event order.

Worried your app has holes like these?

Drop your email and we will run a free teardown of your stack and send back the leaks we find, no pitch.

M
Matthew Turley, Continuum

Fractional CTO and embedded technical partner. 20+ years shipping production software.

Run a free Leak Check →
Paid Order Never Reached My CRM: Stripe Webhook Checklist