All pages

The billing model

Every paid thing in this boilerplate is its own Stripe object. A one-time payment produces one row in purchases. A recurring offer produces one Stripe Subscription. Nothing is merged into a combined object, and nothing is keyed on the customer.

That one rule is what lets a customer hold a one-time purchase and three separate monthly plans at the same time without a single special case.

The two kinds of offer

The catalog in packages/shared defines every offer as a slug, a Stripe mode, and the features it grants.

Mode Stripe object Table Grants access
payment PaymentIntent purchases permanently, from the moment it succeeds
subscription Subscription subscriptions while that subscription is active

Postflare sells subscriptions only: the Creator and Growth plans, each as a monthly and a yearly offer. The one-time payment half stays for the unlisted pro offer, so it keeps working if a one-time offer returns. See docs/adr/0041-subscriptions-limited-by-channel-count.md.

Checkout starts from a slug. The client posts { slug: 'creator-monthly', locale: 'en' } to POST /checkout/session and the api resolves that slug to a price ID out of the catalog. The locale is saved with a completed purchase so later transactional email uses the language selected at checkout, and on the caller's users row, which carries the language for mail about their access.

That body is assembled by a step of this app's own. A pricing button navigates rather than paying: a signed-out reader goes to sign-up carrying the slug, and everyone else goes to /checkout, which shows the offer, the trial and the charge email, and posts once the reader presses. Nothing auto-forwards to Stripe, because a payment page the reader never pressed breaks the back button.

That route requires a signed-in caller. The api creates a Stripe Customer for the account on its first checkout, re-sends the account's current email and name before every later one, and passes the Customer on the Session, which makes the charge email non-editable and therefore identical to the address Clerk holds. The refresh is what keeps that true after a buyer changes their address in Clerk: writing the email only at creation would leave Stripe charging the address the account had at its first purchase. It also passes the local user id as client_reference_id, so the Purchase is bound to its owner before the money moves rather than searching for one afterwards. See docs/adr/0031-identity-precedes-payment.md.

A stored Customer id Stripe refuses is cleared rather than kept, because nothing else would ever replace it. Two refusals are terminal and they arrive differently: an id the current secret key cannot address answers resource_missing, while a Customer deleted through the API still retrieves successfully and comes back flagged deleted. The refresh before checkout is where both surface, and the next checkout creates a replacement from the same contact details, so the email lock survives the repair.

The likeliest cause is a deployment moving STRIPE_SECRET_KEY from test to live after accounts exist. A Customer id carries no marker of the mode that minted it, so every id written under the test key breaks under the live one and nothing detects it before a buyer tries to pay.

Any other refresh failure leaves the id alone and answers 502 stripe_customer_unavailable. Proceeding to the Session would call the same Stripe that just failed, so it buys nothing, and an unreachable Stripe must not cost a buyer the Customer their billing history hangs on.

A client never names a price. A client that could name a price could pay for the cheapest offer in your Stripe account and ask for the most expensive one.

One subscription per offer

A customer holding three recurring offers has three Stripe Subscriptions, not one Subscription carrying three items. Every row keys on subscription.id.

Each offer therefore has its own billing period, its own invoice, its own trial, and its own cancel date. Cancelling one is a single API call against a single object, and it touches nothing else the customer holds.

Nothing in this repo makes that call. The customer does it in Stripe's Customer Portal: POST /user/billing-portal mints a session for the signed-in caller, the dashboard links it beside their payments, and customer.subscription.updated records the result. Writing a cancel endpoint of your own means reimplementing proration, cancel-at-period-end, and the confirmation screen Stripe already ships. See docs/adr/0032-mail-follows-entitlement-not-invoices.md.

A caller who has never paid has no Stripe Customer, because one is created at first checkout rather than at signup. That route answers 409 stripe_customer_missing rather than creating a Customer to have something to open, and it answers the same way for an account whose stored Customer has become unusable. It clears the reference and never mints a replacement of its own.

Three lifecycle events mail. invoice.payment_failed sends the subscriber one warning per failed invoice while their subscription reads past_due, and it links that dashboard rather than a portal session, because minting a session needs a signed-in caller and an email has none. A subscription that then reaches canceled or unpaid mails once more, saying when access ended and linking a fresh checkout, because an ended subscription cannot be revived. The customer who cancelled in the portal gets that mail too, since the handler does not read who ended it. A subscription that is still trialing mails once more, three days before it converts, saying what will be charged and when. Entitlements has the window those mails sit inside.

Note

Period dates live on the subscription item, not the subscription. Read items.data[].current_period_start. Stripe moved them there in API version 2025-03-31.basil, and this boilerplate runs well past that version.

What a webhook means

A webhook is a trigger, not data. The handler takes an object id off the event, re-fetches that object from Stripe, and writes what came back. It never writes the payload it was sent.

Every database write therefore converges on the object's current state, which is why none of these cases need bookkeeping:

Delivery Result
the same event twice the second write is identical to the first
two events out of order both write current state, so the late arrival still lands correct
an event that never arrives state stays stale until the next event or a manual re-trigger

Sending an email is not a database write, so it does not converge. Sending it twice sends two emails. Each mail is claimed atomically instead, through a row in email_deliveries; see docs/adr/0007-atomic-claim-for-side-effects.md.

What this costs you

A customer with several offers receives several invoices on several dates. Combining them into one invoice is possible, but it couples the offers back together and reintroduces the proration and cancellation entanglement this shape exists to avoid.

Every webhook event costs one extra Stripe API call, because the handler re-fetches. At low volume that is free. At high volume, add a table keyed on the Stripe event id and skip ids you have already seen.

A dropped event leaves state stale, and there is no reconciliation timer. Recovery is an admin re-trigger, so in practice a support email is the trigger. Latency is unbounded and that is a deliberate trade for having no scheduler to operate.

Where the decisions live

The reasoning behind all of this, including the options that were rejected, is in the repo:

  • docs/adr/0001-clerk-for-identity-not-clerk-billing.md, why billing is hand-rolled on Stripe rather than run through Clerk
  • docs/adr/0003-independent-subscriptions-per-customer.md, one subscription per offer
  • docs/adr/0004-webhook-as-trigger.md, re-fetching instead of writing payloads
  • docs/adr/0007-atomic-claim-for-side-effects.md, exclusive claims and recovery limits

What a customer is allowed to do once they have paid is a separate question, answered in Entitlements.

Updated