Skip to content

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:

  1. Body is a JSON object, else 400 invalid_json.
  2. plan_id is a known plan, else 400 invalid_field ("Invalid plan_id.").
  3. The account's subscription row is not active, trialing or past_due, else 409 already_subscribed.
  4. The account has an inmate, else 400 inmate_required.
  5. No other account has a live (active, trialing, past_due) subscription for the same inmate_number, else 409 inmate_taken.
  6. STRIPE_SECRET_KEY and the Price secret for the plan are set, else 503 billing_not_configured.
  7. Any previous open Checkout Session is handled (Open sessions).
  8. The Checkout Session is created.

Errors ​

StatusCodeCauseWhat the console shows
401unauthorizedNo valid session cookieredirect to log in (the console's API wrapper handles every 401)
400invalid_jsonBody is not valid JSON or not a JSON objectthe API error message
400invalid_fieldplan_id is missing or not monthly/quarterly/annualthe API error message
409already_subscribedThe 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."
400inmate_requiredThe 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
409inmate_takenAnother 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
503billing_not_configuredSTRIPE_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)."
502stripe_errorStripe 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 urlthe 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:

FieldValueWhy
modesubscriptionRecurring plan
client_reference_idthe portal user idLets checkout.session.completed find the account
line_items[0][price]STRIPE_PRICE_MONTHLY, STRIPE_PRICE_QUARTERLY or STRIPE_PRICE_ANNUAL for the planThe only thing that decides the amount charged
line_items[0][quantity]1
success_url<APP_URL>/console/#billing?checkout=successSee Success and cancel URLs
cancel_url<APP_URL>/console/#billing?checkout=cancel
subscription_data[metadata][user_id]the portal user idCopied by Stripe onto the Subscription, so customer.subscription.* events can find the account
subscription_data[metadata][plan_id]the plan idFallback for plan detection
metadata[user_id], metadata[plan_id]sameOn the Checkout Session itself
customerthe stored Stripe customer idOnly when the account already has one
customer_emailthe account emailOnly 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):

URLValueConsole behaviour
success${APP_URL}/console/#billing?checkout=successNotice "Payment received. We're connecting your loved one now — this can take a few minutes."
cancel${APP_URL}/console/#billing?checkout=cancelNotice "Checkout was cancelled. You have not been charged."
portal return${APP_URL}/console/#billingnone
  • APP_URL must be the exact public origin (scheme and host, no trailing slash) the family uses, otherwise Stripe redirects them to the wrong place. The cookie's Secure flag is derived from the request URL, not from APP_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:

  1. Read users.open_checkout_session_id. If empty, skip to creating the session.
  2. 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.
  3. If the session's status is open: POST .../checkout/sessions/<id>/expire (a failure is 502 stripe_error), then clear the stored id.
  4. If the session's status is anything else (already expired or complete): clear the stored id; no expire call.
  5. 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.

StatusCodeCause
401unauthorizedNo valid session
503billing_not_configuredSTRIPE_SECRET_KEY is unset
404no_customerThe 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.")
502stripe_errorStripe 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).