Skip to main content

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​

StateHas access?Meaning
pendingNoCheckout started, not paid
activeYesPaid and in period. A free trial is active with is_trial: true
in_graceYesRenewal failed, the store is still retrying, access kept
on_holdNoGrace ran out, the store keeps retrying without access
pausedNoThe user paused it (Google Play)
cancelledYes, until period endAuto-renew is off. They paid for this period
expiredNoPeriod over, not renewed
revokedNo, immediatelyRefunded, charged back or revoked by the store
duplicate_blockedNoA second purchase of something they already hold
Rule

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​

Rule

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.

Rule

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.

Rule

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 Date objects 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 premium entitlement, which an upgrading member already had. Wait for the bought plan's own entitlement.
  • Refunds re-granted access. A refunded subscription that stayed active on 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.
  • cancelled keeps 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.