Stripe Says Webhook Delivered. Your App Has No Record. What to Check.

SendPromptly
5 min read

You open the Stripe dashboard. The webhook event shows a green checkmark — delivered. But your database has no record of it. The customer’s plan is still unchanged. Nothing happened.

This is one of the most disorienting failures in a Stripe integration because the payment provider considers the event resolved. From Stripe’s perspective, the work is done. From your app’s perspective, it never started.

Here is a systematic checklist for finding out what went wrong.

Step 1: Confirm Which Endpoint Received the Event

Stripe sends webhooks to the endpoint URL you configured in the dashboard. Before assuming your app had a code failure, confirm that the event was sent to the right place.

In the Stripe dashboard, open the webhook event and check:

  • The endpoint URL it was sent to
  • Whether that URL matches your current production endpoint
  • Whether the endpoint is active (not disabled or paused)

Common mistake: A staging or test endpoint was registered and is receiving live events. The endpoint accepts the request and returns 200, but it has no access to your production database.

Also check: If you recently moved your app to a new domain or subdomain, your Stripe webhook endpoint may still point to the old one. Stripe will show delivery as successful if the old server returns any 2xx response — even if that server does nothing meaningful with the event.

Step 2: Check for Signature Verification Failures

Stripe signs every webhook with a Stripe-Signature header. If your app verifies this signature and fails, it will typically return 400 and discard the event — but Stripe considers a 400 response a delivery failure and will retry. If it returns 200 despite a signature failure, the event is silently ignored.

Check your application logs for:

  • Stripe::SignatureVerificationError (Ruby)
  • stripe.error.SignatureVerificationError (Python)
  • Any 400 or 500 response logged at the time of the event

Common cause: Your STRIPE_WEBHOOK_SECRET environment variable is set to the wrong value. This happens after rotating secrets, after copying variables from a different environment, or after switching between test and live mode secrets.

Verify your secret by checking:

1
Stripe Dashboard → Developers → Webhooks → [your endpoint] → Signing secret

The signing secret is different for each endpoint. The live mode secret and test mode secret are different. The secret shown in the dashboard must exactly match the value your app uses to verify the signature.

For raw body and framework-specific failure modes, use the Stripe signature verification error guide.

Step 3: Check Your App Server’s Request Handling

If the event reached the correct endpoint and passed signature verification, the next question is whether your handler actually executed.

Look for:

  • Missing route registration. If you recently moved your webhook handler to a different path, the old path may no longer have a registered handler. Your framework might return 200 for unmatched routes without logging an error.
  • Middleware interference. CSRF protection, authentication middleware, or body parsing middleware can intercept webhook requests before they reach your handler. Raw request body parsing is required for Stripe signature verification — a middleware that re-parses the body may break verification silently.
  • Framework-level request size limits. If your Stripe event payload exceeds the configured maximum request size, your server may reject it before your handler runs.

Step 4: Check Whether the Event Was Queued

Best practice for Stripe webhooks is to return 200 immediately and process the event asynchronously — enqueue a job, push to a queue, dispatch a background task. If your app does this, the 200 returned to Stripe tells you nothing about whether the job actually ran.

Check:

  • Was the job enqueued? Look in your queue dashboard (Sidekiq, Horizon, BullMQ, etc.) for the job around the time of the event.
  • Did the job execute? A job can be enqueued successfully but fail to run if workers are down, if the queue is paused, or if the job raised an exception and exhausted its retry limit.
  • Did the job fail silently? Some queue configurations swallow exceptions without alerting. Check your dead letter queue (failed jobs) for the relevant job.

A job in the failed queue that isn’t alerting you is one of the most common causes of “webhook delivered but nothing happened.”

Step 5: Check for Idempotency Conflicts

If your handler has idempotency logic — which it should — confirm that logic is not preventing processing of a legitimate event.

A common pattern is: “if an event with this ID has already been processed, skip it.” This is correct behavior for duplicate deliveries. But if your idempotency record was written during a previous failed attempt, it may be blocking a legitimate retry.

Check whether the event ID exists in your idempotency table. If it does, check what state it was recorded with. If the previous attempt failed partway through and wrote the record before completing the business effect, it may be blocking all future processing of that event.

The Stripe webhook idempotency guide covers how to avoid writing a processed marker before the business effect completes.

Step 6: Look at What Actually Happened at the Right Timestamp

Your application logs likely have a timestamp for when the webhook arrived. Pull your logs for ±30 seconds around that timestamp and look for:

  • Any exceptions or errors
  • Any database connection failures
  • Any timeout messages
  • The presence or absence of your handler’s normal log lines

If none of your handler’s expected log lines appear, the request never reached the handler. If they appear but the effect wasn’t applied, the handler ran but failed partway through.


The gap between “Stripe delivered it” and “your app processed it” is where most post-payment failures live. SendPromptly instruments that gap directly — it monitors whether the business effect (access, credits, subscription state) was actually applied after a payment event, and opens an incident automatically when it was not. See how it works →