Two support problems look like opposites. In one, a user whose trial ended weeks ago is still using paid features. In the other, a customer whose renewal went through writes in because they have been locked out.
Both usually come from the same place: the subscription changed at your payment provider, and the access your application gives did not follow. One developer described a trial that had ended while their application went on showing the paid features. Another described an upgrade that went through at the payment provider and never reached their own database, which had handled the first purchase without trouble. The first kind seldom produces a complaint, so it can go on for a long time.
This post is for teams that bill subscriptions through a payment provider and keep access in their own database. It goes through the status changes that should change access, the decision each one needs, why relying on webhooks alone misses some of them, and how to find the drift. The examples use Stripe, because many teams in this situation bill through it and its documentation is specific about each status. The same questions come up with any provider.
Two records of one customer
There are two records. The payment provider keeps the subscription and its status: trialing, active, past due, canceled and so on. Your application keeps what the customer may use: which features are on, and how much of each limit is left. That second record is often called the customer's entitlements.
Webhooks and background jobs carry changes from the first record to the second. Every transition the provider can make is a point where your application has to decide what access to give, and a point where the two records can drift apart.
The transitions that should change access
Stripe's documentation on how subscriptions work and on webhooks for subscriptions describes these statuses and events. Each comes with a decision that belongs to you.
A trial starts and ends. The documentation describes trialing as a status in which access can safely be given. It sends customer.subscription.trial_will_end three days before a trial ends, or at once if the trial is shorter than that. For a trial that started without a payment method, its documentation on free trials lists three things that can happen at the end: the subscription is canceled; it is paused, stops invoicing and can be resumed when the customer adds a payment method; or an invoice is created and the subscription becomes past due. The setting you choose decides which event you have to handle, and what the user sees the day after the trial.
The first payment. A subscription whose first payment has not succeeded is incomplete; if the payment does not succeed within 23 hours, it becomes incomplete_expired and is not billed. The documentation also notes that with payment methods that confirm later, such as US bank account debits, a subscription can become active straight away, and if the payment fails afterwards, the invoice is voided while the subscription stays active. It advises keeping this in mind when designing access control.
A renewal fails. A subscription becomes past_due when payment of its latest finalized invoice fails, and payment may still be retried. Its documentation on retries states that new accounts retry up to 8 times within two weeks by default, and that the period can be set from one week to two months. For past_due, the documentation suggests notifying the customer to update their payment details; for canceled or unpaid, it suggests revoking access. Whether a customer keeps access while you are still trying to collect is your decision. Keeping it risks giving a period away; cutting it risks locking out someone whose card simply expired.
Retries run out. The same page states that once every retry has failed, the subscription does one of four things, depending on your settings: it is canceled, marked unpaid, left past_due, or, where eligible, paused. For unpaid, the documentation says to revoke access, because payment was already retried while the subscription was past due.
A cancellation. Its documentation on canceling describes two kinds. A cancellation at the end of the period lets the subscription run to the end of the period already paid for, and can be withdrawn before then; setting it sends customer.subscription.updated, and customer.subscription.deleted arrives when the period ends. An immediate cancellation takes effect at once by default. A canceled subscription cannot be revived; a customer who comes back starts a new subscription. How long a canceling customer keeps access, and whether anything changes for them in the remaining days, are product decisions.
A pause. The documentation sends customer.subscription.paused when the status becomes paused, and not when collection alone is paused. Pausing collection does not change the subscription's status, and the documentation recommends using the status to start or stop service.
A plan change. An upgrade or a downgrade sends customer.subscription.updated. On a downgrade, what happens to data and features above the new plan's limits is again yours to decide; Adding pricing tiers after launch goes through where existing customers can land.
Write these decisions down before the code. Each one is a question of who carries the cost, not a detail of a webhook handler.
Why users keep access they should have lost
Several documented behaviors leave a subscription in a state where access carries on.
- The end event never comes.
customer.subscription.deletedis sent when a subscription ends. If retries run out and your settings leave the subscription past due or mark it unpaid, the subscription does not end, and that event is never sent. An application that revokes access when it receives the end event, and at no other time, keeps serving a customer who has stopped paying. - Active is not the same as paid. The documentation describes
activeas a subscription in good standing that does not necessarily have every open invoice paid, and the delayed payment case above leaves a subscription active after its payment failed. - An invoice that could not be finalized. The documentation states that subscriptions stay active if invoices cannot be finalized, so users may keep using your product while you are unable to collect. Not answering
invoice.createdsuccessfully can delay the finalization of automatically collected invoices by up to 72 hours. - Extending access on a paid invoice. One approach the documentation describes is to store an access expiry time for each customer and check it when they sign in. On renewal, it advises retrieving the subscription and confirming it is active before extending that time, because when status is worked out from all open invoices, paying one invoice does not necessarily make the subscription active again.
Why paying users get locked out
The other direction usually comes from delivery rather than from a status.
Stripe's webhook documentation states that in live mode, failed deliveries are retried with exponential backoff for up to three days; that events are not guaranteed to arrive in the order they were generated; and that the same event can be delivered more than once. A handler that applies each event as it arrives can therefore apply an older status after a newer one. If the event from a failed payment attempt arrives after the event from the retry that succeeded, a handler that trusts the last event it received locks out a customer who has paid.
A deploy is one way to miss an event. One developer described a webhook that arrived while a deploy was in progress: the customer's access was never updated. Separately, Stripe's documentation on managing webhook endpoints notes that in rare cases an event is not generated at all, so there is nothing to deliver; its recovery step is to fetch the current state of the affected object directly.
Keeping access in step
Public approaches differ in which record they treat as the source of truth.
- Apply each event. Handle the subscription's created, updated, deleted and paused events, and move access with each. Changes reach your application soon after they happen. The cost: events can come late, twice or out of order, deliveries stop after three days of retries, an event may never be generated, and the end event does not cover subscriptions left past due or unpaid. Each handler has to check the subscription's current status rather than trust the order of arrival.
- Store an access expiry time. Give each customer a time until which they have access, and extend it when a renewal is confirmed. Access runs out by itself if nothing extends it, so a missed revocation matters less. The cost: every case that should end access early or keep it longer, such as a cancellation at period end, a refund or a grace period after a failed payment, has to be turned into that one time.
- Use the provider's entitlements. Stripe's documentation on entitlements describes entitlements created for the features a customer subscribes to, and an event,
entitlements.active_entitlement_summary.updated, that carries the customer's full set of active entitlements when they subscribe, change plan or cancel. It describes features being removed when the customer cancels or the subscription is canceled after failed payments. The event lists up to 10 entitlements, beyond which you page through the rest, and the documentation recommends storing them on your side. You write less mapping code. Before relying on it, check for yourself what happens to entitlements while a subscription is paused, past due or unpaid; and features added to a product reach existing subscriptions at their next period. - Re-fetch the current state. When something changes, or on a schedule, fetch the subscription or the customer's active entitlements from the provider and overwrite your copy. The entitlements documentation suggests listing active entitlements at startup, for access checks and for reconciling after a webhook delivery fails. This sidesteps ordering and duplicates. The cost is latency, request volume and rate limits, and it covers what the provider holds and nothing else.
These can be combined. Whichever you use still needs the decisions from the section above: an event or a fetch tells you the status, not what that status should grant.
Finding drift
The last piece is a check that compares what each customer should have, given the provider's current status and your written decisions, with what your application actually gives them. One developer asked in public how to find that kind of drift; a scheduled comparison is one answer.
It has limits. It finds drift after it happens and does not prevent it. It covers the fields someone thought to compare. And it can look back so far and no further: the list of failed webhook deliveries covers the last 30 days. Usage has a similar set of limits; When the usage bill doesn't match usage goes through them layer by layer.
Before the next fix, write down for each transition what the customer should have afterwards, which record decides it, and how often you check that the two agree.
This post was written by the team building ShipRev, which keeps a SaaS product's identity, subscriptions, metering and entitlements on one customer record. The transitions in this post happen with ShipRev too: what each one grants is still a decision to write down, and the payment provider's record and ShipRev's can still drift apart, so the check above still applies. ShipRev is not open yet: the managed cloud is built but not yet open, and the source repository is not public.
Related use case: Plan limits