Add Payments

pithy add payments --set billingSubject=user --with-prerequisites

Two things in that line are not optional.

--set billingSubject answers the one config option with no default. A capability that states a default has an answer the kit is willing to pick; one that states none has an answer only you can give. This one lands in a column and a unique index, so nothing guesses it. Interactively you are asked as a list; headlessly the run is refused, naming the flag and both values.

--with-prerequisites composes secrets, which payments requires. Every rail’s credentials are read through the registry rather than an environment literal, and createBackend refuses to assemble payments without it.

You almost certainly want auth too. Payments has no public routes — every one of them resolves the subject its caller is acting for, and with no identity composed every route denies. That is the right default and not a useful one.

What lands in your repo

apps/<worker>/pithy.config.ts gains the registration. The rails and products objects are yours to fill in by hand:

payments({
  billingSubject: "user",
  rails: { apple: true, google: true, stripe: true },
  stripe: {
    successUrl: "https://acme.example/thanks?session={CHECKOUT_SESSION_ID}",
    cancelUrl: "https://acme.example/pricing",
    portalReturnUrl: "https://acme.example/account",
  },
  products: {
    pro_monthly: {
      type: "subscription",
      name: "Pro",
      entitlements: ["pro"],
      apple: { productId: "com.acme.pro.monthly" },
      google: { productId: "pro_monthly" },
      stripe: { priceId: "price_1Abc" },
    },
  },
}),

A rail that is on must declare its return URLs, and a return-URL block for a rail that is off is refused. Both are checked at config rather than at the first checkout, so a half-configured rail fails at boot with the field named.

apps/<worker>/wrangler.jsonc gains the DB binding, in every environment stanza. That is the only binding add can write.

What runs

add runs that Worker’s dev migrations, which create five tables:

pithy_payments_purchases · pithy_payments_entitlements · pithy_payments_provider_accounts · pithy_payments_webhook_events · pithy_payments_reconcile_runs

It works offline and in CI, and it touches no Cloudflare account.

The four config options

OptionDefaultWhat it decides
billingSubjectnoneWho holds a subscription: user or organization. The one answer only you can give
basePath/paymentsWhere the routes mount, webhooks included
manualEntitlements[]Entitlement keys the control plane may grant that no product sells — a beta flag, an internal tier, a comped key. Declared, because a grant nothing declared is a grant nobody reviewed
graceGrantsAccesstrueWhether a subscription in its billing-retry grace period still grants its entitlements. True, because that is the point of grace: a failed card should not lock somebody out mid-month

Then the two steps add cannot do

1. Provision the reconciliation Workflow

pithy payments provision

The PAYMENTS_RECONCILE binding arrives with this rather than with add, because wrangler requires both a name and a class_name on every workflows entry and the deployed name is per project and environment. An entry short of either field does not degrade — wrangler refuses to load the config at all.

The binding is optional. An unprovisioned project still verifies receipts, accepts webhooks and resolves entitlements. What it does not get is the nightly pass that catches what the webhooks missed, and webhook-only systems rot silently.

2. Set up the stores

This is the part nobody can do for you. Each rail’s credentials are taken by a human from a different company’s console; nothing can mint them.

pithy secrets create payments-provider-credentials

One secret, with a block per rail. pithy payments has the table of which console and what to take from it. Rotation for this secret is manual for the same reason: none of the five returns a replacement over an API.

Each console is per environment on its own terms — Stripe’s test mode, Paddle’s and Lemon Squeezy’s sandboxes, Apple’s and Google’s separate keys. Provision staging and production from the matching one, never the same credential twice.

A provision run before the secrets are set still succeeds. The first reconciliation pass is what reports the missing rail.

Per-store setup — the App Store Connect key, the Play service account, the Stripe webhook endpoint — has a guide each under Build:

Sell a subscription on the web · Sell on the App Store · Sell on Google Play · Sell with Paddle · Sell with Lemon Squeezy

Check it worked

pithy doctor reports payments under the Worker’s health with its bindings satisfied and its migrations applied. The capability’s own settings check runs there too, so a rail that is on with no credential behind it is a finding rather than a silent 500 on the first purchase.

ESC