Config options
Using Payments is the same ground with the code you write around it.
| Option | Default | Meaning |
|---|---|---|
billingSubject | none | user or organization. The one answer only you can give — it lands in a column and a unique index |
basePath | /payments | Where the routes mount, webhooks included |
manualEntitlements | [] | Keys the control plane may grant that no product sells. Declared, because a grant nothing declared is a grant nobody reviewed |
graceGrantsAccess | true | Whether a subscription in billing-retry grace still grants |
rails | — | Which rails are on |
products | — | The catalog |
A rail that is on must declare its return URLs, and a return-URL block for a rail that is off is refused. Both checked at config rather than at the first checkout.
The rails
Standing any of them up is pithy payments; the overview is what the capability is for.
| Rail | Merchant of record |
|---|---|
apple | No |
google | No |
stripe | No |
lemonSqueezy | Yes |
paddle | Yes |
Nothing downstream of a rail’s own module branches on a store’s vocabulary. Every one arrives as a normalized rail-and-transaction-id pair, which is what lets one projection serve five stores.
The merchant-of-record difference is commercial rather than technical, and the only place it shows is that a refund can arrive with no local write preceding it — both issue them on their own.
The nine statuses
Each answers two questions independently.
| Status | Grants | Money arrived |
|---|---|---|
active | Yes | Yes |
in_grace | By policy | No |
on_hold | No | No |
canceled | Yes | Yes |
expired | No | Yes |
never_paid | No | No |
refunded | No | No |
revoked | No | No |
paused | No | Yes |
expired and never_paid are the pair to read twice. Both are over, neither grants, and they differ on the only question a balance cares about — reading a never-paid purchase as expired credits a coin pack for money that never arrived, with no clawback ever to follow.
Apple maps nothing to never_paid: no transaction is issued until the money moves.
in_grace carries a date, and the paid period’s end is not it — by the time a store says grace, the period has already ended. One rail reports the window separately and the later of the two is carried; reading the transaction alone would record a grace period and revoke the subscriber in the same commit.
paused carries a resume date — the instant the store said, never one computed here. Null means the store put no end on it, so paused until the 1st and paused indefinitely stay different sentences. A row that is not paused can never carry the field — a constraint enforces it.
Routes
| Route | Purpose | Verification |
|---|---|---|
POST /payments/purchases | Submit a receipt or signed transaction | bearer · session |
GET /payments/entitlements | The caller’s own resolved entitlements | bearer · session |
POST /payments/restore | Rebind store history to the caller | bearer · session |
GET /payments/pricing | What the caller’s subscription pays, and when it changes | bearer · session |
POST /payments/checkout | Create a checkout | bearer · session |
POST /payments/portal | A billing-portal session for the caller’s own account | bearer · session |
POST /payments/webhooks/<rail> | One per rail | signed-webhook |
No public routes. Every caller is either an authenticated person acting for a subject, or a machine proving authenticity. Turnstile has nothing to gate here.
signed-webhook is one strategy over unrelated mechanisms — a signed payload against a pinned certificate chain, a token verified against published keys with an audience check, a keyed signature inside a timestamp tolerance. Each covers the exact received bytes, which is why these are literal paths per rail rather than one parameterized route: a single route line could not carry several verifiers, and the rail a caller claims is not something to route on.
Control-plane routes
| Route | Scope |
|---|---|
POST /payments/admin/discounts | payments:discounts:create |
GET /payments/admin/discounts | payments:discounts:read |
POST /payments/entitlements/grant | payments:entitlements:grant |
POST /payments/entitlements/revoke | payments:entitlements:revoke |
GET /payments/admin/catalog | payments:catalog:read |
GET /payments/admin/purchases | payments:purchases:read |
GET /payments/admin/subscriptions | payments:subscriptions:read |
GET /payments/admin/entitlements | payments:entitlements:read |
GET /payments/admin/reconcile-runs | payments:reconcile:read |
Every route this capability registers is in these tables, and a test holds it there — parsing them and comparing against the real registrations in both directions.
That gate exists because the management reads shipped without rows for long enough that the next person to add one withheld theirs too: a table missing several peers reads as complete, so one more row would have read as a lie.
Tables
pithy_payments_purchases · pithy_payments_entitlements · pithy_payments_provider_accounts · pithy_payments_webhook_events · pithy_payments_reconcile_runs
Bindings
| Binding | Type | Written by |
|---|---|---|
DB | d1 | pithy add |
PAYMENTS_RECONCILE | workflow, optional | pithy payments provision |
The Workflow binding is optional. An unprovisioned project still verifies receipts, accepts webhooks and resolves entitlements — it simply has no nightly pass.
Secrets
One: payments-provider-credentials, with a block per rail.
A rail’s block is present in full or absent entirely, checked before the write lands — so half a credential is a refusal in your terminal rather than a signature check that silently never passes.
Rotation is manual for all of them, because none of the five returns a replacement over an API.
Sandbox isolation
Every purchase carries its store environment, and a mismatch is refused outright.
The environment is an input from this deployment’s own variable and is never inferred from the payload — inferring it from what the store said is exactly the hole.
Only a Worker deployed to production is production. The asymmetry is deliberate: treating production as sandbox loses a purchase reconciliation repairs; the other way round hands out entitlements for test transactions.
Peer capabilities
Required: secrets.
Optional: auth (strongly implied — every route denies without it), controlplane, ledger.