checkout.session.completed Fired But Your App Didn't Update: A Debugging Checklist
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:
| |
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.completedwithout checking subscription status, using the session itself as the authorization signal - Also listen for
customer.subscription.updatedand provision whenstatustransitions toactive - Use
checkout.session.completedto provision a trial state andcustomer.subscription.updatedto 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:
- Your queue dashboard for a pending or failed job around the event timestamp
- Your worker restart logs — was there a deploy, a crash, or a scale-down event around that time?
- 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:
- 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.
- Monitor the gap between receipt and fulfillment — if a
checkout.session.completedevent is received and access is not provisioned within N minutes, something is wrong. - 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.
Related guides
- Stripe webhook delivered but nothing happened — debug delivery-to-handler gaps
- How to replay a Stripe webhook safely — recover missed events without double-provisioning
- Stripe webhook idempotency guide — design the handler so retries and replay are safe
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 →