Skip to content

Stripe billing ​

Stripe is how a family pays for Reform Assistant. This section is the complete reference for that integration: how to set up a Stripe account, what the Worker sends to Stripe, what it does with Stripe's events, how to test it, and how to go live.

Feature IDs used here are the shared ones (see the product feature map): F09 Checkout, F10 Billing portal, F11 Stripe webhook sync, F17 deployment secrets.

Status of the integration

  • Code complete. src/billing.ts (F09, F10), src/stripe_webhook.ts (F11) and src/plans.ts are implemented, with the webhook path covered by test/stripe_webhook.test.ts.
  • The sandbox Stripe account has not been created yet.
  • Nothing has been tested against real Stripe. The test suite simulates signed webhook events only; it never calls api.stripe.com (the test STRIPE_SECRET_KEY is empty). The outgoing calls in src/billing.ts (Checkout and Billing Portal sessions) and the Stripe re-fetch and cancellation calls in src/stripe_webhook.ts have never been executed against Stripe. Treat every page of this section as "implemented, unverified against Stripe" until the manual QA in Testing has been run in a sandbox.

What Stripe does here ​

JobStripe objectWhere in the code
Collect the first payment and start the subscriptionCheckout Session, mode=subscriptioncreateCheckout in src/billing.ts (Checkout)
Let the family change plan, update the card, or cancelBilling Portal sessioncreatePortal in src/billing.ts (Checkout)
Tell the portal that a subscription started, renewed, changed or endedWebhook eventsreceiveStripeWebhook in src/stripe_webhook.ts (Webhooks)
Recurring charges, retries and receiptsSubscription and PriceStripe itself; the portal never charges anything

The Worker does not use a Stripe SDK. It calls https://api.stripe.com/v1/ with fetch and form-encoded bodies, authenticated with Authorization: Bearer <STRIPE_SECRET_KEY>, and verifies webhook signatures itself with Web Crypto. The portal stores no card data. Stripe owns the money; the portal's D1 database mirrors only the subscription state it needs (status, plan, period, customer and subscription ids).

Flow ​

text
 family
   |  signs up, adds the inmate (#inmate)
   v
 /console  #billing  --- "Choose" on a plan card ---+
                                                    |
        POST /api/billing/checkout {plan_id}        |   (F09)
   <------------------------------------------------+
   |   Worker: validates, expires any older open session,
   |   POST https://api.stripe.com/v1/checkout/sessions
   v   -> {url}
 browser is redirected to Stripe Checkout (hosted by Stripe)
   |   family pays
   v
 Stripe  --- redirect --->  APP_URL/console/#billing?checkout=success
   |
   |   webhook events (signed)                         (F11)
   v
 POST /api/stripe/webhook
   |   verify signature -> reserve event id (D1 stripe_events)
   |   -> archive raw event (R2 stripe/events/<id>.json)
   |   -> re-fetch subscription from Stripe -> upsert subscriptions row
   v
 D1: subscriptions.status = active|trialing
     connections.status   = pending      inmates.connection_status = pending
   |
   v
 Hermes bridge (cron) polls GET /api/hermes/connections?status=pending
   |   adds the Securus contact, then POST .../ack {status: connected|failed}
   v
 inmate is connected; replies start (usage reported back to the portal)

Everything left of the webhook is initiated by the family's browser. Everything from the webhook down is driven only by Stripe's signed events: the redirect to ?checkout=success does not change any state, and the console only shows a notice for it.

Configuration at a glance ​

NameKindValuePage
APP_URLvar (wrangler.jsonc, default http://localhost:8787)Public origin of the portal; builds the Checkout success and cancel URLs and the portal return URLCheckout
STRIPE_SECRET_KEYsecretsk_test_... (sandbox) or sk_live_...Setup
STRIPE_PRICE_MONTHLYsecretprice_... for monthly ($8.00, every 1 month)Setup
STRIPE_PRICE_QUARTERLYsecretprice_... for quarterly ($21.60, every 3 months)same
STRIPE_PRICE_ANNUALsecretprice_... for annual ($81.60, every 12 months)same
STRIPE_WEBHOOK_SECRETsecretwhsec_... of the webhook endpoint (or of stripe listen)Setup

If these are missing the rest of the portal still works and billing answers 503 billing_not_configured (see Checkout).

Pages ​

PageRead it when you want to
SetupCreate a Stripe account from scratch and wire the Worker to it (sandbox)
CheckoutKnow exactly what POST /api/billing/checkout and /api/billing/portal do and which error codes they return
WebhooksUnderstand signature checking, idempotency, event-to-state mapping, duplicates and retries
TestingTest with no account (signed-event simulation, npm run e2e) or with a sandbox account (stripe listen, test cards), and run the manual QA checklist
Going liveSwitch from sandbox to live keys and prices, and handle refunds for duplicate subscriptions

Related pages outside this section: Cloudflare deployment (how secrets are set on the Worker), Hermes bridge (what happens after pending), API reference, Runbook.

Plans ​

The plan catalog lives in src/plans.ts and is served by GET /api/plans. Stripe's Prices must match it exactly, because Stripe, not the portal, decides what is charged.

Plan idLabelAmountStripe billing periodMessages per month
monthlyMonthly$8.00every 1 month40
quarterly3 months$21.60 (10% off)every 3 months40
annual12 months$81.60 (15% off)every 12 months40

The allowance is per month for every plan: a quarterly or annual subscription is billed once but the 40-message allowance renews each calendar month of the billing period (src/period.ts, feature F12).

Terminology note ​

Dashboard menu names, button labels and Stripe CLI flags mentioned in this section that are not visible in this repository come from general Stripe knowledge and may differ from what the Dashboard currently shows. They are marked (general Stripe knowledge) where they appear. Everything else on these pages was read from the code.