A customer paid, upgraded or canceled, and Stripe shows it, but your app still says "trial", "free" or the old plan. A subscription keeps changing after checkout, so your database is only right if your webhook keeps up with it.
Stripe: "A subscription integration requires a destination that handles webhook events, because most subscription activity happens asynchronously" (Stripe docs: Using webhooks with subscriptions).
checkout.session.completed is handled. It fires once, at signup. Renewals, plan changes, cancellations and failed payments arrive later as other events.In the Stripe Dashboard, open your webhook endpoint and look at deliveries around the time of the change. No customer.subscription.updated or invoice.paid at all: go to step 2. Deliveries with an error status: fix the endpoint first (checklist). Deliveries answered 2xx but the row didn't change: go to steps 3 and 4.
From Stripe's subscription webhook guide (source):
invoice.paid: "You can provision access to your product when you receive this event and the subscription status is active."customer.subscription.updated: "Sent when a subscription starts or changes. For example, renewing a subscription, adding a coupon, applying a discount, adding an invoice item, and changing plans all trigger this event."customer.subscription.deleted: "Sent when a customer's subscription ends."invoice.payment_failed: a payment for an invoice failed; notify the customer.If customers manage plans in the Stripe customer portal, Stripe says to use customer.subscription.updated for upgrades and downgrades. If the portal cancels at the end of the billing period, the same event tells you about the cancellation before it happens (Stripe docs: Customer portal webhooks).
Create the Checkout Session with your user's ID as client_reference_id. Stripe describes it as a reference that "can be used to reconcile the Session with your internal systems". In the checkout.session.completed handler, save session.customer and session.subscription ("The ID of the Subscription for Checkout Sessions in subscription mode") on that user's row (Stripe API: Checkout Session object). Later subscription events identify the customer by these Stripe IDs, and Stripe's portal guide says to "look at the ID for the subscription or the customer to determine which customer the event applies to" (source).
Because order isn't guaranteed, Stripe suggests using the API to "retrieve any missing objects" (source). After invoice.paid, it says to "retrieve the associated subscription and confirm that its status is active" before extending access (source). One sync function covers every event:
// After verifying the signature on the raw body (see the raw-body guide)
const SUB_EVENTS = ['customer.subscription.created', 'customer.subscription.updated',
'customer.subscription.deleted', 'invoice.paid', 'invoice.payment_failed'];
if (SUB_EVENTS.includes(event.type)) {
const obj = event.data.object;
// Newer API versions: invoice.parent.subscription_details.subscription; older: invoice.subscription
const subId = obj.object === 'subscription'
? obj.id
: (obj.parent?.subscription_details?.subscription ?? obj.subscription);
if (subId) await syncSubscription(subId);
}
async function syncSubscription(subId) {
const sub = await stripe.subscriptions.retrieve(subId); // current state, not the event's snapshot
await db.users.update({
where: { stripe_customer_id: sub.customer },
data: { stripe_subscription_id: sub.id, subscription_status: sub.status,
price_id: sub.items.data[0]?.price.id ?? null },
});
}
Then decide access from the stored status. Stripe says you "can safely provision" during trialing, and should revoke access "when a subscription changes to canceled or unpaid". For past_due, Stripe says to notify the customer and ask them to update their payment details (source).
delivery_success=false for your event types and process each one. Stripe only returns events from the last 30 days (Stripe docs: Process undelivered events).status=all (by default the list "returns a list of subscriptions that have not been canceled") and run the same sync for each one (Stripe API: List subscriptions). Running it on a schedule catches anything a missed event leaves behind.for await (const sub of stripe.subscriptions.list({ status: 'all', limit: 100 })) {
await syncSubscription(sub.id);
}
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkUsually the webhook only handles checkout.session.completed, or the endpoint is not subscribed to customer.subscription.updated, which Stripe sends when a plan changes. Also check that you stored the Stripe customer ID, so the event can be matched to a user.
Stripe's subscription guide says you can provision access on invoice.paid when the subscription status is active. Retrieve the subscription to confirm its status before extending access.
List undelivered events with delivery_success=false (Stripe keeps the last 30 days) and process them, or list subscriptions with status=all and write each one's current status to the matching user.
Stripe says to revoke access when a subscription changes to canceled or unpaid, and to notify the customer when it becomes past_due.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-10-19. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.