Guides · Free check · $9 fix pack

Guides

Stripe says the subscription changed, but your app still shows the old plan

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).

The usual causes

1. Read the delivery log first

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.

2. Subscribe to the subscription events

From Stripe's subscription webhook guide (source):

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).

3. Store the Stripe IDs when checkout completes

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).

4. Re-read the subscription instead of trusting the event

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).

5. Fix the customers who are already out of sync

for await (const sub of stripe.subscriptions.list({ status: 'all', limit: 100 })) {
  await syncSubscription(sub.id);
}
Check yours in 10 seconds (free).

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 check

FAQ

Why does my app still show the old plan after a customer upgrades?

Usually 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.

Which Stripe event should grant subscription access?

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.

How do I fix users whose subscription status is already wrong?

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.

When should I remove access?

Stripe says to revoke access when a subscription changes to canceled or unpaid, and to notify the customer when it becomes past_due.

Sources

More guides

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.