CheckoutAlarmWatch my app free

Customer paid but premium didn't unlock: failed webhook, or a 200 that did nothing?

Stripe shows the payment, but the customer is still on the free plan. There are only two ways this happens. Find out which one you have before you change any code.

Free tool: how much was paid while your webhook was broken

Case A, delivery failed: we catch this

With a read-only restricted key, we list events Stripe couldn't deliver and email you when any are still pending after about 15 minutes. We also email you when a webhook endpoint is disabled. We don't alert when an account has no endpoint at all.

Case B, a 200 that did nothing: we don't catch this

If your handler answers 200, Stripe records the event as delivered. We read the same record, so to us it looks fine. Nothing on this page should be read as CheckoutAlarm covering this case. The fixes in part 03 are yours to build.

Plans

Free: 1 site, checked once a day. Pro: $19 a month, up to 3 sites checked hourly. You prove you own the site first. Compare plans

How it looks

In Workbench, open Webhooks, pick the endpoint, then Event deliveries. The event shows Failed or Pending, with the status code. Stripe counts any non-2xx answer as a failure, redirects included. Stripe: view event deliveries

Common causes: your route answers 4xx, 5xx or a redirect; it times out; the endpoint is disabled; the signing secret doesn't match, so your code rejects the event; or a Supabase Edge Function answers 401 before your code runs. If there is no live endpoint at all, there is no delivery to see. Add one in live mode. See webhook not firing and Edge Function 401.

Find the failed events

List events with delivery_success=false and the event types you listen for. Only events from the last 30 days come back. Stripe: process undelivered events

Fix the endpoint, then resend

Fix the cause first. Then resend from the Dashboard (up to 15 days after the event) or with stripe events resend <event_id> --webhook-endpoint=<endpoint_id> (up to 30 days). In live mode Stripe also retries on its own for up to three days. Stripe: manual retries Stripe: automatic retries

Make the handler safe to run twice

Resends and Stripe's own retries can overlap, and Stripe can send the same event more than once. Record each event ID you process and skip ones you have seen. Stripe: handle duplicate events Stripe's fulfillment guide also says to make fulfillment safe to call many times, even at once, for the same Checkout Session ID. Stripe: fulfill orders

How it looks

The event shows Delivered with 200, but your database has no new plan. Stripe treats a 2xx as success and stops retrying, so nothing will fix it on its own. Stripe: view event deliveries

Common causes:

  • The handler answers 200 before the database write, and the write then fails.
  • The write fails and the error is caught and ignored.
  • It updates the wrong row: the wrong user ID or Stripe customer ID, so zero rows change.
  • It listens for a different event type than the one Stripe sends, so it skips the event and still answers 200.
  • It ignores the metadata or client_reference_id you set to link the payment to a user.
  • Row Level Security or the wrong database key blocks the write.

Answer 2xx only once the result is saved

Answer 2xx after the entitlement write commits, or after a durable record of the event is saved for a worker to process. Then a failed write becomes a non-2xx, and Stripe retries it. Stripe also asks you to answer quickly, before slow work that could time out, so keep that write small and move slow work to a queue. Stripe: quickly return a 2xx

Log the event ID and the result

Log each event ID with what happened: rows changed, or the error. A write that changed zero rows should be an error, not a 200.

Reconcile against Stripe

On a schedule, list recent Checkout Sessions and compare paid ones with your records. A session's payment_status is paid, unpaid or no_payment_required, and client_reference_id is meant for matching it to your own system. Stripe API: list Checkout Sessions Stripe API: Checkout Session object

Give customers a way back in

Stripe recommends also running fulfillment from your success page, using the Checkout Session ID in the URL, because webhooks can be delayed. Stripe: fulfill orders The same function can back a "Restore purchase" button: look up the customer's paid sessions and grant what is missing.

How do I tell which case I have?
Open the payment's event in Stripe and look at its delivery to your endpoint. Failed or Pending with an error code is case A. Delivered with 200 is case B: Stripe did its part, and the problem is inside your handler.
Why did Stripe mark it delivered if nothing was saved?
Stripe only sees your HTTP answer. Any 2xx counts as a successful delivery, so Stripe stops retrying, even if your code failed after it answered or never saved anything.
Can I find payments my app never unlocked?
Yes. List recent Checkout Sessions with the API and compare the paid ones with what your database recorded. The client_reference_id field is there to reconcile a session with your own records.
Does CheckoutAlarm catch both cases?
No. It catches case A: events Stripe couldn't deliver after about a 15-minute grace, and endpoints that are disabled. It doesn't alert on an account with no endpoint at all. It can't see case B, because Stripe reports those events as delivered.
Can CheckoutAlarm change my Stripe account or my database?
No. It reads Stripe with a restricted key that has read permissions only, and it never connects to your database.

CheckoutAlarm catches case A only: Stripe events still undelivered after about 15 minutes, and endpoints that are disabled, read with a read-only key. It does not see case B, a handler that answers 200 without saving anything. It does not run a browser or test your checkout.

CheckoutAlarm is new. It only checks sites you prove you own. How to verify

Watch my app free

Docs checked on Oct 20, 2026.