Stripe · Webhooks · Payments

Stripe shows the payment as "succeeded" — why does my order stay "Pending"?

Short version: Stripe marking a charge as succeeded and your database marking the order as fulfilled are two completely separate systems connected only by a webhook. When the order gets stuck, it's almost always because that webhook either never arrived, arrived and failed silently, or arrived and your handler didn't do what you think it did. Here's how to tell which, in about five minutes.

Check this first

Go to Stripe Dashboard → Developers → Webhooks → click your endpoint → Recent events / Recent deliveries. This one screen tells you which half of the problem you have:

If you see failed attempts, retries, or nothing at all for the relevant event — it's a delivery problem. Stripe's request never made it into your application successfully. See causes 1–3.

If you see a 200 response logged for the event — delivery worked fine. Your application received it and did the wrong thing (or nothing) with it. See causes 4–6.

Delivery problems (Stripe never got a clean 200 back)

Cause 1

The endpoint URL is wrong, unreachable, or sitting behind auth

Common with a recent redeploy, a new domain, or a staging URL that got left in the live webhook config. Also common when the route is accidentally behind a login/CSRF middleware that returns a redirect or 401 instead of running your handler — Stripe sees that as a failure and eventually stops retrying.

Check: compare the URL shown in Webhooks settings against your actual deployed route. Click "Send test webhook" from the dashboard and watch whether it 200s.

Cause 2

Signature verification is failing, so your handler rejects the request before touching your order

This is the single most common cause. Your code calls something like stripe.webhooks.constructEvent(payload, signature, endpointSecret) and throws if it doesn't match. The usual culprits: the signing secret in your environment doesn't match the one for that specific endpoint (each endpoint has its own secret — copying the CLI's secret into production is a classic mismatch), or the raw request body got parsed/re-serialized by middleware before it reached the verification step, which changes the bytes and breaks the signature even though the secret is correct.

Check: your own server logs for a 400 at the moment the event fired, or Stripe's dashboard showing a non-2xx response code on that delivery attempt.

Cause 3

You're subscribed to the wrong event type

If you're using Stripe Checkout, the event that actually means "this order is paid" is usually checkout.session.completed, not payment_intent.succeeded — the PaymentIntent can succeed slightly before the Checkout Session is fully reconciled, and if your endpoint only listens for one of the two, you can miss the one you actually need.

Check: the endpoint's configured event list in the dashboard against what your handler code actually switches on.

Application problems (Stripe delivered fine, your side dropped it)

Cause 4

The handler returns 200 quickly, then crashes doing the actual work

A common pattern: acknowledge the webhook immediately (correct — Stripe expects a fast response), then process the order update asynchronously. If that background step throws an unhandled exception, Stripe never finds out because you already returned 200. It looks "delivered" in Stripe's dashboard and broken everywhere else.

Check: your application error logs / exception tracker for the timestamp of the webhook, not Stripe's dashboard — Stripe has nothing to tell you here.

Cause 5

Test mode and live mode are pointed at different places

Stripe test mode and live mode are entirely separate: separate API keys, separate webhook endpoints and secrets, separate event history. It's easy to be looking at a "succeeded" payment in test mode while your production webhook (and production database) never heard about it at all — or to have a live payment succeed while you're debugging against the test dashboard and seeing nothing.

Check: the mode toggle in the top-left of the Stripe dashboard matches the keys your running server is actually using.

Cause 6

The webhook arrives before your order row exists (or the lookup key doesn't match)

If your flow creates the order record after redirecting the customer back from Stripe, there's a race: the webhook can arrive and try to update an order that doesn't exist yet, fail that lookup, and silently no-op. Related: if the handler looks up the order by an ID stored in metadata or client_reference_id, a mismatch there (wrong key name, ID set on the wrong object) fails the same way — quietly, with no error thrown.

Check: log the lookup key your handler receives versus what's actually stored on the order at the time the webhook fires.

Not it, or don't want to trace it yourself?

This is exactly the kind of thing we diagnose same-day — we put a monitor in front of the endpoint, capture the real traffic, and show you precisely which of the above (or something else entirely) is happening, with proof, not guesses.

Tell us what you're seeing — the symptom is enough to start.