Appearance
Checkout and billing portal
This page documents the two Worker endpoints that talk to Stripe on behalf of a signed-in family, both in src/billing.ts:
POST /api/billing/checkout— start a Stripe Checkout payment for a plan (feature F09).POST /api/billing/portal— open the Stripe Billing Portal (feature F10).
Both require the ra_session cookie (missing or expired: 401 unauthorized). Errors use the usual shape {"error":{"code":"...","message":"..."}}.
Not yet verified against Stripe
The code is complete but has not been run against Stripe (no sandbox account exists yet). Every outgoing call below is implemented from the Stripe REST conventions and has only been read, not observed. See the status on the section overview.
POST /api/billing/checkout
Request
http
POST /api/billing/checkout
Content-Type: application/json
Cookie: ra_session=...
{"plan_id": "quarterly"}plan_id must be one of the ids in src/plans.ts: monthly, quarterly, annual.
Response
json
{"url": "https://checkout.stripe.com/c/pay/cs_test_..."}The console redirects the browser to url. Nothing else is returned. The state of the account does not change until Stripe's webhook arrives (Webhooks); the only thing written by the endpoint itself is the id of the new Checkout Session (see Open sessions).
Order of checks
The checks run in this order; the first failure wins:
- Body is a JSON object, else
400 invalid_json. plan_idis a known plan, else400 invalid_field("Invalid plan_id.").- The account's subscription row is not
active,trialingorpast_due, else409 already_subscribed. - The account has an inmate, else
400 inmate_required. - No other account has a live (
active,trialing,past_due) subscription for the sameinmate_number, else409 inmate_taken. STRIPE_SECRET_KEYand the Price secret for the plan are set, else503 billing_not_configured.- Any previous open Checkout Session is handled (Open sessions).
- The Checkout Session is created.
Errors
| Status | Code | Cause | What the console shows |
|---|---|---|---|
| 401 | unauthorized | No valid session cookie | redirect to log in (the console's API wrapper handles every 401) |
| 400 | invalid_json | Body is not valid JSON or not a JSON object | the API error message |
| 400 | invalid_field | plan_id is missing or not monthly/quarterly/annual | the API error message |
| 409 | already_subscribed | The account's subscription is active, trialing or past_due. Plan changes then go through the billing portal | "You already have an active plan. Use Manage billing to change it." |
| 400 | inmate_required | The account has no inmate. A plan is bought for an inmate, so one must exist first | "Add your inmate before choosing a plan." with a link to #inmate |
| 409 | inmate_taken | Another account has a live subscription for the same inmate_number. The same rule runs again in the webhook to close the race (Webhooks) | "This inmate already has an active plan on another account." with a support link |
| 503 | billing_not_configured | STRIPE_SECRET_KEY is unset, or the STRIPE_PRICE_* secret for the chosen plan is unset (this check comes after the four above) | "Payments are not switched on yet (sandbox setup pending)." |
| 502 | stripe_error | Stripe answered non-2xx or with an unusable body when expiring the old session, retrieving it, or creating the new one; or the created session had no id or url | the API error message |
A subscription row in status canceled or incomplete does not block checkout: the account can buy again, and the existing Stripe customer is reused (Customer reuse).
The Stripe request
The Worker sends POST https://api.stripe.com/v1/checkout/sessions with Authorization: Bearer <STRIPE_SECRET_KEY> and a form-encoded body:
| Field | Value | Why |
|---|---|---|
mode | subscription | Recurring plan |
client_reference_id | the portal user id | Lets checkout.session.completed find the account |
line_items[0][price] | STRIPE_PRICE_MONTHLY, STRIPE_PRICE_QUARTERLY or STRIPE_PRICE_ANNUAL for the plan | The only thing that decides the amount charged |
line_items[0][quantity] | 1 | |
success_url | <APP_URL>/console/#billing?checkout=success | See Success and cancel URLs |
cancel_url | <APP_URL>/console/#billing?checkout=cancel | |
subscription_data[metadata][user_id] | the portal user id | Copied by Stripe onto the Subscription, so customer.subscription.* events can find the account |
subscription_data[metadata][plan_id] | the plan id | Fallback for plan detection |
metadata[user_id], metadata[plan_id] | same | On the Checkout Session itself |
customer | the stored Stripe customer id | Only when the account already has one |
customer_email | the account email | Only when there is no stored customer id; pre-fills Checkout |
Customer reuse
If the account's subscriptions row has a stripe_customer_id (from an earlier checkout, a canceled subscription, or an incomplete row written by checkout.session.completed), the Worker sends customer=<id>. Otherwise it sends customer_email and Stripe creates a new customer when the payment succeeds. The customer id is saved on the account by the webhook, not by this endpoint. Reusing the customer keeps invoices, payment methods and the portal history together for a returning family.
Plan id from price
The plan is chosen in the portal (plan_id in the request), mapped to a Price secret by priceForPlan, and sent to Stripe as a Price id. The reverse mapping, used when Stripe's events come back, compares the Price id of the subscription's first item with the three STRIPE_PRICE_* secrets and falls back to metadata.plan_id (Webhooks). Consequence: the three secrets must be set to three distinct Prices, and a plan switched in the billing portal is recognised from the new Price even if the metadata is stale.
Success and cancel URLs
Both URLs are built from the APP_URL var (wrangler.jsonc, default http://localhost:8787):
| URL | Value | Console behaviour |
|---|---|---|
| success | ${APP_URL}/console/#billing?checkout=success | Notice "Payment received. We're connecting your loved one now — this can take a few minutes." |
| cancel | ${APP_URL}/console/#billing?checkout=cancel | Notice "Checkout was cancelled. You have not been charged." |
| portal return | ${APP_URL}/console/#billing | none |
APP_URLmust be the exact public origin (scheme and host, no trailing slash) the family uses, otherwise Stripe redirects them to the wrong place. The cookie'sSecureflag is derived from the request URL, not fromAPP_URL.- The success redirect is cosmetic: reaching it proves nothing. The portal state only changes through the signed webhook, so the console may show "Payment received" a few seconds before the inmate's status moves to
pending.
Open-session expiry
A Checkout Session stays payable at Stripe until it expires or completes. To prevent a family from holding two payable sessions at once (for example two browser tabs, which could lead to two subscriptions), the Worker remembers the last session id per user in users.open_checkout_session_id (migration 0002_checkout_sessions.sql) and handles it before creating a new one:
- Read
users.open_checkout_session_id. If empty, skip to creating the session. GET https://api.stripe.com/v1/checkout/sessions/<id>.- HTTP 404, or a Stripe error with code
resource_missing: the session is gone; clear the stored id. - Any other non-2xx, or an invalid body:
502 stripe_error.
- HTTP 404, or a Stripe error with code
- If the session's
statusisopen:POST .../checkout/sessions/<id>/expire(a failure is502 stripe_error), then clear the stored id. - If the session's
statusis anything else (alreadyexpiredorcomplete): clear the stored id; no expire call. - Create the new session and store its id.
The stored id is also cleared by the webhook events checkout.session.completed and checkout.session.expired (only when the event's session id equals the stored one), so those two events must be enabled on the endpoint (Setup). The clearing in step 2-4 matches on the session's status, never on Stripe's error text.
Failure modes worth knowing: if the expire call succeeds but creating the new session then fails, the old session is already expired and the stored id was cleared, so the family just retries. If creation succeeds but the UPDATE users write fails, the new session exists without a stored id; it is then not expired by the next checkout (a rare path, covered in the duplicate handling in Webhooks).
Billing portal
POST /api/billing/portal (no body) opens Stripe's hosted Billing Portal, where the family can switch plan, update the card and cancel (depending on the configuration saved in Setup step 5).
json
{"url": "https://billing.stripe.com/p/session/test_..."}The Worker sends POST https://api.stripe.com/v1/billing_portal/sessions with customer=<stripe_customer_id of the account's subscription row> and return_url=<APP_URL>/console/#billing.
| Status | Code | Cause |
|---|---|---|
| 401 | unauthorized | No valid session |
| 503 | billing_not_configured | STRIPE_SECRET_KEY is unset |
| 404 | no_customer | The account has no subscription row, or its row has no Stripe customer id: the family never completed a checkout (console: "No billing account yet. Choose a plan first.") |
| 502 | stripe_error | Stripe returned non-2xx (including, as general Stripe knowledge, no saved portal configuration in this mode), or the response had no url |
Everything the family does in the portal comes back as webhook events; the portal itself never writes a plan or status from this endpoint (Webhooks).