checkout.session.completed Fired But Your App Didn't Update: A Debugging Checklist

SendPromptly
5 min read

The customer completed checkout. Stripe sent checkout.session.completed. Your webhook endpoint returned 200. But the customer’s account is still on the free tier — no access, no plan change, nothing.

This failure pattern is the most common post-payment support issue for subscription SaaS products. It has a short list of causes. Here is how to isolate which one you hit.

The Delivery vs. Fulfillment Problem

Stripe’s webhook logs confirm delivery. They do not confirm that your app acted on the event. A 200 response tells Stripe the event was received — it says nothing about whether your subscription logic ran, whether your database was updated, or whether the customer now has access.

The entire fulfillment path — from receiving the event to updating the customer’s account — happens inside your app, outside Stripe’s view.

If the customer has already reported the issue, use the broader paid-but-no-access diagnosis guide alongside this checklist.

Cause 1: The Wrong Event Mode

If you are using Stripe Checkout in both test mode and live mode, make sure you have registered separate webhook endpoints for each, and that each endpoint is listening for the correct events.

Symptom: Everything works in test, but not in production. Check: Your production endpoint is registered under the Live mode webhook configuration, not Test mode.

The Stripe dashboard will not warn you if a test-mode event is being delivered to a live-mode endpoint or vice versa. They operate independently.

Cause 2: Metadata Not Passed Through

checkout.session.completed contains the information you put into the checkout session when you created it — including metadata, client_reference_id, and the customer ID.

If your fulfillment logic uses metadata.user_id or metadata.plan to know which user to update and what to activate, and that metadata was not set when creating the session, your handler may run but apply nothing — or apply the update to the wrong account.

Check:

1
Event payload  checkout.session.completed  metadata

Confirm the keys your handler expects are present. If metadata is empty or missing keys, the session was created without setting them.

Cause 3: Subscription Created but Plan Not Applied

With Stripe Billing, checkout.session.completed fires when the session completes, but the subscription itself may not be fully active at that exact moment. Some teams listen for checkout.session.completed to provision access, but their logic checks subscription.status === 'active' — and the subscription is still incomplete at the time the handler runs.

Check: Look at the subscription field in the checkout.session.completed payload. Retrieve the subscription from the Stripe API to see its current status. If it is incomplete or trialing, and your handler requires active, provisioning will be skipped.

Fix options:

  • Provision based on checkout.session.completed without checking subscription status, using the session itself as the authorization signal
  • Also listen for customer.subscription.updated and provision when status transitions to active
  • Use checkout.session.completed to provision a trial state and customer.subscription.updated to activate the paid plan

Cause 4: Database Lookup Failed Silently

Your handler looked up the user associated with the payment but did not find them, and failed silently without raising an error.

Common scenario: You look up the user by customer_email from the session. The user registered with a different case or with a different email than the one they used for payment. The lookup returns null, your handler logs nothing meaningful, and the event is considered processed.

Check your handler for:

  • Null checks after database lookups — is a null result raising an error or being swallowed?
  • Case-sensitive email comparison when it should be case-insensitive
  • Stripe Customer ID vs. your internal user ID — are you creating the Stripe customer object correctly and linking it to your user record?

Cause 5: The Job Ran but Was Rolled Back

If your fulfillment logic runs inside a database transaction and that transaction is rolled back due to an error, the database ends up unchanged — but your queue system may consider the job complete because it did not raise.

Check: Is your webhook job wrapped in a database transaction? Did that transaction commit or roll back? Check your database logs around the time of the event for rollback entries.

Cause 6: Queue Worker Was Down

If you enqueue a job to handle checkout.session.completed and your queue workers were down or restarting at the time, the job may have been accepted into the queue but never executed. Depending on your queue configuration, it may still be pending hours later.

Check:

  1. Your queue dashboard for a pending or failed job around the event timestamp
  2. Your worker restart logs — was there a deploy, a crash, or a scale-down event around that time?
  3. Your dead letter queue for a failed job that exhausted retries without alerting you

A Structured Way to Prevent This in the Future

The root problem is that Stripe’s delivery confirmation and your app’s fulfillment confirmation are separate, and there is no default mechanism to alert you when fulfillment does not happen after delivery.

A structured approach:

  1. Log every state transition — when the event is received, when the job is enqueued, when the job starts, when the business effect is applied. Without this, you are debugging blind.
  2. Monitor the gap between receipt and fulfillment — if a checkout.session.completed event is received and access is not provisioned within N minutes, something is wrong.
  3. Make your handler idempotent and safe to replay — so that when you do identify a missed event, you can replay it without risk of double-provisioning.

SendPromptly instruments the gap between checkout.session.completed and your app’s fulfillment directly — it opens an incident automatically when payment succeeds but the business effect does not complete. See how recovery works →