Appearance
HTTP API
Everything under /api/ is handled by the Worker (src/index.ts, src/hermes.ts). Requests and responses are JSON (Content-Type: application/json; charset=utf-8) unless noted.
Conventions
Authentication kinds
| Kind | How | Endpoints |
|---|---|---|
| Public | none | POST /api/auth/signup, POST /api/auth/login, POST /api/auth/logout, GET /api/plans |
| Session | cookie ra_session (see Architecture); missing or invalid gives 401 unauthorized | /api/me, /api/inmate, /api/usage, /api/billing/* |
| Stripe signature | Stripe-Signature header | POST /api/stripe/webhook |
| Hermes bearer | Authorization: Bearer <HERMES_LINK_TOKEN> | /api/hermes/* |
The session cookie is ra_session=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000, plus Secure whenever the request URL scheme is https:. The flag follows the request, not APP_URL.
Errors
Every error has this shape:
json
{ "error": { "code": "invalid_field", "message": "Invalid first_name." } }| HTTP | code | When |
|---|---|---|
| 400 | invalid_json | Body is not valid JSON, or is not a JSON object |
| 400 | invalid_field | A field failed validation; the message names the field |
| 400 | inmate_required | Checkout without an inmate on the account |
| 400 | invalid_signature | Stripe signature missing, wrong, or older or newer than 300 seconds |
| 400 | invalid_event | Stripe event body is invalid JSON or lacks a string id or type |
| 401 | unauthorized | No valid session cookie, or no valid Hermes bearer token |
| 401 | invalid_credentials | Login with an unknown email or wrong password |
| 404 | not_found | Unknown /api/ path, or an unknown Hermes connection id |
| 404 | no_customer | Billing portal requested but the account has no Stripe customer |
| 404 | unknown_inmate | Hermes usage post for an inmate number that has no live (active, trialing, or past_due) subscription |
| 405 | method_not_allowed | Wrong HTTP method; the Allow header lists the allowed methods |
| 409 | email_taken | Signup with an email that already has an account |
| 409 | inmate_locked | Inmate number change or removal while a subscription is active, trialing, or past_due |
| 409 | already_subscribed | Checkout while the account's own subscription is active, trialing, or past_due |
| 409 | inmate_taken | Checkout for an inmate_number that another account already has under a live (active, trialing, or past_due) subscription |
| 502 | stripe_error | Stripe returned an error or no URL |
| 503 | billing_not_configured | Stripe secret, price, or webhook secret is missing |
| 500 | internal_error | Any unexpected exception |
Route checks run in a fixed order. For session and public routes the method is checked before authentication, so POST /api/me without a cookie returns 405, not 401. For /api/hermes/* the bearer token is checked first, then the method.
Monthly allowance window
Every plan's allowance (messages_per_month, 40) is per month, even for quarterly and annual plans that Stripe bills once per 3 or 12 months. Usage and allowances are therefore reported for a monthly window, computed by monthlyWindow(periodStart, periodEnd, months, now) in src/period.ts from the subscription's Stripe billing period and the plan's months (1, 3, or 12):
- A billing period has exactly
monthswindows, numbered k = 0 tomonths - 1. The anchor iscurrent_period_start. Boundary k is the anchor plus k calendar months in UTC, keeping the time of day. Each boundary is computed from the anchor (not from the previous boundary), and a day that does not exist in the target month is clamped to that month's last day. - Window k is
[boundary(k), boundary(k + 1)), except that the last window (k =months - 1) always ends atcurrent_period_end. So no extra short window is created when Stripe's period end differs from the anchor plusmonthscalendar months (for example Feb 28 to Mar 31 for a monthly plan). - k is the largest number whose boundary is not after now, capped at
months - 1. - If now is outside
[current_period_start, current_period_end), or either date is missing or invalid, there is no window:period_startandperiod_endarenullandusedis0.
period_start and period_end in GET /api/usage and GET /api/hermes/allowances are this window. billing_period_end is the Stripe period end. Usage used counts only usage_events inside the monthly window. A monthly plan has a single window, equal to its billing period.
Examples (all UTC):
| Plan | Billing period | Now | Window |
|---|---|---|---|
monthly | 2026-10-01T00:00Z to 2026-11-01T00:00Z | 2026-10-17 | 2026-10-01T00:00Z to 2026-11-01T00:00Z |
monthly | 2025-02-28T00:00Z to 2025-03-31T00:00Z | 2025-03-15 | 2025-02-28T00:00Z to 2025-03-31T00:00Z (one window for the whole period, not a 28 Feb to 28 Mar window plus a 3-day tail) |
quarterly | 2026-09-01T00:00Z to 2026-12-01T00:00Z | 2026-10-17 | 2026-10-01T00:00Z to 2026-11-01T00:00Z |
quarterly | 2025-01-31T12:30Z to 2025-04-30T12:30Z | 2025-03-01 | 2025-02-28T12:30Z to 2025-03-31T12:30Z (the 31st clamps to February's last day, then returns to the 31st) |
quarterly | 2025-01-31T00:00Z to 2025-04-30T00:00Z | 2025-04-01 | 2025-03-31T00:00Z to 2025-04-30T00:00Z (the third and last window ends at the billing end) |
quarterly | 2025-04-30T00:00Z to 2025-07-31T00:00Z | 2025-07-30 | 2025-06-30T00:00Z to 2025-07-31T00:00Z (exactly three windows: Apr 30 to May 30, May 30 to Jun 30, Jun 30 to Jul 31) |
annual | 2024-02-29T08:00Z to 2025-02-28T08:00Z | 2025-02-01 | 2025-01-29T08:00Z to 2025-02-28T08:00Z (the twelfth and last window ends at the billing end) |
Types
Plan
ts
interface Plan {
id: "monthly" | "quarterly" | "annual";
label: string; // "Monthly" | "3 months" | "12 months"
price_cents: number; // 800 | 2160 | 8160
months: number; // 1 | 3 | 12
billing: string; // "per month" | "billed once"
save_pct: number | null; // null | 10 | 15
messages_per_month: number; // 40
}Inmate
The public shape returned by the API. Internal ids and timestamps are not exposed.
ts
interface Inmate {
inmate_number: string;
first_name: string;
last_name: string;
state: string; // two uppercase letters
facility: string;
connection_status: "not_connected" | "pending" | "connected" | "failed";
connection_detail: string | null; // set by Hermes acknowledgements
}Subscription
ts
interface Subscription {
plan_id: "monthly" | "quarterly" | "annual";
status: "active" | "trialing" | "past_due" | "canceled" | "incomplete";
current_period_start: string | null; // ISO-8601 UTC
current_period_end: string | null; // ISO-8601 UTC
cancel_at_period_end: boolean;
}User
ts
interface User { id: string; email: string; name: string }Family API
POST /api/auth/signup
Public. Creates the account and starts a session.
Request:
json
{ "email": "ana@example.com", "password": "at-least-10-chars", "name": "Ana" }emailis trimmed and lowercased and must look likea@b.c.passwordmust be at least 10 characters.nameis trimmed and must not be empty.
Response 201: {"user":{"id":"...","email":"ana@example.com","name":"Ana"}} and a Set-Cookie: ra_session=... header.
Errors: 400 invalid_json, 400 invalid_field (messages "Invalid email.", "Password must be at least 10 characters.", "Invalid name."), 409 email_taken.
POST /api/auth/login
Public. Request {"email","password"} (the email is trimmed and lowercased). Response 200: {"user":{...}} with Set-Cookie. Errors: 400 invalid_json, 401 invalid_credentials.
POST /api/auth/logout
Public (no session is required). Deletes the session row for the presented cookie and clears the cookie. Response 204 with no body and a Set-Cookie that expires ra_session.
GET /api/me
Session. Response 200:
json
{
"user": { "id": "...", "email": "ana@example.com", "name": "Ana" },
"inmate": { "inmate_number": "12345", "first_name": "Jordan", "last_name": "Taylor", "state": "CA", "facility": "Facility", "connection_status": "pending", "connection_detail": null },
"subscription": { "plan_id": "monthly", "status": "active", "current_period_start": "2026-10-01T00:00:00.000Z", "current_period_end": "2026-11-01T00:00:00.000Z", "cancel_at_period_end": false },
"setup": { "inmate_added": true, "plan_chosen": true, "connected": false }
}inmateandsubscriptionarenullwhen absent.subscriptionis the account's most recently updated subscription row, whatever its status.setup.inmate_addedis true when an inmate exists;plan_chosenis true when any subscription row exists;connectedis true wheninmate.connection_statusisconnected.
GET /api/plans
Public. Response 200: {"plans":[Plan, Plan, Plan]} in the order monthly, quarterly, annual.
GET /api/inmate
Session. Response 200: {"inmate": Inmate | null}.
PUT /api/inmate
Session. Creates or updates the account's only inmate.
Request (all five fields are required non-empty strings; values are trimmed):
json
{ "inmate_number": "12345", "first_name": "Jordan", "last_name": "Taylor", "state": "ca", "facility": "Facility" }stateis uppercased and must then be exactly two lettersAtoZ.- On update, the
connection_statusis reset tonot_connected(andconnection_detailcleared) wheninmate_numberchanged.
Response 200: {"inmate": Inmate}.
Errors: 400 invalid_json; 400 invalid_field with the message Invalid <field>. for the first failing field (inmate_number, first_name, last_name, state, facility, in that order; each must be non-empty and at most 80 characters), then Invalid state. (not two letters), then Invalid inmate_number. (not 3-20 letters, digits or -); 409 inmate_locked ("The inmate number cannot change while a subscription is active.") when the account has an active, trialing or past_due subscription and inmate_number differs from the stored one. Other fields can still be edited while a subscription is active.
DELETE /api/inmate
Session. Response 204. Succeeds even if there is no inmate. Error: 409 inmate_locked while a subscription is active, trialing or past_due.
GET /api/usage
Session. Usage for the current monthly window of the account's subscription (see Monthly allowance window).
Response 200:
json
{
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z",
"billing_period_end": "2026-12-01T00:00:00.000Z",
"allowance": 40,
"used": 3,
"remaining": 37,
"events": [ { "occurred_at": "2026-10-03T09:15:00.000Z", "kind": "reply", "stamps": 1 } ]
}- The subscription considered is one with status
active,trialing, orpast_due. Without one the response isperiod_start: null,period_end: null,billing_period_end: null,allowance: 0,used: 0,remaining: 0,events: []. period_startandperiod_endare the monthly window;billing_period_endis the end of the Stripe billing period (the example is a quarterly subscription billed from2026-09-01).allowanceis the plan'smessages_per_month.eventsare theusage_eventsof the account's inmate withperiod_start <= occurred_at < period_endof the monthly window, newest first.usedis the sum of theirstampsandremainingismax(0, allowance - used). Events from earlier months of a quarterly or annual period are not counted.- If the account has no inmate,
usedis0andeventsis empty. If the period dates leave no window,period_startandperiod_endarenull,usedis0,remainingequalsallowance, andeventsis empty.
POST /api/billing/checkout
Session. Creates a Stripe Checkout Session for a plan.
Request: {"plan_id":"monthly"} (monthly, quarterly, or annual). Response 200: {"url":"https://checkout.stripe.com/..."}; the browser should navigate to it.
Errors, in the order they are checked: 400 invalid_json; 400 invalid_field ("Invalid plan_id."); 409 already_subscribed (the account's own subscription is active, trialing, or past_due); 400 inmate_required; 409 inmate_taken (a different account has a live subscription for the same inmate_number); 503 billing_not_configured (no STRIPE_SECRET_KEY or no Price secret for the plan); 502 stripe_error (Stripe rejected a request, including a failure to look up or expire the account's previously open Checkout Session). Plan changes of a live subscription go through the billing portal, not through checkout.
The id of the Checkout Session just created is stored per user (users.open_checkout_session_id). The next call first fetches that session from Stripe (GET /v1/checkout/sessions/<id>): only a session whose status is open is expired, so an account never has two payable Checkout Sessions open at once. A session that is missing at Stripe (404) or no longer open (expired or complete) is not an error: the stored id is simply cleared and a new session is created.
Details are in Stripe checkout.
POST /api/billing/portal
Session. No request body is read. Creates a Stripe Billing Portal session for the account's Stripe customer. Response 200: {"url":"..."}.
Errors: 503 billing_not_configured (no STRIPE_SECRET_KEY); 404 no_customer (no Stripe customer id on the subscription row); 502 stripe_error.
Stripe webhook
POST /api/stripe/webhook
Stripe signature, not a session. The body must be the raw event JSON exactly as Stripe sent it.
Response 200: {"received":true}, or {"received":true,"duplicate":true} when the event id was already processed.
Errors: 503 billing_not_configured (no STRIPE_WEBHOOK_SECRET), 400 invalid_signature, 400 invalid_event, and 502 stripe_error when STRIPE_SECRET_KEY is set and the Worker cannot re-fetch the subscription from Stripe or cannot cancel a duplicate subscription. A failure while archiving or handling the event releases the event id and returns the error status, so Stripe's retry is processed.
Verification, idempotency, the R2 archive, and the event-to-state mapping are described in Stripe webhooks.
Hermes link
All Hermes endpoints require Authorization: Bearer <HERMES_LINK_TOKEN> (exactly one space between the scheme and the token). The token is compared in constant time. If the secret is not configured, or the header is missing or malformed, the response is 401 unauthorized.
GET /api/hermes/connections?status=pending
Lists connections waiting for Hermes. status defaults to pending; any other value gives 400 invalid_field. Method other than GET: 405.
Response 200:
json
{
"connections": [
{
"connection_id": "uuid",
"inmate_number": "12345",
"first_name": "Jordan",
"last_name": "Taylor",
"state": "CA",
"facility": "Facility",
"plan_id": "monthly",
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z",
"billing_period_start": "2026-09-01T00:00:00.000Z",
"billing_period_end": "2026-12-01T00:00:00.000Z",
"messages_per_month": 40
}
]
}Only connections whose status is pending and whose subscription is active or trialing are returned, oldest first. period_start and period_end are the current monthly window (see Monthly allowance window), the same window GET /api/hermes/allowances reports; when there is no window (the dates are missing or now is outside the billing period) they fall back to the Stripe billing period. billing_period_start and billing_period_end are always the Stripe billing period (the quarterly or annual period for those plans).
POST /api/hermes/connections/<connection_id>/ack
Records the outcome of connecting an inmate.
Request:
json
{ "status": "connected", "detail": "optional text" }statusmust beconnectedorfailed.detailis optional; it must be a string ornullif present. It is trimmed, and an empty string is stored asnull.
Response 200: {"ok":true}. The connection row and the inmate's connection_status and connection_detail are both updated.
Errors: 400 invalid_json, 400 invalid_field ("Invalid status." or "Invalid detail."), 404 not_found (unknown connection id), 405 for non-POST.
POST /api/hermes/usage
Records one message's stamps for an inmate. Idempotent on message_id, which is an opaque key: the Hermes bridge sends <ledger_entry_id>:<message_id> (for example 1042:2739507631), or <ledger_entry_id>: for a spend with no message ids.
Request:
json
{
"inmate_number": "12345",
"message_id": "2739507631",
"kind": "reply",
"stamps": 1,
"occurred_at": "2026-10-03T09:15:00Z"
}inmate_numberandmessage_idare non-empty strings (trimmed).message_idis unique across all usage events.kindisreply,snap, orvideogram.stampsis a finite number that is>= 0(fractions allowed).occurred_atis any stringDate.parseaccepts; it is stored as ISO-8601 UTC.
Response 200: {"ok":true,"duplicate":false} for a new event, or {"ok":true,"duplicate":true} if message_id already exists (nothing is stored).
Attribution: the event is stored for the inmate row that is joined to a live (active, trialing, or past_due) subscription with that inmate_number, newest subscription first. Another account that merely entered the same number never receives the usage. If no live subscription matches, nothing is stored and the response is 404 unknown_inmate.
Errors: 400 invalid_json, 400 invalid_field (message names the field), 404 unknown_inmate, 405 for non-POST.
GET /api/hermes/allowances
Lists every active or trialing subscription that has an inmate, ordered by inmate number, so Hermes can refill monthly allowances. (past_due subscriptions are not listed; the bridge treats an inmate that disappears from this list as no longer entitled and revokes the remaining stamps.)
Response 200:
json
{
"allowances": [
{
"inmate_number": "12345",
"messages_per_month": 40,
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z",
"billing_period_end": "2026-12-01T00:00:00.000Z",
"used": 3
}
]
}period_start and period_end are the current monthly window (see Monthly allowance window) and change each month, which is the signal the bridge uses to refill; billing_period_end is the Stripe period end. used is the sum of the inmate's stamps with period_start <= occurred_at < period_end inside the monthly window, or 0 when there is no window (then period_start and period_end are null). Method other than GET: 405.
Routes that are not in this API
Any other path under /api/ returns 404 not_found. All paths outside /api/ are static pages; see Frontend.