Guides · Updated October 31, 2026
Stripe subscription past_due but the user still has access: what to do
A customer's renewal failed, Stripe shows the subscription as past_due, and they're still using your paid plan. Sometimes that's intended (a grace period while Stripe retries the card), and sometimes it's a bug that will keep them on the paid plan long after Stripe has stopped trying. This guide explains what past_due means, why apps keep granting access, how to choose a policy, a code pattern that follows it, and what happens when the retries run out.
What past_due means
Stripe's definition: payment on the latest finalized invoice either failed or wasn't attempted (Stripe: How subscriptions work). In practice it's usually a renewal whose card was declined. While a subscription is past_due:
- It still exists and keeps creating invoices. It is not canceled.
- Stripe might retry the payment, following your retry settings (Smart Retries or a custom schedule), but Stripe's docs note the status doesn't guarantee another attempt. For example, Stripe doesn't retry when no payment method is available or the card issuer returned a hard decline code; those retries only run once a new payment method is added (Stripe: Automate payment retries).
- Each failed attempt sends
invoice.payment_failed, whoseattempt_countsays how many attempts have been made. The move intopast_dueitself arrives ascustomer.subscription.updated. - If you set status resolution to "all invoices" in your subscription settings,
past_duecan also mean an earlier invoice is still outstanding.
Stripe's guidance for this status is to notify the customer and ask them to update their payment details. For canceled and unpaid it says to revoke access (Stripe: Using webhooks with subscriptions). Whether past_due keeps access is left to you.
Why your app still grants access
- It checks for bad statuses instead of good ones. A deny-list lets every status you didn't think of through:
// Looks fine, lets past_due (and unpaid, incomplete, paused) straight through: const hasAccess = user.stripeStatus !== "canceled";
Gate on an explicit allow-list (active,trialing, pluspast_dueonly if your policy says so) and treat everything else as no access. - It never hears about the failure. An app that only handles
checkout.session.completed, or onlycustomer.subscription.deleted, misses the change topast_due, which comes ascustomer.subscription.updated, along withinvoice.payment_failedfor each failed attempt. Check that your endpoint subscribes to these events and that your handler does something with them. - The events never arrived. If your endpoint returned errors, timed out, redirected or was disabled, the status change is sitting undelivered in Stripe. In live mode Stripe retries delivery for up to three days; after that you need to resend it. Look at the endpoint's Event deliveries tab (Stripe: Receive Stripe events). Our webhook secret guide covers finding and resending failed events.
- The status is cached. A plan or
is_proflag copied into a session, a JWT claim or a cache keeps saying "paid" until it's refreshed, even after your database is correct. Read access from your database (or re-check it) on requests that matter, and keep token lifetimes short. - Events were applied out of order. Stripe doesn't guarantee event order. If your handler writes the status from each payload, an older
updatedevent processed late can move a customer frompast_dueback toactive. Re-read the subscription from the API instead. - Your app treats past_due as paid, and the retries end with "leave past due". Then the subscription never becomes
canceledorunpaid, nocustomer.subscription.deletedever arrives, and the customer keeps access with no end date.
Decide your policy: grace period or restrict
Both are reasonable. Write down which one you're using, because the code and your Stripe settings have to agree.
- Grace period. Keep access for a fixed number of days after the first failure, show an "update your card" banner, and restrict after that. Good when most failures are expired cards that customers fix once told. Make the grace period end on its own (a timestamp you check), not only when a later event arrives.
- Restrict right away. On
past_due, drop to read-only or the free plan and point the customer to a page where they can update their payment method. Access comes back when the subscription returns toactive. - Line it up with your retry settings. If retries can run longer than your grace period, the customer is restricted while Stripe is still trying, which is fine. If you grant access for all of
past_due, choose cancel or mark unpaid as the final action after retries, so the grace ends when Stripe gives up (Stripe: Automate payment retries).
A handler that follows your policy
Two rules: gate access on an allow-list of statuses, and treat each webhook as a prompt to re-read the subscription from Stripe rather than trusting the event's contents. A sketch in the style of Stripe's Node library (the db calls stand in for your own data layer):
// 1) Gate on an allow-list. Anything not listed, including a status you
// didn't plan for, means no access.
const ALLOW = new Set(["active", "trialing"]);
const GRACE_DAYS = 0; // your policy: 0 = restrict on the first failed renewal
function hasAccess(user, now = Date.now()) {
if (ALLOW.has(user.stripeStatus)) return true;
if (user.stripeStatus === "past_due" && GRACE_DAYS > 0 && user.pastDueSince) {
return now - user.pastDueSince < GRACE_DAYS * 86_400_000;
}
return false; // past_due after grace, unpaid, canceled, incomplete, paused, ...
}
// 2) On each webhook (after verifying the signature), re-read the
// subscription from Stripe instead of trusting the event payload.
async function onStripeEvent(event) {
if (await db.processedEvents.has(event.id)) return; // duplicates and resends
const obj = event.data.object;
const subId = obj.object === "subscription"
? obj.id
// invoice events: newer API versions use parent.subscription_details.subscription,
// older ones use invoice.subscription
: obj.parent?.subscription_details?.subscription ?? obj.subscription;
if (subId) {
const sub = await stripe.subscriptions.retrieve(typeof subId === "string" ? subId : subId.id);
const user = await db.users.findByStripeSubscription(sub.id);
await db.users.update(user.id, {
stripeStatus: sub.status,
pastDueSince: sub.status === "past_due" ? (user.pastDueSince ?? Date.now()) : null,
});
}
await db.processedEvents.add(event.id);
}
// Subscribe the endpoint to customer.subscription.updated,
// customer.subscription.deleted, invoice.payment_failed and invoice.paid.Because hasAccess checks the grace period when a request comes in, access ends on time even if no further event arrives. pastDueSince records when your app first saw the status, so if events were delayed it starts late; if that matters, use the date of the first failed invoice instead. Verify the webhook signature before processing, return a 2xx quickly, and record processed event IDs, since Stripe can deliver the same event more than once (Stripe: Best practices). After invoice.paid, Stripe's docs say to retrieve the subscription and confirm it's active before extending access, which the re-read above does.
What happens after the retries end
When the last retry fails, your failed payment settings decide the subscription's next status (Stripe: Automate payment retries):
- Cancel the subscription: it becomes
canceled, a terminal state, and Stripe sendscustomer.subscription.deleted. To come back, the customer needs a new subscription. - Mark the subscription as unpaid: it becomes
unpaid. It stays in place and keeps generating invoices, which stay in draft, but Stripe doesn't attempt payment. Stripe's docs say to revoke access here. - Leave the subscription past-due: it stays
past_due, and invoices keep being generated and charged according to your retry settings. Your app has to end access itself if that's your policy. - Pause the subscription: eligible subscriptions become
paused, and Stripe doesn't generate new invoices until it resumes.
Changing these settings only affects future retries. To bring a past_due or unpaid subscription back to active with the default status setting, pay the most recent non-voided invoice or mark it uncollectible; with "all invoices", settle every outstanding one (Stripe: Subscription statuses). If a customer updated their card and nothing happened, check where they updated it: retries use the subscription's default payment method before the customer's.
Find the past_due customers who have access now
- In the Dashboard's subscriptions list, filter by
Past due(andUnpaid), or call List subscriptions withstatus=past_due(API: List subscriptions). Export the customer email or ID. - Export the users who currently have paid access in your app.
- Match them. Anyone in both lists is getting access while their payment is failing. Apply your policy: inside the grace period, send a reminder; past it, restrict. Before restricting, check they don't also have another active subscription, a one-time purchase or a comp.
What PaidCheck's free report checks
With a read-only restricted key and your user list (a CSV, a Supabase function or an HTTPS endpoint), the free report does step 3 for you and looks at the webhook causes above:
- Past due, still has access: users your app marks active whose Stripe subscription is
past_due,unpaid,canceledorincomplete. It skips users who also have an active or trialing subscription or a paid one-time purchase. PaidCheck doesn't know your grace period, so it lists everypast_dueone at medium severity for you to judge. - Failed webhooks: Stripe events from the last 30 days that at least one of your endpoints never acknowledged, grouped by event type.
- Disabled endpoints: webhook endpoints Stripe isn't sending to.
- Missing events: no enabled endpoint subscribes to
customer.subscription.updatedorcustomer.subscription.deleted, or there's no endpoint at all. It doesn't check whether you subscribe toinvoice.payment_failed.
Without your user list it runs only the Stripe-side webhook checks. It can't see your code, sessions or caches, and it never writes to Stripe or your app. Paid plans run these checks daily and send an alert email when a check finds a new issue.
Who's past due and still has access?
Run the free leak report to list past-due, unpaid and canceled customers who can still use your app, plus failed webhook deliveries. Read-only restricted key, nothing stored.
Run the free leak report →FAQ
Does past_due mean the Stripe subscription is canceled?
No. past_due means payment on the latest finalized invoice failed or wasn't attempted. The subscription is still in place and keeps creating invoices, and Stripe might retry the payment. What happens after the retries end depends on your failed payment settings.
Should I cut access when a subscription goes past_due?
That's your policy. Stripe's docs say to notify the customer and ask them to update their payment details when a subscription becomes past_due, and to revoke access when it becomes canceled or unpaid. Keeping access for a short grace period or restricting right away are both reasonable, as long as your code does what you decided.
How long does Stripe retry a failed subscription payment?
It depends on your settings. Stripe's docs say Smart Retries can retry a set number of times within 1 week, 2 weeks, 3 weeks, 1 month or 2 months, with a recommended default of 8 tries within 2 weeks. A custom schedule allows up to three retries. Stripe doesn't retry in some cases, for example when no payment method is available or the issuer returned a hard decline code.
What happens to a past_due subscription after the last retry?
Your failed payment settings decide: Stripe can cancel the subscription, mark it unpaid, leave it past_due, or, for eligible subscriptions, pause it. If you choose to leave it past_due, it stays past_due and invoices keep being generated, so an app that grants access to past_due keeps that customer on the paid plan.
The customer updated their card but the subscription is still past_due. Why?
Stripe's docs say retries use the subscription's default payment method first, then the subscription's default source, then the customer's default payment method. If the subscription has its own default payment method and only the customer's default was updated, Stripe keeps retrying the old one. Update the field where the payment failed, or have the customer pay the open invoice. With the default status setting, paying the most recent non-voided invoice moves the subscription back to active.
Can PaidCheck change access in my app?
No. PaidCheck reads Stripe with a read-only restricted key and compares it with who has access in your app (from a CSV, a Supabase function or an HTTPS endpoint you provide). It lists users whose subscription is past_due, unpaid or canceled but who still have access, so you can decide what to do. It never writes to Stripe or your app.
Check your own account
PaidCheck's free leak report runs these comparisons with a read-only Stripe restricted key. Upload a CSV of your users or connect nothing for Stripe-only checks. Nothing is stored.
Run a free leak report →