Skip to content

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 ​

KindHowEndpoints
PublicnonePOST /api/auth/signup, POST /api/auth/login, POST /api/auth/logout, GET /api/plans
Sessioncookie ra_session (see Architecture); missing or invalid gives 401 unauthorized/api/me, /api/inmate, /api/usage, /api/billing/*
Stripe signatureStripe-Signature headerPOST /api/stripe/webhook
Hermes bearerAuthorization: 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." } }
HTTPcodeWhen
400invalid_jsonBody is not valid JSON, or is not a JSON object
400invalid_fieldA field failed validation; the message names the field
400inmate_requiredCheckout without an inmate on the account
400invalid_signatureStripe signature missing, wrong, or older or newer than 300 seconds
400invalid_eventStripe event body is invalid JSON or lacks a string id or type
401unauthorizedNo valid session cookie, or no valid Hermes bearer token
401invalid_credentialsLogin with an unknown email or wrong password
404not_foundUnknown /api/ path, or an unknown Hermes connection id
404no_customerBilling portal requested but the account has no Stripe customer
404unknown_inmateHermes usage post for an inmate number that has no live (active, trialing, or past_due) subscription
405method_not_allowedWrong HTTP method; the Allow header lists the allowed methods
409email_takenSignup with an email that already has an account
409inmate_lockedInmate number change or removal while a subscription is active, trialing, or past_due
409already_subscribedCheckout while the account's own subscription is active, trialing, or past_due
409inmate_takenCheckout for an inmate_number that another account already has under a live (active, trialing, or past_due) subscription
502stripe_errorStripe returned an error or no URL
503billing_not_configuredStripe secret, price, or webhook secret is missing
500internal_errorAny 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 months windows, numbered k = 0 to months - 1. The anchor is current_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 at current_period_end. So no extra short window is created when Stripe's period end differs from the anchor plus months calendar 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_start and period_end are null and used is 0.

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):

PlanBilling periodNowWindow
monthly2026-10-01T00:00Z to 2026-11-01T00:00Z2026-10-172026-10-01T00:00Z to 2026-11-01T00:00Z
monthly2025-02-28T00:00Z to 2025-03-31T00:00Z2025-03-152025-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)
quarterly2026-09-01T00:00Z to 2026-12-01T00:00Z2026-10-172026-10-01T00:00Z to 2026-11-01T00:00Z
quarterly2025-01-31T12:30Z to 2025-04-30T12:30Z2025-03-012025-02-28T12:30Z to 2025-03-31T12:30Z (the 31st clamps to February's last day, then returns to the 31st)
quarterly2025-01-31T00:00Z to 2025-04-30T00:00Z2025-04-012025-03-31T00:00Z to 2025-04-30T00:00Z (the third and last window ends at the billing end)
quarterly2025-04-30T00:00Z to 2025-07-31T00:00Z2025-07-302025-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)
annual2024-02-29T08:00Z to 2025-02-28T08:00Z2025-02-012025-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" }
  • email is trimmed and lowercased and must look like a@b.c.
  • password must be at least 10 characters.
  • name is 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 }
}
  • inmate and subscription are null when absent. subscription is the account's most recently updated subscription row, whatever its status.
  • setup.inmate_added is true when an inmate exists; plan_chosen is true when any subscription row exists; connected is true when inmate.connection_status is connected.

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" }
  • state is uppercased and must then be exactly two letters A to Z.
  • On update, the connection_status is reset to not_connected (and connection_detail cleared) when inmate_number changed.

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, or past_due. Without one the response is period_start: null, period_end: null, billing_period_end: null, allowance: 0, used: 0, remaining: 0, events: [].
  • period_start and period_end are the monthly window; billing_period_end is the end of the Stripe billing period (the example is a quarterly subscription billed from 2026-09-01).
  • allowance is the plan's messages_per_month.
  • events are the usage_events of the account's inmate with period_start <= occurred_at < period_end of the monthly window, newest first. used is the sum of their stamps and remaining is max(0, allowance - used). Events from earlier months of a quarterly or annual period are not counted.
  • If the account has no inmate, used is 0 and events is empty. If the period dates leave no window, period_start and period_end are null, used is 0, remaining equals allowance, and events is 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.

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" }
  • status must be connected or failed.
  • detail is optional; it must be a string or null if present. It is trimmed, and an empty string is stored as null.

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_number and message_id are non-empty strings (trimmed). message_id is unique across all usage events.
  • kind is reply, snap, or videogram.
  • stamps is a finite number that is >= 0 (fractions allowed).
  • occurred_at is any string Date.parse accepts; 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.