You need: payments composed. This page is about reading state rather than creating it.
Nine statuses, answering two questions independently
Entitlements is what a status resolves to, entitlements across mobile and web is why a lapse on one rail does not always end access, and pithy payments reconcile is what catches a change the webhook missed.
Every rail’s vocabulary maps into nine normalized statuses. Each answers does it grant access and did its money ever arrive — and those are not the same question.
| Status | Grants | Money arrived | What it means |
|---|---|---|---|
active | Yes | Yes | Paid and current |
in_grace | By policy | No | A failed renewal, still inside the retry window |
on_hold | No | No | A payment outstanding — retries exhausted, or a deferred payment still settling |
canceled | Yes | Yes | Auto-renew off, with the paid period still running |
expired | No | Yes | A period you were paid for has ended |
never_paid | No | No | It terminated before any money cleared |
refunded | No | No | Money went back |
revoked | No | No | The store took it back |
paused | No | Yes | The user suspended it |
expired and never_paid are the pair to read twice
They look alike — both are over, neither grants — and they differ on the only question a balance cares about.
A bank debit that bounced. A subscription abandoned before its first charge. A deferred purchase canceled before payment. All three end with no charge, and reading them as merely expired credits a 100-coin pack for money that never arrived — with no clawback ever to follow, because there is nothing to reverse.
If you sell consumables, this is the distinction that costs you real money to get wrong.
Apple maps nothing to never_paid: StoreKit issues no transaction until the money moves.
canceled does not mean unentitled
Turning off auto-renew forfeits the next period, not the one already paid for.
A subject is entitled while some purchase granting that key is active, in_grace, or canceled with time left.
So a canceled subscriber keeps their access until the date they paid through — which is both correct and the thing a naive status === "active" check gets wrong.
in_grace is a status and a date, and the date is the half that is easy to get wrong
Grace only grants if the expiry covers the retry window — and the paid period’s end is not that date. By the time a store says grace, the period has already ended.
Apple reports the window separately from the transaction, so the rail carries the later of the two. Reading the transaction alone would record a grace period and revoke the subscriber in the same commit, which is the exact opposite of what grace is for.
Stripe and Play each report one expiry that already covers it.
Whether grace grants at all is your config key, defaulting to yes — because that is the point of grace: a failed card should not lock somebody out mid-month while the store is still trying.
paused is a status and a date too
A paused purchase carries when it resumes — the instant the store said it comes back, never one computed here.
Null means the store put no end on the pause. So paused until the 1st and paused indefinitely stay different sentences, and a row that is not paused can never carry the field at all — a database constraint enforces that.
What your UI should do in each
| State | Show |
|---|---|
active | Nothing about billing |
in_grace | Nothing about billing. Nagging somebody whose card is being retried is how you lose a customer who was never leaving |
canceled, with time left | Your Pro runs until the 14th. Offer to resume |
expired | Your Pro ended on the 4th, and the way back |
refunded, revoked | The paywall. No apology, no explanation you cannot stand behind |
on_hold | We could not take payment. A link to update the card |
paused | Paused until the 1st, or paused — never invent the date |
Lapsed rows come back with their flag false rather than filtered out, which is what makes those sentences possible.
A refund can arrive with nothing before it
On the merchant-of-record rails especially: a chargeback, a support decision, a tax correction, all issued without your side initiating anything.
The projection handles it as a state like any other, because it projects a state rather than applying a diff. But a support workflow that assumes a refund follows a request will not hold.
Out-of-order events, and why nothing here is last-write-wins
Providers do not guarantee delivery order.
An expired notification can arrive after the renewed that superseded it — and last-write-wins would then silently revoke a paying subscriber. That defect produces no error anywhere, and the subscriber reports it rather than your monitoring.
sequenceDiagram
participant P as Provider
participant W as Your Worker
participant DB as Your D1
P->>W: renewed, event time 10:00
W->>DB: Row moves to 10:00
P->>W: expired, event time 09:00
W->>W: Older than the row it would update
W--xDB: Ignored entirely
Note over W,DB: A predicate in the database, not only a pre-read —<br/>two concurrent writers cannot order themselves
So the projection is monotonic on the provider’s own event time: an event no newer than the row it would update is ignored entirely. It is a database predicate as well as a pre-read, because two concurrent writers cannot order themselves correctly on their own.
Repairing one person’s state
pithy payments reconcile --env prod --subject user:usr_a1b2c3The same steps the nightly cron runs, narrowed to one holder. This is the answer to my subscription isn’t showing up.
--dry-run reports the drift and writes nothing. --rail narrows to one store after that store’s webhooks were interrupted, rather than paying for the others.
A rising drift count means webhooks are not arriving — which is the signal the whole reconciliation exists to surface.
Comping and taking back
Two control-plane routes: grant an entitlement, and revoke one.
A comped key must be declared in your config’s manual entitlements first. A grant nothing declared is a grant nobody reviewed — and comped keys are also the ones that survive the catalog re-derivation, because a key the catalog never sold is a human’s decision.
Both are audited under the management client’s own actor kind.