Stripe subscription status and paid access: what past_due, unpaid and incomplete should mean in your app
Stripe subscription status decides paid access: what past_due, unpaid and incomplete mean, why checkout needs a second list, and a grace period.
A customer's card expires. Stripe fails the renewal, moves the subscription to past_due, and your webhook writes that Stripe subscription status into your database. What happens next comes down to one line of your code: the list of statuses that grant paid access. In the kit that list is active and trialing, so the customer drops to Free the moment the webhook lands, and the dashboard says "Free" without saying why. That is a defensible choice, and it has a cost. It is also only half the picture, because the kit keeps two status lists for two different questions.
Two questions, two status lists
// src/lib/billing/subscription.ts: who gets paid features right now
const ACTIVE_STATUSES = ["active", "trialing"] as const;
// src/lib/billing/customer.ts: who must not be sent to a second Checkout
const LIVE_STATUSES: ReadonlySet<Stripe.Subscription.Status> = new Set([
"active",
"trialing",
"past_due",
"unpaid",
"paused",
"incomplete",
]);
getUserPlan filters the subscriptions table, which the webhook writes, on ACTIVE_STATUSES. That gives one query against your own database per chat request instead of a call to Stripe. The checkout route instead asks Stripe directly for anything in LIVE_STATUSES and sends those customers to the billing portal. How that works is in Stripe Checkout and the customer portal in the App Router.
The two lists disagree on four statuses, on purpose. A past_due customer has lost paid access, but they still have a subscription. A fresh Checkout for them would start a second, separately billed subscription on top of the one Stripe is still trying to collect for.
What each status means, and what the kit does with it
These descriptions are from the status field documentation in stripe-node 22.2.0. To check the kit's side, I loaded a throwaway Postgres 16 with one Pro subscription in each status and ran getUserPlan against it. It returned Pro for active and trialing, and Free for the other six.
trialing: in a trial, moves toactivewhen it ends. Paid access: yes. Blocks a new Checkout: yes.active: paid and current. Access: yes. Blocks: yes.incomplete: "moves intoincompleteif the initial payment attempt fails". If the first invoice is not paid within 23 hours it becomesincomplete_expired. Access: no. Blocks: yes.past_due: "payment is required but cannot be paid (due to failed payment or awaiting additional user actions)". Once Stripe has exhausted its retries, the subscription becomescanceledorunpaid, depending on your subscription settings. Access: no. Blocks: yes.unpaid: the end state of failed retries if your settings choose it. Invoices are still created "but then immediately automatically closed". Access: no. Blocks: yes.paused: only reachable "when a trial ends without a payment method". The kit creates no trials, so you only see this if you add them. Access: no. Blocks: yes.canceledandincomplete_expired: over. Access: no. Blocks: no, so a returning customer can check out again.
past_due: cut access now, or let Stripe's retries be the grace period
Strict, the kit's default. A failing card stops costing you model tokens immediately, which matters more in an AI product than in most SaaS: a Pro seat here is up to 1,000 requests a month on Opus. The cost lands on your best customers. Someone whose card simply expired is downgraded mid-month without warning. They hit the 25-request Free cap on Haiku, and the 402 says "You've used all 25 requests on the Free plan this month" with a link to the pricing page, inviting them to buy a plan they already pay for. Clicking Upgrade does get them to the right place, because checkout finds the past_due subscription and opens the portal. But it is a confusing route to their payment method.
Grace period. Add "past_due" to ACTIVE_STATUSES. Stripe's retry schedule becomes your grace period. When the retries run out, Stripe moves the subscription to canceled or unpaid, the webhook writes that, and access ends with no extra code. Your exposure is bounded, because the monthly request cap still applies to a past_due Pro user exactly as it does to a paying one.
Whichever you choose, tell the customer why. The kit does not do this today. A small helper finds a subscription that Stripe is still trying to collect:
/** Statuses where Stripe still wants money: show the problem, whatever access you grant. */
const BILLING_PROBLEM = ["past_due", "unpaid", "incomplete"] as const;
export async function getBillingProblem(userId: string) {
const [row] = await db()
.select({ status: subscriptions.status, plan: subscriptions.plan })
.from(subscriptions)
.where(
and(
eq(subscriptions.userId, userId),
inArray(subscriptions.status, [...BILLING_PROBLEM]),
),
)
.orderBy(desc(subscriptions.updatedAt))
.limit(1);
return row ?? null;
}
Against the same test database, it flagged exactly the past_due, unpaid and incomplete users. On the dashboard, one banner covers both policies:
const problem = await getBillingProblem(session.user.id);
// ...
{problem && (
<p className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm">
Your last payment for {planById(problem.plan).label} did not go through.
Update your payment method under Manage billing to keep it.
</p>
)}
The existing Manage billing button opens the portal home, where the customer can replace the card. Once the outstanding invoice is paid, the status returns to active through the same webhook.
Things that bite
- Decide access from your table, not from Stripe.
getUserPlanruns on every chat request, and a Stripe API call there adds latency and a dependency to every model call. Checkout is the one place that reads Stripe directly, because it cannot wait for the webhook. - Order matters on the webhook. The kit upserts each
customer.subscription.*event's snapshot, so a delayedupdatedevent can overwrite a newer status until the next event arrives. The tradeoff is covered in Stripe webhook signature verification in Next.js. unpaidandcanceledare a setting, not a law. Which one a failed subscription ends in depends on your Stripe subscription settings. Both are outsideACTIVE_STATUSES, so access ends either way. Onlycanceledlets the customer start a new Checkout.- Keep the lists typed.
LIVE_STATUSESis typed asStripe.Subscription.Status, so a typo fails the build.ACTIVE_STATUSESis compared against a plain text column, so a misspelled status there silently never matches.
Shipwright, the Next.js and Claude starter kit this blog documents, ships both lists wired to the webhook, the quota check and Checkout. The policy you choose for past_due is a one-word change.