Payments reference

Config options

Using Payments is the same ground with the code you write around it.

OptionDefaultMeaning
billingSubjectnoneuser or organization. The one answer only you can give — it lands in a column and a unique index
basePath/paymentsWhere 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
graceGrantsAccesstrueWhether 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.

RailMerchant of record
appleNo
googleNo
stripeNo
lemonSqueezyYes
paddleYes

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.

StatusGrantsMoney arrived
activeYesYes
in_graceBy policyNo
on_holdNoNo
canceledYesYes
expiredNoYes
never_paidNoNo
refundedNoNo
revokedNoNo
pausedNoYes

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

RoutePurposeVerification
POST /payments/purchasesSubmit a receipt or signed transactionbearer · session
GET /payments/entitlementsThe caller’s own resolved entitlementsbearer · session
POST /payments/restoreRebind store history to the callerbearer · session
GET /payments/pricingWhat the caller’s subscription pays, and when it changesbearer · session
POST /payments/checkoutCreate a checkoutbearer · session
POST /payments/portalA billing-portal session for the caller’s own accountbearer · session
POST /payments/webhooks/<rail>One per railsigned-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

RouteScope
POST /payments/admin/discountspayments:discounts:create
GET /payments/admin/discountspayments:discounts:read
POST /payments/entitlements/grantpayments:entitlements:grant
POST /payments/entitlements/revokepayments:entitlements:revoke
GET /payments/admin/catalogpayments:catalog:read
GET /payments/admin/purchasespayments:purchases:read
GET /payments/admin/subscriptionspayments:subscriptions:read
GET /payments/admin/entitlementspayments:entitlements:read
GET /payments/admin/reconcile-runspayments: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

BindingTypeWritten by
DBd1pithy add
PAYMENTS_RECONCILEworkflow, optionalpithy 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.

ESC