Entitlements and subscription states
Every rail has its own words for "paid", "cancelled" and "refunded". This page is the one model we turn them into, and the rules for deciding whether a user has access right now. Read it before you write a paywall, a renewal handler or a support tool.
How it works
A user can hold more than one grant at once: their own plan, a family seat, a support grant. Access is the union of every live grant. It is never "whatever the last processed subscription said".
The states
| State | Has access? | Meaning |
|---|---|---|
pending | No | Checkout started, not paid |
active | Yes | Paid and in period. A free trial is active with is_trial: true |
in_grace | Yes | Renewal failed, the store is still retrying, access kept |
on_hold | No | Grace ran out, the store keeps retrying without access |
paused | No | The user paused it (Google Play) |
cancelled | Yes, until period end | Auto-renew is off. They paid for this period |
expired | No | Period over, not renewed |
revoked | No, immediately | Refunded, charged back or revoked by the store |
duplicate_blocked | No | A second purchase of something they already hold |
cancelled is not "access gone". It means auto-renew is off. The user keeps access
until the period they paid for ends. Revoking on cancel is the most common bug in
hand-written billing.
How to build it
The paywall reads a local table
The product backend keeps its own small table, filled by UPG's callbacks:
create table billing.entitlement (
user_ref uuid not null,
entitlement text not null,
is_active boolean not null,
expires_at timestamptz, -- includes grace
source_occurred_at timestamptz not null, -- provider time of the event
source_sequence bigint not null,
primary key (user_ref, entitlement)
);
The gate:
select 1 from billing.entitlement
where user_ref = $1 and entitlement = $2
and is_active and (expires_at is null or expires_at > now());
- Fail closed. If the table cannot be read, the user is on the free tier. Never unlock on an error.
- Never call the payment service on the read path. A paywall that calls out on every request is down whenever the payment service is.
Apply events in order, per row
Callbacks can arrive late, twice, or out of order. Each event carries the provider's time and a sequence number. Apply a row only when the event is newer than what the row already holds:
insert into billing.entitlement as e (...)
values (...)
on conflict (user_ref, entitlement) do update set ...
where (e.source_occurred_at, e.source_sequence) < (excluded.source_occurred_at, excluded.source_sequence);
The sequence counts per subscription, but access is per user. So the guard must be on each entitlement row, not one number per user.
Refunds win
A refund or revoke skips the ordering guard. Money going out always wins. A
revoked subscription stays revoked against later events, except an event that opens
a new paid period (a goodwill refund on a subscription the user keeps paying).
Reconcile every night
Webhooks get lost. Every night, page through the payment service's entitlement list and repair rows that differ. Rules we learned:
- Write only rows whose values really differ. Keep the existing timestamps.
- Do not stamp repaired rows with
now(). A real renewal that happened earlier but arrives later would then look stale and be dropped. - Reconcile everyone who started a checkout recently, not "the 200 most recently updated". A user whose first webhook was lost never gets updated, so they never make that list.
- "The rail has no record of it" never revokes. It means our setup is wrong.
Rules
Recompute access from every grant the user holds, every time. Upserting from the one subscription being processed is last-writer-wins: when a user holds two plans, the one that lapses first switches off the one still paid for.
Decide revocation from the event, not only from the subscription status. Stripe leaves
a refunded subscription active. Google sends PENDING on some activations. Status
alone is wrong both ways.
Keep the raw signed bytes of every callback for at least 30 days. A JSON.stringify of
the parsed body cannot be re-verified.
Mistakes we made
- No purchase ever unlocked, and every test passed. The webhook handler passed JS
Dateobjects to a raw SQL client that did not accept them, so every callback threw. The tests used an in-memory fake database. Run the billing path against a real database in tests. - Nightly reconcile locked payers out for a day. It stamped rows with
now(). A renewal from 02:59 provider time, delivered after the 03:00 reconcile, was dropped as older. See "Reconcile every night". - A "payment landed" screen never finished for upgrades. It waited for the umbrella
premiumentitlement, which an upgrading member already had. Wait for the bought plan's own entitlement. - Refunds re-granted access. A refunded subscription that stayed
activeon the rail got its next event, and access came back. Refunds were also not durable, so the refunded customer could not buy again.
Checklist
- The paywall reads only the local table and fails closed.
- Callbacks dedupe on event id and apply with a per-row ordering guard.
- Refunds and revokes bypass the guard.
-
cancelledkeeps access until period end. - Access is the union of all grants, including family seats and support grants.
- A nightly reconcile repairs drift without moving timestamps forward.
- Tests run the callback path against a real database.