DEV Community

Posting Dude
Posting Dude

Posted on

Stripe Has 8 Subscription Statuses. Your Paywall Probably Checks One.

Most tiny SaaS paywalls I've seen boil down to one line:

if (sub.status === "active") showTheApp();
Enter fullscreen mode Exit fullscreen mode

It works on day one. It keeps working right up until a card fails, or someone signs up with a card that needs 3D Secure, or a trial ends with no card on file. Then that one line either locks out a person who is about to pay you, or keeps paid features on for someone who stopped paying weeks ago.

A reader on my Stripe webhook post raised exactly this in the comments, and it deserved more than a reply. So here is the boring part nobody puts in the tutorial: what each status actually means and what access I'd give for it when you have under 100 customers.

The 8 statuses, in plain words

From Stripe's own docs on how subscriptions work:

  • trialing: in a trial. Safe to give access.
  • active: in good standing. Note: active doesn't promise every old invoice is paid, only the ones that govern status under your settings.
  • incomplete: the first payment failed, or needs action (3D Secure), or is still processing. The customer has 23 hours to pay.
  • incomplete_expired: those 23 hours passed without a payment. It never bills. To come back they need a new subscription.
  • past_due: payment on the latest invoice failed or wasn't attempted. Stripe may retry while it sits here, but it isn't guaranteed.
  • unpaid: only happens if you picked it in your failed-payment settings. Retries are done, invoices keep getting created but aren't charged.
  • canceled: terminal. Can't be updated. Coming back means a new subscription.
  • paused: a trial ended with no payment method and you set the trial to pause instead of cancel.

The part that bites people: what happens after the last retry is your choice in the Dashboard. The sub can go to canceled, to unpaid, or just stay past_due. If you never opened that settings page, go look now, because your paywall logic depends on it.

What I'd actually do with each one

Under 100 customers, I wouldn't build anything clever. One function, one small table:

Status Access Why
trialing full they're trying it
active full paid
past_due full, with a banner, for a grace window (say 7 days) most failed renewals are an expired card, not someone leaving
incomplete none yet, show a "finish payment" screen they never paid the first invoice
incomplete_expired none, send them back to checkout it's dead, a new sub is needed
unpaid read-only (can log in, export data, can't use paid stuff) retries are over, but don't hold their data hostage
paused read-only + "add a card to continue" trial ran out without a card
canceled free plan or read-only until period end, if you allow that they chose to leave

The grace window on past_due is the one I'd fight for. Picture a customer at $29/month whose card expired over the weekend. If you hard-lock them on Monday morning, you've turned a card problem into a "this app is broken" problem, and some of them won't come back to fix the card. A banner that says "your last payment didn't go through, update your card" and still lets them work is kinder and it recovers more money.

That banner is only half of it. The other half is the emails you send while the retries run. I wrote up the three short emails I'd send when a payment fails with under 50 customers, and there's a longer playbook for recovering failed SaaS payments before they turn into churn on Aura++ if you want the full flow.

Store access, not just status

The mistake I'd avoid is calling Stripe on every page load to check status. Slow, and it breaks when Stripe has a bad minute. Instead keep your own row:

create table entitlements (
  user_id        bigint primary key,
  stripe_sub_id  text,
  stripe_status  text not null,
  access         text not null,      -- 'full' | 'read_only' | 'none'
  grace_until    timestamptz,
  updated_at     timestamptz not null default now()
);
Enter fullscreen mode Exit fullscreen mode

Your app reads access. Only your webhook handler (and a nightly check) writes it. The mapping from stripe_status to access lives in one function, so when you change your mind about unpaid you change one place.

function accessFor(status, graceUntil, now = new Date()) {
  switch (status) {
    case "trialing":
    case "active":
      return "full";
    case "past_due":
      return graceUntil && now < graceUntil ? "full" : "read_only";
    case "unpaid":
    case "paused":
      return "read_only";
    default: // incomplete, incomplete_expired, canceled
      return "none";
  }
}
Enter fullscreen mode Exit fullscreen mode

Set grace_until the first time you see past_due for that sub, and clear it when it goes back to active.

The webhook events that matter

You don't need to handle 40 event types. For this you need roughly:

  • customer.subscription.updated and customer.subscription.deleted: status changed, recompute access.
  • invoice.payment_failed: start the grace window, send the first email.
  • invoice.paid: back to normal, clear the banner.
  • invoice.payment_action_required: the card needs 3D Secure, send them a link to finish it.

Two things that go wrong here: events arrive twice and out of order. So on every event, re-fetch the subscription from Stripe and write its current status, don't trust the status inside the event payload. I went through the other ways Stripe webhooks break between test mode and production in an earlier post, idempotency included.

The nightly safety net

Webhooks get missed. Deploys happen at the wrong moment, endpoints return 500 for ten minutes. So once a night, list your subscriptions from Stripe, recompute access for each, and log any row where your table disagreed with Stripe. Under 100 customers that's one API page and a few seconds. If that log is ever non-empty, you just found a bug in your webhook handler for free.

A quick test before you trust it

Stripe's test cards make this easy to check in an afternoon:

  1. Create a sub with a card that requires authentication and don't complete it. You should see incomplete and your "finish payment" screen.
  2. Create a sub with a normal test card, then swap the default payment method to a card that declines and advance a test clock past the renewal. You should land in past_due, with full access and the banner.
  3. Keep advancing past your retry window. You should land wherever your Dashboard setting says (canceled, unpaid or still past_due), and your app should match.

If step 3 surprises you, that's the whole point of doing it.


If you're getting a small SaaS ready to put in front of people, I run Aura++, a launch platform for indie products, and early-stage stuff is exactly what it's for.

Curious how others handle it: when a renewal fails, do you keep full access during retries, go read-only right away, or lock it? And how long is your grace window?

Top comments (0)