Somebody buys Pro in your iOS app on Sunday. On Monday they open your web app on a laptop and it does not know who they are, or that they paid.
That resolution — a purchase made in one store granting access everywhere — is the whole product, and it is the same one RevenueCat built a very good business on. Their SDKs, their store-quirk coverage and their dashboards are genuinely ahead of this package, and if you want a hosted product with a support contract you should buy theirs.
The honest difference is not the feature list. It is where the purchase history lives: theirs is a hosted data plane holding your customers’ transactions, and this is five tables in a D1 you own, written by a Worker you deploy. Nothing here calls a Pithy-operated service, because there isn’t one.
A product is not an entitlement
This is the load-bearing distinction, and getting it wrong is the mistake worth naming first.
pro_monthly and pro_annual are two products. Each is listed in several stores’ catalogs under different SKUs. Between them they grant one entitlement — pro. Gating code names the key:
app.get("/reports", requireAuth(), requireEntitlement("pro"), handler);Nothing outside the catalog ever names a SKU. Add an annual plan, launch on a fifth store, rename a Play product id — the gate above does not change, and neither does anything that reads it.
A product is not an entitlement works the distinction through properly.
Five rails
| Rail | Who owns the tax |
|---|---|
apple | You |
google | You |
stripe | You |
lemonSqueezy | Merchant of record. Global sales tax, EU VAT, invoicing, dunning and chargebacks are theirs |
paddle | Merchant of record, likewise |
Nothing downstream of a rail’s own module ever branches on a store’s vocabulary. Apple’s originalTransactionId, Google’s purchaseToken, Stripe’s subscription and Paddle’s sub_… all arrive as one normalized (rail, provider transaction id) pair, which is what lets one projection serve five stores and one entitlement resolve across them.
The merchant-of-record difference is commercial rather than technical, and the only place it shows in the code is that a refund can arrive with no local write preceding it — both issue them on their own.
What makes it correct rather than merely present
Every write converges on one idempotent projection, keyed on that (rail, transaction id) pair. A client submission, a provider webhook and a reconciliation pass produce the identical row — so a dropped client call costs nothing and a replayed webhook changes nothing.
The projection is monotonic on the provider’s own event time. Providers do not guarantee delivery order, and a stale expired arriving after the renewed that superseded it would silently revoke a paying subscriber. So a later event never loses to an earlier one that arrived second.
Sandbox purchases are tracked as sandbox and never grant a production entitlement.
A cron Workflow re-verifies what the webhooks missed, because webhook-only systems rot silently and nothing tells you. A rising drift count is the signal that webhooks are not arriving, and pithy payments reconcile runs the same pass on demand.
The catalog lives in pithy.config.ts, not in D1. A product’s entitlement mapping is policy: it should be diffable in git and it should not be mutable at runtime. The cost is that a new SKU needs a deploy, which is the correct trade — a table a mis-click or an attacker can edit decides who is entitled to what.
What it deliberately does not do
It does not do your tax. On three of the five rails, registration, thresholds and invoicing are yours. The two merchant-of-record rails exist precisely so you can hand that to somebody else, and choosing one is a business decision this package will not make for you.
It does not price anything. What a product costs is set in each store’s own console. The catalog maps SKUs to entitlements and nothing else.
It has no public routes. Every route belongs to an authenticated purchaser or to a machine proving authenticity. Without auth composed, every one of them denies.
It does not sell anything by itself. Checkout is the store’s — Apple’s sheet, Google’s, Stripe’s hosted Checkout, Paddle’s overlay. This package verifies what came back and decides what it grants.
Who holds the entitlement
billingSubject has no default, and that is the whole declaration: it is an answer only you can give, and it lands in a column and a unique index.
| Value | Means |
|---|---|
user | One person buys, one person is entitled |
organization | A company buys, and everybody in it is entitled |
pithy add payments refuses without it in a run that cannot be asked, naming the flag and both values. Changing it later is a migration of your own, not a config edit.
When you would reach for it
When money changes hands and access follows. A subscription business, a mobile app with in-app purchases, a one-off unlock, a consumable currency pack — all four are the same five tables and the same gate.
Not for invoicing a B2B customer against a purchase order, and not for a marketplace taking a cut of a third party’s sale. Both are real businesses and neither is this.
What it needs
It requires secrets — five rails’ credentials are read through it, so payments will not compose without it.
auth is optional and strongly implied: every route resolves the subject its caller is acting for, and that resolution starts at the identity seam, so with no auth composed every route denies. That is the right default and not a useful one.
ledger is optional and reached only by products whose catalog entry declares a balance grant. Most products never touch one.