48-hour launch: 40% off with code LAUNCH40, Guide $5.40 / Kit $11.40

Stripe checkout.session.completed not firing or not received: checklist

First work out whether Stripe didn't send the event or your endpoint didn't accept it. Open your webhook endpoint in Workbench (Webhooks) and check the Event deliveries tab. Each event is listed as Delivered, Pending or Failed, with the HTTP status of every attempt.

No delivery attempt listed: Stripe isn't sending it to this endpoint

  1. Event type not selected. The endpoint only receives the event types you chose when you created it. Add checkout.session.completed.
  2. Wrong mode or account. Sandbox (test) and live have separate endpoints. A test-mode checkout never reaches a live-only endpoint, and the reverse is also true.
  3. Connect scope. If the Checkout Session is created on a connected account (direct charges), the event happens on that account. Only an endpoint with Events from: Connected accounts (API: connect=true) receives it. A "Your account" endpoint won't.
  4. You're testing with the CLI. stripe listen --forward-to forwards events to your local URL using the CLI's own whsec_. That doesn't send anything to your Dashboard endpoint. Use --forward-connect-to for Connect events.
  5. The session never completed. The event fires only when the customer finishes Checkout. Abandoned sessions produce checkout.session.expired instead.

Attempts listed but Failed: your endpoint rejected it

It fired, but the order wasn't paid

With delayed payment methods (bank debits and similar), the session can complete while payment_status is still unpaid. Stripe's fulfilment guide says to check payment_status before fulfilling, and to handle checkout.session.async_payment_succeeded, which fires when a delayed payment later succeeds. It also suggests handling checkout.session.async_payment_failed, for example to email the customer.

switch (event.type) {
  case 'checkout.session.completed':
  case 'checkout.session.async_payment_succeeded':
    if (event.data.object.payment_status !== 'unpaid') await fulfil(event.data.object.id);  // idempotent
    break;
  case 'checkout.session.async_payment_failed':
    await notifyFailed(event.data.object.id);
    break;
}

Need checkout events you can't lose? The guide covers every common Stripe webhook failure with copy-paste fixes. The kit is a self-hosted Cloudflare Worker relay that verifies Stripe signatures, then retries, replays or dead-letters each event instead of dropping it.

Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.

Missed some? List them with GET /v1/events?type=checkout.session.completed&delivery_success=false and process each one idempotently. Stripe's fulfilment guide also recommends triggering fulfilment from your landing page, because webhooks can be delayed. Webhooks are still required, because not every customer reaches that page. Use the same idempotent function for both.

Free download: Webhook debugging cheat sheet.

Signature errors? Paste your payload, header and secret into the free Stripe signature checker. All five common causes with copy-paste fixes: Fix Your Stripe Webhooks guide ($9). Want retries, replay and a dead-letter list in front of your endpoint? Self-Hosted Webhook Relay Kit ($19).