# Rivendell API Documentation — Full Agent Reference > Generated from the committed OpenAPI contract > OpenAPI version: 0.1.0 > Base URL: https://rivendell.health/api > Operations: 126 across 30 tags This file is auto-generated by `scripts/generate-llms-full.ts`. Do not edit by hand — rerun `bun run openapi:generate` to refresh it. The companion short index is at `/llms.txt`. --- ## Index (from llms.txt) # Rivendell > Rivendell One is HSA-qualified, open-access health insurance for startups. The plan is live and binding. People: start with the plan facts and site links below. Agents: read `/plan.json` for plan facts and readiness, then `/api/capabilities` for the API contract. ## Plan facts (Rivendell One) - Plan status: live and binding. Actuarially certified, carrier approved. - Plan type: HSA-qualified, open-access, level-funded. No network or referrals. - Deductible: $1,700 individual / $3,400 family (the 2026 statutory minimum). Employers fund 100% into each employee's HSA by default, so the net deductible is $0 and deposited money belongs to the employee. - Out-of-pocket max: $5,000 individual / $10,000 family. Net of default HSA funding: $3,300 / $6,600. - Coinsurance after the deductible: fixed 20% for covered care. Care navigation does not change the member percentage. - Always $0: Virtual primary care through One Medical, 24/7; Telehealth and virtual urgent care, 24/7; Virtual therapy and psychiatry sessions; Preventive care; Everyday chronic meds: SSRIs, statins, insulin, inhalers. - Coverage: all 50 states, any licensed provider. A California company location is required to sign up. - Hold-harmless: members never pay above the out-of-pocket max, with any provider, in any state. This is in the plan contract. - Insurance: from $757.33 per member per month. - HSA contribution: $141.67 per employee per month by default; this is employee-owned money, not insurance premium. - Default core employer outlay: $899 per employee per month (about $900), before optional add-ons. - Founding cohort: $657.33 insurance plus $141.67 HSA, for $799 per employee per month. A $100 per employee per month credit against the standard rate card, for the first 10 groups, applied in the first plan year. Renewals are rated on the group's own experience. Founding pricing is set by Rivendell, in the binding quote. - Pricing basis: California baseline. Rates vary by employee location; final terms are set in the binding quote. San Francisco is the pricing anchor (factor 1.00); other metros price lower (for example New York City 0.98, unlisted locations 0.86), applied per employee to the risk portion of the rate. Coverage tiers: spouse 2x, child 1.8x, family 3x the employee-only rate. - Rate basis: the 2026 rate card prices coverage start dates through 2026-12-31; later start dates use the next rate card. - Add-ons per member per month: Dental & Vision $100 (self-funded, works exactly like the base plan, same card; not part of the base plan). Carrot fertility $12.50 (pass-through). Wellness pack (Function Health labs 2x/year + wearable) $30. Lifestyle perk $100 via payroll. - Group size: minimum 15 enrolling employees, firm. Every group is underwritten. Smaller teams are a better fit for an ICHRA or a marketplace plan. - Administrator: Elrond Health Inc, company NPN 22113786. Licensed as CA third-party administrator + producer #6018541; NY producer #1970717; DE producer #3004061616. - Behind the plan: Stop-loss is carried by a licensed carrier, named in your binding quote. Claims run-out protection, and 100% of claims-fund surplus returned to the employer. - Renewals: rated on the group's own experience. Rivendell does not promise a group renewal-rate cap. - Machine-readable version: https://rivendell.health/plan.json ## Site & Content - [Rivendell](https://rivendell.health/): the Rivendell One plan, with insurance and employee-owned HSA funding shown separately. - [About](https://rivendell.health/about): Heiki Riesenkampf's background, why he built Rivendell, where the name comes from, and the licensing behind the plan. [LinkedIn](https://www.linkedin.com/in/heikir/) · [X](https://twitter.com/heikirk). - [Plan facts](https://rivendell.health/plan): every plan design number and its readiness state on one page. Machine version at [/plan.json](https://rivendell.health/plan.json). - [How it works & FAQ](https://rivendell.health/faq): coverage, the funded HSA, concierge, joining, ongoing conditions, renewals, and how Rivendell compares to PEOs. - [For brokers & payroll partners](https://rivendell.health/broker): evaluate the plan design, submit a census, and review the disclosed funding ledger and broker FAQ. - [Blog](https://rivendell.health/blog): guides and analysis on startup health insurance. - [Why your tech startup pays 30% more for health insurance](https://rivendell.health/blog/why-your-tech-startup-pays-way-more-for-health-insurance) - [How to choose a health insurance plan for your startup](https://rivendell.health/blog/how-to-choose-health-insurance) - [The Health 401(k): a free-market cure for American healthcare](https://rivendell.health/blog/the-health-401k) - [Your health plan is designed to make you avoid care](https://rivendell.health/blog/health-plan-designed-to-avoid-care) - [The best tax hack in the US requires the worst health insurance](https://rivendell.health/blog/the-best-tax-hack-in-the-us-requires) - [Five healthcare predictions for 2026](https://rivendell.health/blog/five-healthcare-predictions-for-2026) ## Discovery - [Plan facts JSON](https://rivendell.health/plan.json): machine-readable plan summary; read this to evaluate the insurance product. - [Capabilities](https://rivendell.health/api/capabilities): machine-readable API capability descriptor; read this first for API work. - [OpenAPI spec](https://rivendell.health/openapi.json): full REST schema. - [Short LLM index](https://rivendell.health/llms.txt): this file. - [Full LLM reference](https://rivendell.health/llms-full.txt): every endpoint, generated from the OpenAPI spec. - [MCP server](https://rivendell.health/mcp): Model Context Protocol endpoint. - [REST API base](https://rivendell.health/api): base path for REST calls. ## Authentication - Production REST: `Authorization: Bearer ` or a Rivendell session cookie. - MCP: OAuth or the same authenticated Rivendell bearer/session context exposed by the connector. - Sandbox REST: `Authorization: Bearer sk_sandbox_PUBLIC` under `/api/sandbox/**` only. The sandbox key is intentionally public and is rejected everywhere else. - Developer CLI / SDK: long-lived org-scoped Personal Access Tokens (`riv_pat_<43 chars>`) created at `/developers/api-tokens` or via `rivendell login`. Tokens carry server-defined scopes (`org:read`, `members:read`, `members:write`, `claims:read`, `claims:write`, `webhooks:read`, `webhooks:write`, `wearables:read`) and are tenant-scoped to the organization that minted them. Send as `Authorization: Bearer riv_pat_…`. ## MCP Connector Rivendell runs a remote MCP server at `https://rivendell.health/mcp`. Connected, an assistant can read the member's plan and balances, claims, medications, provider options and appointments, and their own Oura and WHOOP data. ```bash claude mcp add --transport http rivendell https://rivendell.health/mcp ``` Claude desktop and web, ChatGPT, Cursor and other MCP clients take the same URL as a custom connector. Auth is OAuth 2.1 with PKCE against the member's existing Rivendell account, plus dynamic client registration: there is no key to copy. Scopes, approved once in the browser: `plan:read`, `account:read`, `account:write`, `claims:read`, `claims:write`, `medications:read`, `care:read`, `appointments:read`, `appointments:write`, `pricing:quote`, `employer:quote`, `wearables:read`. `wearables:read` is outside the default grant and must be requested explicitly, because consent is all-or-nothing per registration. Full install steps, the tool list with what each tool is for, and example prompts: [/docs/mcp/connectors](https://rivendell.health/docs/mcp/connectors), or as raw markdown at [/docs/mcp.md](https://rivendell.health/docs/mcp.md). Wearables setup for members: [/wearables](https://rivendell.health/wearables). ## Developer CLI `rivendell` is the official CLI for AI coding agents and developer integrations. Install: ```bash brew install rivendell-health/cli/rivendell # or curl -fsSL https://rivendell.health/cli/install.sh | sh export RIVENDELL_TOKEN=riv_pat_… rivendell whoami ``` Signing in mints an org-scoped token, and only an organization's owners and admins may mint one. A member who is not an admin should use the MCP connector instead; it authenticates them as themselves and reaches the same wearable data. `rivendell wearables` reads the caller's own devices: `status`, `connect`, `sleep`, `recovery`, `activity`, `workouts`, each taking `--days` or `--start-date`/`--end-date`, and `--provider oura|whoop`. The CLI ships an `api` raw escape hatch matching `gh api`: ```bash rivendell api GET /api/cli/whoami rivendell api POST /api/cli/foo --data '{"x":1}' ``` For real-time API call audit, `rivendell logs tail` streams events for the active organization via SSE (filters: `--surface`, `--status`, `--since`; auto-reconnects with `Last-Event-Id`; bodies never leave the server). Same auth contract as the REST API. Auto-detects TTY for output: pretty JSON when interactive, raw bytes when piped. Exit codes: 0 success, 3 reauth required, 4 other 4xx, 5 5xx. Raw markdown for this section: [/docs/cli.md](https://rivendell.health/docs/cli.md). Both documents together: [/docs/agents.md](https://rivendell.health/docs/agents.md). ## IDs Rivendell-owned IDs are prefixed typed IDs: `^{prefix}_[A-Za-z0-9]{24,32}$`. New IDs use body `[a-z2-7]{24}`. Provider-owned IDs such as Stripe, Increase, Google, OAuth, device, and external IDs keep their native format. Malformed owned IDs return `400 validation_error` with `recovery_hint: CHECK_INPUT`. ## Lists List endpoints use one envelope: `object="list"`, `data=[...]`, `has_more`, `url`, and optional `next_cursor`. ## Errors Errors use an RFC 9457-style JSON envelope with `type`, `title`, string `status`, nullable `detail`, optional `param`, optional `code`, optional `doc_url`, and `recovery_hint`. ## Idempotency Send `Idempotency-Key: ` on POST, PATCH, DELETE JSON mutations. Matching retries replay for 24 hours and include `Idempotent-Replayed: true`. Same key with changed method, path, query, or body returns `409 idempotency_key_already_used_error`. Do not use this header for multipart, form, or binary uploads. Sandbox idempotency is public and globally scoped, so generate a fresh key for each new sandbox mutation to avoid collisions with other agents. ## Timestamps Canonical resource base field `created` is Unix seconds. Some legacy fields such as `created_at`, `updated_at`, `service_date`, or route-specific compatibility fields may still be ISO 8601 strings when documented by that operation. ## Resources - claims: Reimbursement and card-transaction claim status, substantiation, and adjudication workflows. Endpoints: /api/claims, /api/claims/{id}, /api/claims/reimbursement. Sandbox: /api/sandbox/claims. - benefits: Current plan year deductible, out-of-pocket, stipend, and coverage information. Endpoints: /api/plan, /api/benefits/assistant. Sandbox: /api/sandbox/benefits. - cards: Member card listing, card details, card status, Apple Pay provisioning, and card activity. Endpoints: /api/cards, /api/cards/{cardId}, /api/cards/{cardId}/status, /api/card-activity. - medications: Medication catalog search with Cost Plus Drugs handoff information. Endpoints: /api/medications/search. Sandbox: /api/sandbox/medications/search. - providers: Provider search, care options, cash-pay estimates, and provider details. Endpoints: /api/doctors/search, /api/doctors/suggest. - appointments: Appointment request creation, listing, cancellation, and booking workflow status. Endpoints: /api/doctors/appointments, /api/doctors/schedule. - wearables: The caller's own Oura and WHOOP data, normalized to shared units: nightly sleep, daily recovery and readiness, daily activity and training load, individual workouts, body metrics, and which devices are linked. Read-only, member-scoped, and gated on the wearables:read scope. Endpoints: /api/wearables/connections, /api/wearables/sleep, /api/wearables/recovery, /api/wearables/activity, /api/wearables/workouts, /api/wearables/profile. ## Safe Sandbox Claim Example ```http POST /api/sandbox/claims Authorization: Bearer sk_sandbox_PUBLIC Content-Type: application/json Idempotency-Key: 7d6f6dc3-6d91-4c1a-b332-4e94f0c74b8a { "member_id": "mem_test_001", "service_category": "VISION", "submitted_amount": 4250, "service_date": "2026-04-01", "merchant_name": "Sandbox Vision", "description": "Eye exam simulation", "metadata": { "source": "agent_smoke_test" } } ``` Expected success: `201`, `object: "claim"`, `livemode: false`, and an ID beginning `claim_`. Retry the identical request with the same idempotency key to observe replay. Retry with the same key and changed amount to observe `409 idempotency_key_already_used_error`. ## Smoke Test Prompt Read `/llms.txt`, discover sandbox auth, create a sandbox claim with an `Idempotency-Key`, retry the same request and explain replay, then retry the same key with a changed amount and explain `idempotency_key_already_used_error`. --- ## Authentication All `/api` operations marked `(auth required)` accept either: - **Bearer token** (mobile/native): `Authorization: Bearer ` — obtained from Better Auth on sign-in and captured from the `set-auth-token` response header on the auth endpoints. - **Session cookie** (web): standard Better Auth session cookie issued by `/api/auth/*`. Do not call Better Auth internal routes from mobile — use the documented wrappers under `/api` instead. Unauthorized requests receive `401`. Authenticated requests without the required organization context receive `403`. ## Conventions - Canonical resource `created` timestamps are Unix seconds. Legacy compatibility timestamp fields may still be ISO 8601 strings when documented by an operation; Zod coerces accepted request timestamps to `Date` on the server. - Money values are integer cents unless explicitly marked otherwise. - Enum fields use explicit string literals; prefer those over the underlying Prisma enum values when calling the API. - Errors use the RFC 9457-style envelope documented below in the API author's guide. - Mutating `POST`, `PATCH`, and `DELETE` operations accept an optional `Idempotency-Key` header. Use a fresh opaque key for each new mutation and reuse it only for retries of that same request. Matching retries replay the cached response for 24 hours; same-key/different-parameters returns `409 idempotency_key_already_used_error`. ## Operations by Tag ### account - `POST /account/deletion` (auth required) — Initiate account deletion. - responses: 202, 400, 401, 403, 409, 500 - `GET /sign-in-email` (auth required) — Get sign-in email status. - responses: 200, 400, 401, 403, 500 ### accounts - `POST /accounts` (auth required) — Create a new Increase account for the user's active organization. - responses: 201, 400, 401, 403, 409, 500 ### admin - `POST /admin/claims/{id}/adjudicate` (auth required) — POST /api/admin/claims/[id]/adjudicate - path params: id - body: application/json (AdjudicateClaimRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/invoice` (auth required) — POST /api/admin/claims/[id]/invoice - path params: id - body: application/json (AdminClaimInvoiceRequestSchema) - responses: 202, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/preview-coverage` (auth required) — POST /api/admin/claims/[id]/preview-coverage - path params: id - body: application/json (PreviewCoverageRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/readjudicate` (auth required) — POST /api/admin/claims/[id]/readjudicate - path params: id - body: application/json (ReadjudicateClaimRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/receipt` (auth required) — POST /api/admin/claims/[id]/receipt - path params: id - body: application/json (AdminClaimReceiptRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/resubstantiate` (auth required) — POST /api/admin/claims/[id]/resubstantiate - path params: id - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/update-category` (auth required) — POST /api/admin/claims/[id]/update-category - path params: id - body: application/json (UpdateCategoryRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/resubstantiate` (auth required) — POST /api/admin/claims/resubstantiate - body: application/json (ResubstantiateOrgRequestSchema) - responses: 200, 400, 409, 500 - `POST /admin/clinical-gates` (auth required) — POST /api/admin/clinical-gates - body: application/json (CreateClinicalGateRequestSchema) - responses: 201, 400, 401, 403, 409, 500 - `POST /admin/clinical-gates/{id}/end` (auth required) — POST /api/admin/clinical-gates/[id]/end - path params: id - body: application/json (EndClinicalGateRequestSchema) - responses: 200, 400, 401, 403, 409, 500 - `GET /admin/merchant-rules` (auth required) — GET /api/admin/merchant-rules - responses: 200, 400, 500 - `POST /admin/merchant-rules` (auth required) — POST /api/admin/merchant-rules - body: application/json (CreateMerchantRuleSchema) - responses: 201, 400, 409, 500 - `DELETE /admin/merchant-rules/{id}` (auth required) — DELETE /api/admin/merchant-rules/[id] - path params: id - responses: 200, 400, 409, 500 - `PATCH /admin/merchant-rules/{id}` (auth required) — PATCH /api/admin/merchant-rules/[id] - path params: id - body: application/json (UpdateMerchantRuleSchema) - responses: 200, 400, 409, 500 - `POST /admin/pre-auths` (auth required) — POST /api/admin/pre-auths - body: application/json (CreatePreAuthorizationRequestSchema) - responses: 201, 400, 401, 403, 409, 500 - `POST /admin/pre-auths/{id}/approve` (auth required) — POST /api/admin/pre-auths/[id]/approve - path params: id - body: application/json (ApprovePreAuthorizationRequestSchema) - responses: 200, 400, 401, 403, 409, 500 - `POST /admin/pre-auths/{id}/cancel` (auth required) — POST /api/admin/pre-auths/[id]/cancel - path params: id - responses: 200, 400, 401, 403, 409, 500 - `POST /admin/pre-auths/{id}/reject` (auth required) — POST /api/admin/pre-auths/[id]/reject - path params: id - responses: 200, 400, 401, 403, 409, 500 ### assistant - `GET /assistant/threads` (auth required) — GET /api/assistant/threads - query params: limit?, offset? - responses: 200, 400, 401, 403, 500 - `GET /assistant/threads/{threadId}` (auth required) — GET /api/assistant/threads/[threadId] - path params: threadId - responses: 200, 400, 401, 403, 404, 500 ### banking - `GET /banking/accounts` (auth required) — GET /api/banking/accounts - responses: 200, 400, 401, 403, 500 - `POST /banking/accounts` (auth required) — POST /api/banking/accounts - body: application/json (ManualBankAccountCreateSchema) - responses: 200, 400, 401, 403, 409, 500 - `DELETE /banking/accounts/{bankAccountId}` (auth required) — DELETE /api/banking/accounts/[bankAccountId] - path params: bankAccountId - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /banking/link` (auth required) — POST /api/banking/link - body: application/json (LinkBankAccountRequestSchema) - responses: 200, 400, 401, 403, 409, 500 - `POST /banking/link-token` (auth required) — POST /api/banking/link-token - body: application/json (BankLinkTokenRequestSchema) - responses: 200, 400, 401, 403, 409, 500 - `POST /banking/set-primary` (auth required) — POST /api/banking/set-primary - body: application/json (SetPrimaryBankAccountRequestSchema) - responses: 200, 400, 401, 403, 409, 500 ### benefits - `POST /benefits/assistant` (public) — Public benefits chatbot for the shared Rivendell benefits explainer. - body: application/json (BenefitsAssistantRequestSchema) - responses: 200, 400, 409, 500 - `GET /sandbox/benefits` (auth required) — GET /api/sandbox/benefits - responses: 200, 400, 500 - response examples: 200:get_sandbox_benefits={"object":"benefits_summary","livemode":false,"created":1775001600,"plan":{"object":"health_plan","id":"health_plan_aaaaaaaaaaaaaaaaaaaaaaaa","livemode":false,"name":"Sandbox PPO"},"balances":{"deductible":{"used":{"cents":125000,"currency":"USD"},"limit":{"cents":500000,"currency":"USD"},"remaining":{"cents":375000,"currency":"USD"}}}} ### capabilities - `GET /capabilities` (public) — GET /api/capabilities - responses: 200, 400, 500 ### cards - `GET /card-activity` (auth required) — GET /api/card-activity - query params: cursor?, limit?, decision? - responses: 200, 400, 401, 403, 500 - response examples: 200:list_card_activity={"object":"list","data":[{"object":"transaction","id":"transaction_aaaaaaaaaaaaaaaaaaaaaaaa","created":1775001600,"livemode":true,"metadata":{},"merchant_name":"Market Street Pharmacy","authorized_amount":3200,"status":"SETTLED"}],"has_more":false,"url":"/api/card-activity"} - `GET /cards` (auth required) — List all cards for the authenticated user. - query params: status? - responses: 200, 400, 401, 403, 500 - `POST /cards` (auth required) — Create a new card for the authenticated user. - body: application/json (CreateCardRequestSchema) - responses: 201, 400, 401, 403, 409, 500 - `GET /cards/{cardId}` (auth required) — Fetch card details. - path params: cardId - responses: 200, 400, 401, 403, 404, 500 - `GET /cards/{cardId}/details` (auth required) — Fetch sensitive card details. - path params: cardId - responses: 200, 400, 401, 403, 404, 500 - `POST /cards/{cardId}/push-provision` (auth required) — Push-provision a card for Apple Pay (Add to Apple Wallet). - path params: cardId - body: application/json (PushProvisionRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `PATCH /cards/{cardId}/status` (auth required) — Update card status (freeze/unfreeze). - path params: cardId - body: application/json (CardStatusUpdateRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /cards/{cardId}/step-up` (auth required) — Mint a step-up grant for revealing sensitive card details. - path params: cardId - responses: 201, 400, 401, 403, 404, 409, 429, 500 - `POST /cards/hsa` (auth required) — Issue or retrieve the authenticated member's HSA card. - responses: 200, 400, 401, 403, 409, 500 - `GET /cards/hsa/transactions` (auth required) — Compatibility alias for the member HSA transaction statement. - query params: cursor?, limit? - responses: 200, 400, 401, 403, 409, 500 ### care - `POST /care/procedure-requests` (auth required) — Submit an expensive-procedure negotiation request. - body: application/json (CreateProcedureRequestSchema) - responses: 201, 400, 401, 403, 409, 500 ### census - `PATCH /census/dependents/{dependentId}` (auth required) — Update a dependent email address. - path params: dependentId - body: application/json (UpdateDependentSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /census/dependents/{dependentId}/invite` (auth required) — Resend a dependent invite email. - path params: dependentId - responses: 200, 400, 401, 403, 404, 409, 500 - `GET /census/entry` (auth required) — GET /api/census/entry - responses: 200, 400, 401, 403, 500 - `POST /census/upload` (auth required) — Upload a census file for downstream AI analysis. - body: multipart/form-data (CensusUploadFormDataSchema) - responses: 200, 400, 401, 403, 500 ### claims - `POST /admin/claims/{id}/adjudicate` (auth required) — POST /api/admin/claims/[id]/adjudicate - path params: id - body: application/json (AdjudicateClaimRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/invoice` (auth required) — POST /api/admin/claims/[id]/invoice - path params: id - body: application/json (AdminClaimInvoiceRequestSchema) - responses: 202, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/preview-coverage` (auth required) — POST /api/admin/claims/[id]/preview-coverage - path params: id - body: application/json (PreviewCoverageRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/readjudicate` (auth required) — POST /api/admin/claims/[id]/readjudicate - path params: id - body: application/json (ReadjudicateClaimRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/receipt` (auth required) — POST /api/admin/claims/[id]/receipt - path params: id - body: application/json (AdminClaimReceiptRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/resubstantiate` (auth required) — POST /api/admin/claims/[id]/resubstantiate - path params: id - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /admin/claims/{id}/update-category` (auth required) — POST /api/admin/claims/[id]/update-category - path params: id - body: application/json (UpdateCategoryRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `GET /claims` (auth required) — GET /api/claims - query params: status?, source?, limit?, offset? - responses: 200, 400, 401, 403, 500 - response examples: 200:list_claims={"object":"list","data":[{"object":"claim","id":"claim_aaaaaaaaaaaaaaaaaaaaaaaa","created":1775001600,"livemode":true,"metadata":{},"source":"REIMBURSEMENT","status":"SUBMITTED","category":"VISION","submitted_amount":4250,"service_date":1775001600,"merchant_name":"Market Street Vision","description":"Eye exam"}],"has_more":false,"url":"/api/claims"} - `GET /claims/{id}` (auth required) — GET /api/claims/[id] - path params: id - responses: 200, 400, 401, 403, 404, 500 - `GET /claims/{id}/invoice` (auth required) — GET /api/claims/[id]/invoice - path params: id - responses: 200, 400, 401, 403, 404, 500 - `POST /claims/{id}/invoice` (auth required) — POST /api/claims/[id]/invoice - path params: id - body: application/json (ClaimInvoiceRequestSchema) - responses: 202, 400, 401, 403, 404, 409, 500 - `GET /claims/{id}/payment-proposal` (auth required) — GET /api/claims/[id]/payment-proposal - path params: id - responses: 200, 400, 401, 403, 404, 500 - `POST /claims/{id}/payment-proposal/cancel` (auth required) — POST /api/claims/[id]/payment-proposal/cancel - path params: id - body: application/json (CancelClaimPaymentRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /claims/{id}/payment-proposal/confirm` (auth required) — POST /api/claims/[id]/payment-proposal/confirm - path params: id - body: application/json (ConfirmClaimPaymentRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /claims/{id}/payment-proposal/step-up` (auth required) — POST /api/claims/[id]/payment-proposal/step-up - path params: id - responses: 201, 400, 401, 403, 404, 409, 429, 500 - `POST /claims/{id}/upload` (auth required) — POST /api/claims/[id]/upload - path params: id - body: application/json (UploadReceiptRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /claims/invoice/preview` (auth required) — POST /api/claims/invoice/preview - body: application/json (InvoicePreviewRequestSchema) - responses: 200, 400, 401, 403, 409, 500 - `POST /claims/reimbursement` (auth required) — POST /api/claims/reimbursement - body: application/json (CreateReimbursementClaimRequestSchema) - request examples: create_reimbursement_claim={"service_category":"VISION","submitted_amount":4250,"service_date":"2026-04-01","merchant_name":"Market Street Vision","description":"Eye exam","metadata":{"external_id":"claim-042"}} - responses: 201, 400, 401, 403, 409, 500 - response examples: 201:created_reimbursement_claim={"success":true,"claim_id":"claim_aaaaaaaaaaaaaaaaaaaaaaaa"} - `GET /sandbox/claims` (auth required) — GET /api/sandbox/claims - responses: 200, 400, 500 - `POST /sandbox/claims` (auth required) — POST /api/sandbox/claims - body: application/json (SandboxCreateClaimRequestSchema) - request examples: create_sandbox_claim={"member_id":"mem_test_001","service_category":"VISION","submitted_amount":4250,"service_date":"2026-04-01","merchant_name":"Sandbox Vision","description":"Eye exam simulation","metadata":{"source":"agent_smoke_test"}} - responses: 201, 400, 409, 500 - response examples: 201:created_sandbox_claim={"object":"claim","id":"claim_aaaaaaaaaaaaaaaaaaaaaaaa","created":1775001600,"livemode":false,"metadata":{"source":"agent_smoke_test"},"member_id":"mem_test_001","source":"REIMBURSEMENT","status":"SUBMITTED","category":"VISION","submitted_amount":4250,"approved_amount":null,"member_owes":null,"deductible_applied":null,"coinsurance_applied":null,"service_date":1775001600,"merchant_name":"Sandbox Vision","description":"Eye exam simulation","receipt_url":null,"uploaded_at":null}; 201:idempotency_replay={"object":"claim","id":"claim_aaaaaaaaaaaaaaaaaaaaaaaa","created":1775001600,"livemode":false,"metadata":{"source":"agent_smoke_test"},"member_id":"mem_test_001","source":"REIMBURSEMENT","status":"SUBMITTED","category":"VISION","submitted_amount":4250,"approved_amount":null,"member_owes":null,"deductible_applied":null,"coinsurance_applied":null,"service_date":1775001600,"merchant_name":"Sandbox Vision","description":"Eye exam simulation","receipt_url":null,"uploaded_at":null}; 409:idempotency_conflict={"type":"idempotency_key_already_used_error","title":"This idempotency key was already used with different request parameters.","status":"409","detail":null,"param":"Idempotency-Key","recovery_hint":"CHECK_INPUT"} ### doctors - `GET /doctors/appointments` (auth required) — List appointments and in-flight scheduling jobs. - query params: scope? - responses: 200, 400, 401, 403, 500 - `DELETE /doctors/appointments/{id}` (auth required) — Delete a failed appointment scheduling job. - path params: id - responses: 200, 400, 401, 403, 404, 409, 500 - `GET /doctors/appointments/{id}` (auth required) — Get one appointment or in-flight scheduling job. - path params: id - responses: 200, 400, 401, 403, 404, 500 - `PATCH /doctors/appointments/{id}` (auth required) — Cancel an appointment. - path params: id - body: application/json (AppointmentCancelRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `GET /doctors/insights` (auth required) — Fetch review summaries and attributes for a list of doctors. - query params: ids - responses: 200, 400, 500 - `POST /doctors/schedule` (auth required) — Schedule doctors based on prior search results - body: application/json (ScheduleDoctorsRequestSchema) - request examples: book_or_request_appointment={"doctors":[{"doctor_id":"doctor_aaaaaaaaaaaaaaaaaaaaaaaa","visited_status":"no"}],"location":{"zip_code":"94107"},"specialty":"primary care","care_type":"annual physical","symptoms":[],"urgency":"routine","visit_type":"inperson","availability":[{"date":"2026-05-01T00:00:00.000Z","slots":[{"start":"2026-05-01T17:00:00.000Z","end":"2026-05-01T18:00:00.000Z"}]}]} - responses: 200, 400, 409, 500 - `POST /doctors/search` (auth required) — Find doctors based on natural language criteria using Google Places API - body: application/json (FindDoctorsRequestSchema) - request examples: find_care_options={"location":{"city":"San Francisco","region":"CA","country_code":"US","postal_code":"94107"},"specialty":"primary care","care_type":"annual physical","symptoms":[],"urgency":"routine","visit_type":"inperson","max_results":5} - responses: 200, 400, 409, 500 - `GET /doctors/suggest` (auth required) — GET /api/doctors/suggest - query params: q, limit?, city?, region?, postalCode?, countryCode? - responses: 200, 400, 401, 403, 500 ### files - `DELETE /cleanup-pdf` (auth required) — Delete a generated PDF file from the public directory. - responses: 200, 400, 401, 403, 409, 500 ### funding - `GET /claims/{id}/payment-proposal` (auth required) — GET /api/claims/[id]/payment-proposal - path params: id - responses: 200, 400, 401, 403, 404, 500 - `POST /claims/{id}/payment-proposal/cancel` (auth required) — POST /api/claims/[id]/payment-proposal/cancel - path params: id - body: application/json (CancelClaimPaymentRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /claims/{id}/payment-proposal/confirm` (auth required) — POST /api/claims/[id]/payment-proposal/confirm - path params: id - body: application/json (ConfirmClaimPaymentRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /claims/{id}/payment-proposal/step-up` (auth required) — POST /api/claims/[id]/payment-proposal/step-up - path params: id - responses: 201, 400, 401, 403, 404, 409, 429, 500 - `DELETE /funding/hsa/payment-authorization` (auth required) — DELETE /api/funding/hsa/payment-authorization - responses: 200, 400, 401, 403, 404, 409, 500 - `GET /funding/hsa/payment-authorization` (auth required) — GET /api/funding/hsa/payment-authorization - responses: 200, 400, 401, 403, 404, 500 - `PUT /funding/hsa/payment-authorization` (auth required) — PUT /api/funding/hsa/payment-authorization - body: application/json (UpdateClaimPaymentAuthorizationRequestSchema) - responses: 200, 400, 401, 403, 404, 500 - `POST /funding/hsa/payment-authorization/step-up` (auth required) — POST /api/funding/hsa/payment-authorization/step-up - responses: 201, 400, 401, 403, 404, 409, 429, 500 - `GET /funding/transactions` (auth required) — List the authenticated member's reconciled funding-account transactions. - query params: cursor?, limit? - responses: 200, 400, 401, 403, 409, 500 ### labs - `POST /labs/extract` (auth required) — Triggers AI processing for a lab file that has already been uploaded to R2 - body: application/json (ExtractLabRequestSchema) - responses: 201, 400, 401, 403, 409, 500 ### legal - `GET /legal/acceptances` (auth required) — Read current legal acceptance status. - responses: 200, 400, 401, 403, 500 - `POST /legal/acceptances` (auth required) — Record legal document acceptance. - body: application/json (CreateLegalAcceptanceRequestSchema) - responses: 201, 400, 401, 403, 409, 500 - `GET /legal/documents` (public) — List the current legal documents. - responses: 200, 400, 500 ### medical-history - `GET /medical-history` (auth required) — GET /api/medical-history - responses: 200, 400, 401, 403, 500 - `PUT /medical-history` (auth required) — PUT /api/medical-history - body: application/json (MedicalHistoryUpdateSchema) - responses: 200, 400, 401, 403, 500 - `POST /medical-history/extract` (auth required) — POST /api/medical-history/extract - body: application/json (MedicalHistoryExtractRequestSchema) - responses: 200, 400, 401, 403, 409, 500 ### medications - `GET /medications/mine` (auth required) — List the authenticated member's medications. - responses: 200, 400, 401, 403, 500 - `PATCH /medications/mine` (auth required) — Update a medication owned by the authenticated member. - body: application/json (UpdateMemberMedicationRequestSchema) - responses: 200, 400, 401, 403, 404, 409, 500 - `POST /medications/mine` (auth required) — Add or reactivate a medication for the authenticated member. - body: application/json (CreateMemberMedicationRequestSchema) - responses: 201, 400, 401, 403, 409, 500 - `GET /medications/search` (auth required) — GET /api/medications/search - query params: q, limit?, offset? - responses: 200, 400, 401, 403, 500 - `GET /sandbox/medications/search` (auth required) — GET /api/sandbox/medications/search - query params: q, limit?, offset? - responses: 200, 400, 500 - response examples: 200:search_sandbox_medications={"object":"list","data":[{"id":"medication_search_aaaaaaaaaaaaaaaaaaaaaaaa","ndc":"00002143380","medication_name":"Atorvastatin","brand_name":"Lipitor","strength":"20 mg","form":"tablet","formatted_price":"$6.60","url":"https://www.costplusdrugs.com/medications/atorvastatin-20mg-tablet/","display_name":"Atorvastatin 20 mg tablet","livemode":false}],"has_more":false,"url":"/api/sandbox/medications/search"} ### mobile - `GET /mobile/config` (public) — GET /api/mobile/config - responses: 200, 400, 500 ### personal-email - `DELETE /personal-email` (auth required) — Unlink the associated secondary Google account. - responses: 200, 400, 401, 403, 409, 500 - `POST /personal-email/link` (auth required) — Initiate secondary Google account linking. - body: application/json (LinkRequestSchema) - responses: 200, 400, 401, 403, 409, 500 ### phone - `POST /phone/send-otp` (auth required) — Auth context unused — the plugin identifies the user via the forwarded request headers. - body: application/json (SendOtpRequestSchema) - responses: 200, 400, 401, 403, 409, 500 - `POST /phone/verify` (auth required) — Auth context unused — the plugin identifies the user via the forwarded request headers. - body: application/json (VerifyOtpRequestSchema) - responses: 200, 400, 401, 403, 409, 500 ### plan - `GET /plan` (auth required) — GET /api/plan - responses: 200, 400, 401, 403, 404, 500 - `POST /plan/funding-attestation` (auth required) — POST /api/plan/funding-attestation - body: application/json (FundingAttestationInputSchema) - responses: 200, 400, 401, 403, 404, 409, 500 ### pre-auths - `GET /pre-auths` (auth required) — GET /api/pre-auths - query params: status?, open?, cardActivityId?, limit?, offset? - responses: 200, 400, 401, 403, 500 - `POST /pre-auths` (auth required) — POST /api/pre-auths - body: application/json (CreateMemberPreAuthorizationRequestSchema) - responses: 201, 400, 401, 403, 409, 429, 500 ### profile - `GET /profile/{userId}` (auth required) — Fetch the authenticated user's profile. - path params: userId - responses: 200, 400, 401, 403, 404, 500 - `PATCH /profile/{userId}` (auth required) — Update the authenticated user's profile. - path params: userId - body: application/json (UserProfileUpdateSchema) - responses: 200, 400, 401, 403, 409, 500 - `GET /profile/address` (auth required) — Get the authenticated member's residential address. - responses: 200, 400, 401, 403, 500 - `PATCH /profile/address` (auth required) — Replace the authenticated member's residential address. - body: application/json (ResidentialAddressRequestSchema) - responses: 200, 400, 401, 403, 409, 500 ### push - `POST /push/devices` (auth required) — Register a push notification device token. - body: application/json (RegisterPushDeviceRequestSchema) - responses: 201, 400, 401, 403, 409, 500 ### quotes - `POST /quotes/census` (auth required) — Normalize and price an employee census through the canonical quoting pipeline. A coverage effective date is required and must fit the active rate-card window. Clean CSV stays deterministic; malformed CSV with at most 500 source rows can be corrected by Claude before strict validation. Any unresolved ZIP or tier blocks the entire premium. Returns the full election grid unless one election is supplied. Indicative only; nothing here binds stop-loss. - body: application/json (CensusQuoteRequestSchema) - responses: 200, 400, 401, 403, 409, 500 ### sandbox - `GET /sandbox/benefits` (auth required) — GET /api/sandbox/benefits - responses: 200, 400, 500 - response examples: 200:get_sandbox_benefits={"object":"benefits_summary","livemode":false,"created":1775001600,"plan":{"object":"health_plan","id":"health_plan_aaaaaaaaaaaaaaaaaaaaaaaa","livemode":false,"name":"Sandbox PPO"},"balances":{"deductible":{"used":{"cents":125000,"currency":"USD"},"limit":{"cents":500000,"currency":"USD"},"remaining":{"cents":375000,"currency":"USD"}}}} - `GET /sandbox/claims` (auth required) — GET /api/sandbox/claims - responses: 200, 400, 500 - `POST /sandbox/claims` (auth required) — POST /api/sandbox/claims - body: application/json (SandboxCreateClaimRequestSchema) - request examples: create_sandbox_claim={"member_id":"mem_test_001","service_category":"VISION","submitted_amount":4250,"service_date":"2026-04-01","merchant_name":"Sandbox Vision","description":"Eye exam simulation","metadata":{"source":"agent_smoke_test"}} - responses: 201, 400, 409, 500 - response examples: 201:created_sandbox_claim={"object":"claim","id":"claim_aaaaaaaaaaaaaaaaaaaaaaaa","created":1775001600,"livemode":false,"metadata":{"source":"agent_smoke_test"},"member_id":"mem_test_001","source":"REIMBURSEMENT","status":"SUBMITTED","category":"VISION","submitted_amount":4250,"approved_amount":null,"member_owes":null,"deductible_applied":null,"coinsurance_applied":null,"service_date":1775001600,"merchant_name":"Sandbox Vision","description":"Eye exam simulation","receipt_url":null,"uploaded_at":null}; 201:idempotency_replay={"object":"claim","id":"claim_aaaaaaaaaaaaaaaaaaaaaaaa","created":1775001600,"livemode":false,"metadata":{"source":"agent_smoke_test"},"member_id":"mem_test_001","source":"REIMBURSEMENT","status":"SUBMITTED","category":"VISION","submitted_amount":4250,"approved_amount":null,"member_owes":null,"deductible_applied":null,"coinsurance_applied":null,"service_date":1775001600,"merchant_name":"Sandbox Vision","description":"Eye exam simulation","receipt_url":null,"uploaded_at":null}; 409:idempotency_conflict={"type":"idempotency_key_already_used_error","title":"This idempotency key was already used with different request parameters.","status":"409","detail":null,"param":"Idempotency-Key","recovery_hint":"CHECK_INPUT"} - `POST /sandbox/errors` (auth required) — POST /api/sandbox/errors - body: application/json (SandboxErrorSimulationRequestSchema) - request examples: validation_error={"type":"validation_error","param":"submitted_amount","recovery_hint":"CHECK_INPUT"} - responses: 400, 401, 403, 404, 409, 429, 500, 503 - response examples: 400:validation_error={"type":"validation_error","title":"Simulated validation_error.","status":"400","detail":null,"param":"submitted_amount","recovery_hint":"CHECK_INPUT"} - `GET /sandbox/medications/search` (auth required) — GET /api/sandbox/medications/search - query params: q, limit?, offset? - responses: 200, 400, 500 - response examples: 200:search_sandbox_medications={"object":"list","data":[{"id":"medication_search_aaaaaaaaaaaaaaaaaaaaaaaa","ndc":"00002143380","medication_name":"Atorvastatin","brand_name":"Lipitor","strength":"20 mg","form":"tablet","formatted_price":"$6.60","url":"https://www.costplusdrugs.com/medications/atorvastatin-20mg-tablet/","display_name":"Atorvastatin 20 mg tablet","livemode":false}],"has_more":false,"url":"/api/sandbox/medications/search"} ### uploads - `POST /upload/initiate` (auth required) — POST /api/upload/initiate - body: application/json (InitiateUploadRequestSchema) - request examples: initiate_upload={"file_name":"vision-invoice.pdf","content_type":"application/pdf","file_size":120000,"purpose":"member-invoices"} - responses: 200, 400, 401, 403, 409, 500 - response examples: 200:upload_initiated={"upload_url":"https://files.example.com/presigned-put","key":"member-invoices/member_aaaaaaaaaaaaaaaaaaaaaaaa/vision-invoice.pdf","expires_at":"2026-04-28T12:15:00.000Z"} ### user - `GET /users/{userId}` (auth required) — Fetch the authenticated user's full record. - path params: userId - responses: 200, 400, 401, 403, 404, 500 - `PATCH /users/{userId}` (auth required) — Update user-level attributes. - path params: userId - body: application/json (UserUpdateSchema) - responses: 200, 400, 401, 403, 409, 500 ### wearables - `GET /wearables/activity` (auth required) — Get daily activity. - query params: days?, startDate?, endDate?, includeRaw? - responses: 200, 400, 401, 403, 500 - `GET /wearables/connections` (auth required) — List wearable connections. - responses: 200, 400, 401, 403, 500 - `GET /wearables/profile` (auth required) — Get wearable profile. - responses: 200, 400, 401, 403, 500 - `GET /wearables/recovery` (auth required) — Get daily recovery. - query params: days?, startDate?, endDate?, includeRaw? - responses: 200, 400, 401, 403, 500 - `GET /wearables/sleep` (auth required) — Get nightly sleep. - query params: days?, startDate?, endDate?, includeRaw? - responses: 200, 400, 401, 403, 500 - `GET /wearables/workouts` (auth required) — List workouts. - query params: days?, startDate?, endDate?, includeRaw? - responses: 200, 400, 401, 403, 500 --- ## API Author's Guide (app/api/AGENT.md) # API Route Guide This folder contains Next.js App Router route handlers. Keep logic thin—delegate to server actions/queries or `lib` utilities. For TypeScript and file naming conventions, see [docs/agents/typescript.md](../../docs/agents/typescript.md). For authentication patterns, see [docs/agents/authentication.md](../../docs/agents/authentication.md). For file storage, see [docs/agents/file-storage.md](../../docs/agents/file-storage.md). ## Middleware Pipeline Every route exports goes through `withApiHandler` (see `lib/api-handler.ts`): ```ts export const POST = withApiHandler( withAuth(async ({ auth, request }) => { // handler body }), ); ``` `withApiHandler` is the outermost wrapper. It catches anything thrown by the handler and renders the canonical RFC 9457 error envelope via `toErrorResponse` (`lib/api-error.ts`). Use `withSerializer` inside the wrapper for routes that accept public JSON request bodies; it normalizes incoming snake_case keys to internal camelCase before Zod parses. `withIdempotency` is applied by `withApiHandler` for supported mutating requests that include an `Idempotency-Key` header. `withApiHandler` also creates an AsyncLocalStorage request context used by centralized 5xx telemetry. The default action is `METHOD /api/path`; pass `withApiHandler(handler, { action: "stableActionName" })` only when a route needs a long-lived dashboard key. `withAuth` enriches that context with `userId` and organization metadata after authentication succeeds. Unknown `/api/*` paths are handled by `app/api/[...path]/route.ts` and return `api_method_not_found_error`. Do not add ad-hoc 404 JSON responses for missing API methods. ## Security & Session Handling - **Always protect with authentication**: Use `withAuth()` wrapper or `authenticateRequest()` for all new endpoints unless they're webhooks or public APIs. - `withAuth` *throws* `ApiError` (type `invalid_api_key_error` / `insufficient_permissions_error`) on missing auth or missing org — `withApiHandler` renders the canonical envelope. Do not catch and re-emit auth errors yourself. - A resolved missing or invalid session is `401 invalid_api_key_error`. A database or Better Auth session-validation failure is `503 service_unavailable_error` with code `authentication_validation_unavailable`. Never convert that retryable failure into 401 because clients may clear a valid session in response. - Global admin checks use `requireGlobalAdmin()`; it throws `ApiError` on failure, so call it with `await requireGlobalAdmin(auth.user.id)` and do not branch on a returned response. - CORS preflight/allowed origins are enforced in `proxy.ts`, but route handlers must still perform session checks. - All endpoints support both bearer tokens (mobile) and session cookies (web). ## Error Envelope (RFC 9457 + extensions) All errors are emitted as: ```jsonc { "type": "validation_error", // ErrorType enum, see lib/api-error.ts "title": "Human-readable summary.", "status": "400", // Increase-style string, duplicated for log-only contexts "detail": "Specific message or null", "code": "missing_field", // optional sub-code "param": "amount", // optional dotted field path "doc_url": "https://…", // optional link to docs "recovery_hint":"CHECK_INPUT", // RETRY_LATER | CHECK_INPUT | TRY_ALTERNATIVE | REQUIRES_USER_ACTION | UNRECOVERABLE "errors": [{"path":"amount","message":"…"}] // present for ZodError } ``` `ErrorType` vocabulary (see `lib/api-error.ts:ERROR_TYPES`): | Type | HTTP | When to use | |------|------|-------------| | `malformed_request_error` | 400 | JSON didn't parse | | `invalid_parameters_error` | 400 | JSON parsed but a value is invalid | | `validation_error` | 400 | Zod / semantic validation failed (use `throw zodResult.error;`) | | `invalid_api_key_error` | 401 | Missing/invalid bearer or session | | `insufficient_permissions_error` | 403 | Token valid but missing scope/org | | `private_feature_error` | 403 | Feature exists, caller lacks access | | `environment_mismatch_error` | 403 | Test ID in livemode (or vice versa) | | `object_not_found_error` | 404 | Resource doesn't exist | | `api_method_not_found_error` | 404 | Wrong URL / no such endpoint | | `invalid_operation_error` | 409 | Wrong state for this action | | `idempotency_key_already_used_error` | 409 | Same key, different body | | `rate_limited_error` | 429 | Rate-limited | | `internal_server_error` | 500 | Catch-all (default for unknown thrown values) | | `service_unavailable_error` | 503 | A transient dependency prevented the request from being evaluated | Concurrent reuse of the same in-flight idempotency key returns `type: "invalid_operation_error"` with `code: "idempotency_key_in_progress"`. Keep this on the generic conflict type unless the public error vocabulary is intentionally expanded. **Throw, don't return:** raise errors via `throw new ApiError({...})` (or just re-throw a `ZodError` / Prisma `P2025` — both are mapped natively by `toErrorResponse`). Do not return `NextResponse.json({error:...})` for failures — the envelope wrapper won't render and you'll skip the `recovery_hint` / `type` fields agents rely on. ## Idempotency Documented `POST`, `PATCH`, and `DELETE` API operations accept an optional `Idempotency-Key` header. Clients should generate a fresh opaque key for each new mutation (UUIDv4 is recommended) and reuse that same key only when retrying the same request after a timeout or network failure. Server behavior: - Keys are scoped to the authenticated user and, when present, the active organization. Different users or active organizations may use the same key safely without replaying each other's responses. Legacy user-scoped records are replayed only when their stored organization matches the active one. - Matching retries replay the first cached JSON response for 24 hours and set `Idempotent-Replayed: true`. - Reusing the same key with a different method, path, query, or request body returns `409 idempotency_key_already_used_error`. - Idempotency is supported for JSON requests and empty-body mutations only. Multipart, form, and binary uploads should not send `Idempotency-Key`; the middleware rejects them before reading the body to avoid buffering uploads. - JSON bodies are fingerprinted canonically, so object key order does not affect matching. Empty-body mutations use an empty-body fingerprint. - Validation/auth/client errors (`4xx`) are not cached. Successful responses and rendered `5xx` envelopes are cached once endpoint execution has begun. - An endpoint may opt out of caching rendered `5xx` responses only when its underlying operation has a separate durable idempotency guarantee. Card issuance does this so transient provider failures can recover with the same client key without creating a second card. - An endpoint may reclaim a stale `IN_PROGRESS` reservation after a short lease only when overlapping retries have the same separate idempotency guarantee. Card issuance uses a five-minute lease so an interrupted request cannot block onboarding for the full 24-hour response replay window. - If a matching request is still processing concurrently, the API returns `409` with `type: "invalid_operation_error"`, `code: "idempotency_key_in_progress"`, and `recovery_hint: "RETRY_LATER"`. Do not put personal data, emails, or access tokens in the idempotency key. ## Validation - Define Zod schemas alongside each handler. - On Zod failure, simply `throw parsed.error;` — `toErrorResponse` maps it to a `validation_error` envelope with `param` set to the first failing field path and full enumeration in `errors[]`. - Keep responses JSON serializable; avoid streaming unless using OpenAI helpers or explicit SSE. ## Resource Base Contract Resource responses that participate in the Stripe-style API surface must use `serializeResource(kind, model)` from `lib/api-serialize.ts`. The canonical base fields are: ```jsonc { "object": "claim", "id": "claim_a3kfp2ze8mqwv7rxt94yhc6n", "created": 1772668800, // Unix seconds "livemode": true, "metadata": {} } ``` The current rollout is compatibility-first: converted routes may include legacy camelCase fields alongside canonical snake_case fields so existing web/mobile clients keep working. New canonical fields should be additive until the client inventory is complete. Use `parseApiMetadata()` from `lib/metadata.ts` when accepting public `metadata`. It enforces at most 50 string key/value pairs; do not expose internal nested provider metadata as API `metadata`. ## Identifiers Every resource ID has the form: ``` {prefix}_{body} ``` - **prefix**: `snake_case` of the Prisma model name, full word, no abbreviations (e.g. `claim`, `bank_account`, `medical_history_snapshot`). The full registry lives in `lib/ids.ts:ID_PREFIX` — adding a new model means adding a one-line entry there. - **body**: matches `[A-Za-z0-9]{24,32}`. New rows are 24 chars of base32 RFC 4648 lowercase (`abcdefghijklmnopqrstuvwxyz234567`); legacy rows preserved their original cuid v1 / cuid v2 / Better Auth bodies through the Phase 4 backfill, so 25- and 32-char alphanumeric bodies also pass. - **separator**: a single `_`. The body never contains `_`. Examples: `claim_a3kfp2ze8mqwv7rxt94yhc6n` (new), `user_qW2QiXgXU46mONo9CLoxqWXmCSiSPTrl` (Better Auth legacy), `doctor_cmkmqoufn0000e817zdqjhr20` (cuid v1 legacy). The Prisma client extension (`lib/prisma-id-extension.ts`) stamps prefixed IDs on every `create`, `createMany`, `createManyAndReturn`, and `upsert` create branch. Better Auth tables (`User`, `Session`, `Account`, `Verification`, `Organization`, `Member`, `Invitation`) get prefixed IDs the same way — Better Auth uses `prismaAdapter(prisma)`, which routes through our extended client. **Validation:** route validators import from `lib/api-schemas/ids.ts` (e.g. `ClaimIdSchema`, `BankAccountIdSchema`) which wrap `idSchema(prefix)`. Malformed IDs are rejected at the API boundary with `validation_error`. Postgres `_id_format` CHECK constraints enforce the same shape at the storage layer. `Subscription.id` is an internal CUID2 value and participates in `ID_PREFIX`. Subscription lifecycle state is stored on the internal `Subscription` row. **Public `object` vs ID prefix:** the wire `object` field is the friendly public label (`"appointment"`), while the ID prefix follows the Prisma model name (`doctor_appointment_job_…`). Two precedents for accepting this: Stripe's `object: "customer"` / `id: "cus_…"`, and the simplicity of a single mechanical rule. Most resources match (`Claim` → `claim` → `claim_…`). External provider IDs (`increase_account_id`, Financial Connections account IDs, etc.) are stored in dedicated columns with their native formats and are **never** rewritten with our prefix scheme. When a path, query, or body ID originates from Increase, use the matching validator from `lib/api-schemas/increase-ids.ts`. Never use `lib/api-schemas/ids.ts` for a provider-owned value, even when its prefix matches an internal Prisma model. ### Dates Legacy timestamp fields still use `z.coerce.date()` and ISO strings. Canonical resource `created` is Unix seconds. ### Enums Avoid `z.nativeEnum` (OpenAPI generator drops values). Use `z.enum([...])` and map to native enums: ```ts const OnboardingStepSchema = z.enum(["PROFILE", "WEARABLES"]); const STEP_MAP: Record, UserOnboardingStep> = { PROFILE: UserOnboardingStep.PROFILE, WEARABLES: UserOnboardingStep.WEARABLES, }; ``` ## OpenAPI Generation Annotate handlers with JSDoc tags: `@description`, `@pathParams`, `@params`, `@body`, `@response`, `@responseSet`, `@add`, `@auth`, `@tag`, `@openapi`. - Use `@params` for query params (not `@queryParam`) - Keep `@response` codes aligned with actual `NextResponse` statuses - Run `bun run openapi:generate` after modifying routes - Export Zod schemas and parse both request and response payloads to prevent drift - Export response schemas as explicit `z.object(...)` values. Do not assign an exported schema to a generic helper call such as `listResponseSchema(ItemSchema)`; `next-openapi-gen` cannot statically resolve it and will emit an empty object schema. - For multipart endpoints, use `@contentType multipart/form-data` ## Adding New Endpoints 1. Protect with `withAuth()` or `authenticateRequest()`. 2. Decide whether logic belongs in an existing server action/query. 3. Create clear Zod schemas for request/response. 4. Document the route with OpenAPI annotations (include `@auth bearer`). 5. Add analytics/logging via shared helpers. 6. Test with both bearer tokens and session cookies. ## iOS / Mobile API Surface The iOS app exclusively uses endpoints documented in the OpenAPI spec (`public/openapi.json`). Never direct mobile clients to Better Auth internal routes (`/api/auth/*`). When adding auth-adjacent features that mobile needs, create wrapper routes under `/api/` with full OpenAPI annotations and Zod schemas. ### Member funding contract `GET /api/plan` always includes `funding.fundingRole`. Covered dependents receive neutral amount fields and a `not_applicable` account status. Funding-attestation writes for covered dependents return `409 covered_dependent_funding_not_applicable`. For a primary member, the HSA planned-employer-funding amount is the exact sum of immutable member lines already frozen on employer bills. Zero is a valid response when no complete monthly coverage period has been billed yet. Deposit amounts and dates use only lines whose employer premium payment succeeded. The billed override applies while eligibility is unresolved and after HSA eligibility is established. An HRA result keeps the design-level HRA funding view because HRA funding has no custodial seed-deposit workflow. HSA account, seed, and schedule fields remain null until the primary member is identified as HSA eligible. ## API Evolution & Deprecation Policy Mobile clients cannot be force-updated. Every published build keeps calling the API exactly as it looked the day it shipped, for months. The April 2026 envelope migration and the July taxonomy cutover both shipped as silent breaks and killed the shipped iOS app's Payments tab. This policy exists so that cannot happen again. **Additive-only evolution.** Once an endpoint is in `public/openapi.json`, its contract only widens: - Add optional fields; never remove or rename a field, change a field's type, or repurpose its meaning. - Never make an optional request field required, and never tighten validation an existing client could fail. - Response envelopes (`{object:"list", data, has_more}`, resource base fields, RFC 9457 errors) are frozen shapes. - Enums are **open**: adding a value is a normal additive change and requires no coordination — clients are required to decode unknown enum values tolerantly. Removing or renaming a value is breaking. **The gate.** `.github/workflows/api-ci.yml` regenerates the spec on every API PR (stale spec = failure) and runs `oasdiff breaking` against the base ref. A red oasdiff check means: redesign the change additively (new field, new endpoint, or versioned sibling) — not override the gate. **When a break is truly unavoidable:** 1. Ship the replacement additively first (new endpoint or new field alongside the old). 2. Mark the old operation `deprecated: true` in its annotations and serve `Deprecation` + `Sunset` headers (RFC 9745 / RFC 8594) with a sunset date **at least 90 days out**. 3. Migrate clients; watch usage via request logs and the `x-app-version` header. 4. Before removal, raise `minimumAppVersion` in `lib/mobile-app-config.ts` so builds that still depend on the old contract are blocked with the upgrade screen instead of breaking silently. (`GET /api/mobile/config`; the iOS app enforces it on launch/foreground.) 5. Only then remove the old contract — with the oasdiff finding acknowledged explicitly in the PR description. **Tolerant-reader requirement (all clients, including ours).** Clients must ignore unknown JSON fields, decode unknown enum values to an `unknown` case, and treat new response fields as invisible. The iOS repo enforces this in its schema layer; Android and future clients inherit the same rule. ## Background Jobs Long-running (>10 seconds) or parallel background work does not belong in an API route, which has a timeout. It runs on the `backend-jobs` Cloud Run service. **Scheduled work** is a cron. Add a handler to `services/backend/src/jobs/registry.ts` and create a matching Cloud Scheduler job targeting `POST /internal/jobs/{name}`. The handler returns a `JobOutcome`; a `retry: true` makes the runner answer 5xx so Scheduler retries the whole job, so handlers must be idempotent. `withJobLock` already prevents a retry overlapping a live run. **Event-triggered work** is a background task. Add a payload type to `lib/background-tasks/types.ts` and a handler to `services/backend/src/tasks/registry.ts`, then enqueue with `enqueueBackgroundTask(tx, name, payload)` from `lib/background-tasks/enqueue.ts`. Pass the transaction client whenever the task depends on rows written in that transaction: the enqueue is a row in the same transaction, so the work cannot be lost by committing the domain write and then failing to dispatch. Call `nudgeBackgroundTask(id)` after the transaction commits for sub-second dispatch; it is best effort, and the one-minute `drain-background-tasks` job runs anything it misses. Handlers get whole-task retry with backoff, not step-level checkpointing, so every handler must be safe to run twice. ## Increase Webhook Contract All Increase webhook events are handled by the Cloud Run `backend` service at `/webhooks/increase`. There is no Vercel route for Increase webhooks. See [services/backend/README.md](../../services/backend/README.md) for the end-to-end flow and [docs/backend/cloud-run.md](../docs/backend/cloud-run.md) for deployment. The Increase dashboard webhook URL must point at the backend Cloud Run service. All categories below are subscribed on that single endpoint: ``` real_time_decision.card_authorization_requested card_payment.created card_payment.updated account_transfer.created account_transfer.updated ach_transfer.created ach_transfer.updated transaction.created declined_transaction.created digital_wallet_token.created digital_wallet_token.updated ``` A missing subscription means the event silently never arrives — no error, no retry. Operational invariants of the backend handlers (signature verify, advisory-lock-per-entity, always re-fetch from Increase, throw on failure so Increase retries) live in `services/backend/README.md`. --- ## Authentication Guide (docs/agents/authentication.md) # Authentication & Authorization Better Auth powers session management. This guide covers setup, API authentication patterns, and permissions. ## Core Files - **`lib/auth.ts`**: Server-side Better Auth configuration - **`lib/auth-client.ts`**: Browser/client counterpart - **`lib/auth-helpers.ts`**: Unified auth utilities for API routes - **`lib/with-auth.ts`**: Higher-order route wrapper - **`lib/permissions.ts`**: Server-side permission checks - **`lib/permissions-client.ts`**: Client-safe permission checks ## Server-Side Auth (`auth.ts`) Registers Prisma adapter, session lifetimes, trusted origins, and cookie caching. Key features: - Email/password with verification, magic links, Google OAuth - **Bearer token plugin** for mobile/native clients via `Authorization: Bearer ` - Organization plugin with extra fields (`ein`, `legalName`, `hqLocation`, member `title`) - Expo push, admin access, and OAuth providers for Oura/WHOOP - Hooks sync tier quantities via `syncSubscriptionTiers` - OAuth access and refresh tokens are encrypted at rest. Read and refresh them only through `auth.api.getAccessToken()`, never directly from Prisma. - Oura and WHOOP are link-only data providers. They are not trusted identity providers and both wearable sign-in routes are denied. Their callbacks may link to an existing authenticated account but may never create a session. - `Account(providerId, accountId)` is unique. Apply that constraint before the auth deployment so one external account cannot race onto two users. - Database-backed rate limiting. Production deploys must run `bun run db:migrate --better-auth-1-6-25` before deploying auth code. This targeted migration installs and verifies only the `RateLimit` model and the external-account unique index, then removes legacy stored Google ID tokens. Do not use a broad `db:push` for this rollout while the production schema has unrelated pending changes. - Rate-limit identity comes only from Vercel's `x-vercel-forwarded-for` system header. Vercel overwrites it even on direct deployment URLs. Do not replace it with `cf-connecting-ip` unless the origin is provably reachable only through Cloudflare. - Normal Google sign-in requests only identity scopes. The Workspace directory scope is requested incrementally by the authenticated team-import flow. Do not add privileged Google scopes back to the shared provider configuration. ## Client-Side Auth (`auth-client.ts`) Created with `createAuthClient`. Adds organization, admin, Stripe, generic OAuth, and magic link plugins. Browser sessions rely on Better Auth's Secure, HttpOnly session cookie. Never copy session credentials from `set-auth-token` into localStorage. The bearer plugin is reserved for native and CLI clients that use platform-protected credential storage. Always import from `lib/auth-client.ts` in Client Components. ## API Route Authentication ### Using `withAuth()` Wrapper (Recommended) ```ts import { withAuth } from "@/lib/with-auth"; export const POST = withAuth(async ({ auth, request }) => { const { user, session } = auth; // handler logic }); ``` Returns `401` if the credential is absent or invalid, and `403` if an organization is required but missing. If the database or Better Auth cannot validate the session, the API returns retryable `503 service_unavailable_error` with code `authentication_validation_unavailable`. Clients must preserve their credential and retry rather than treating 503 as a logout signal. ### Using `authenticateRequest()` Directly ```ts import { authenticateRequest } from "@/lib/auth-helpers"; export async function GET(request: Request) { const auth = await authenticateRequest(request); if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // handler logic } ``` Supports both: - Bearer tokens (`Authorization: Bearer `, `x-session-token` header) - MCP OAuth access tokens (`Authorization: Bearer mcp_...`) for remote connector calls that reuse existing authenticated route handlers - Session cookies ### Key Exports from `auth-helpers.ts` - `authenticateRequest(request)`: Main auth function returning `AuthResult | null`; throws `AuthenticationServiceUnavailableError` when validation infrastructure fails - `getAuth()`: Convenience wrapper using Next.js `headers()` - `extractBearerToken(headerValue)`: Extracts token from Authorization header - `validateBearerToken(token)`: Validates against session table with expiry check ## Server Component / Action Auth ```ts import { auth } from "@/lib/auth"; import { headers } from "next/headers"; const session = await auth.api.getSession({ headers: await headers() }); ``` Never rely on `cookies()` directly. ## Permissions System We use a custom synchronous permission system (not Better Auth's `createAccessControl()`) because: - Permission checks are sync (no await/DB calls) - Works with both global `User.role` and org `Member.role` - Pure functions are easy to test ### Server-Side ```ts import { canManageUsers, hasOrgPermission } from "@/lib/permissions"; if (canManageUsers(user.role)) { // allowed } if (hasOrgPermission(member.role, "manage_billing")) { // allowed } ``` ### Client-Side ```ts import { canManageUsers } from "@/lib/permissions-client"; ``` Mirrors server-side logic without pulling in server-only code. ### Available Helpers - `hasAdminPermission(role, action)`: Check global User.role permissions - `hasOrgPermission(role, action)`: Check org Member.role permissions - `isGlobalSupport()`, `canManageUsers()`, `canAccessBanking()`, etc. Always use specific permission helpers over raw role checks. ## Better Auth Entity Checklist When modifying Prisma models powering Better Auth (`User`, `Account`, `Session`, `Organization`, `Member`, `Invitation`, `Verification`): 1. Extend the Prisma schema 2. Update `lib/auth.ts` data shapes and `select` clauses 3. Regenerate client: `bun run generate` 4. Fix TypeScript errors before committing 5. Apply to database: `bun db:push` See [Better Auth docs](https://www.better-auth.com/docs/concepts/database#extending-core-schema) for guidance. ## Phone Number Plugin The `phoneNumber()` plugin in `lib/auth.ts` enables OTP-based phone verification: - **SMS delivery**: Uses Quo/OpenPhone via `sendSms()` from `server/services/notifications/channels/sms`. - **Validation**: US-only numbers matching `+1` followed by 10 digits (`/^\+1\d{10}$/`). - **OTP config**: 6-digit code, 5-minute expiry. - **Data model**: `phoneNumber` and `phoneNumberVerified` live on the `User` model (not UserProfile). - **Update flow**: Phone numbers are updated exclusively through OTP verification — never via direct DB writes. - **Wrapper routes**: `POST /api/phone/send-otp` and `POST /api/phone/verify` expose this to iOS/mobile via the OpenAPI spec. These call `auth.api.sendPhoneNumberOTP()` and `auth.api.verifyPhoneNumber()` server-side. ## Prisma ↔ Better Auth `additionalFields` Sync Rule When adding a column to a Better Auth-managed model (`User`, `Organization`, `Session`, `Account`, `Member`, `Invitation`, `Verification`), you **must** also declare it in `additionalFields` in `lib/auth.ts` (for User/Organization) or the relevant plugin config. Without this, Better Auth won't include the field in session/auth responses, causing silent drift. ### Current `additionalFields` **User** (`user.additionalFields` in `lib/auth.ts`): - `onboardingStep` — string, `input: false` - `onboardingCompletedAt` — date, `input: false` - `personalGoogleAccountId` — string, `input: false` **Organization** (`organization()` plugin `schema.organization.additionalFields`): - `ein` — string, `input: true` - `legalName` — string, `input: true` - `hqLocation` — string, `input: true` - `addressLine1` — string, `input: true` - `addressLine2` — string, `input: true` - `city` — string, `input: true` - `state` — string, `input: true` - `postalCode` — string, `input: true` - `country` — string, `input: true` - `onboardingStep` — string, `input: false` - `onboardingCompletedAt` — date, `input: false` - `increaseAccountId` — string, `input: false` **Member** (`schema.member.additionalFields`): - `title` — string, `input: true` - `workEmail` — string, `input: true` **Invitation** (`schema.invitation.additionalFields`): - `censusEntryId` — string, `input: true` ## OAuth Failure Handling Better Auth reports every OAuth callback failure as a **302 to an error URL** with `?error=`, never as a thrown error — so these failures produce no server logs and no 5xx telemetry. Error codes, where each one lands (native deep link vs. the web `/error` page), and the iOS client requirements are documented in [auth-error-contract.md](./auth-error-contract.md). Canonical copy lives in `lib/auth-error-codes.ts`. Any endpoint that hands a client-supplied redirect to Better Auth must validate it with `isTrustedRedirectURL()` from `lib/auth-constants.ts` first — `linkSocialAccount` has no `originCheck` middleware of its own. ## iOS / Mobile API Rule The iOS app uses Better Auth routes directly only for the core sign-in, session-validation, and sign-out lifecycle. Every other mobile server call must use an endpoint defined in the OpenAPI spec (`public/openapi.json`). Auth-adjacent features such as phone verification need wrapper routes under `/api/` with OpenAPI annotations. ## Legal Acceptance Evidence - `GET /api/legal/documents` is public so sign-in UI can fetch the current terms and privacy version identifiers. - `GET /api/legal/acceptances` requires authentication. `POST` additionally requires an interactive web or mobile session, so PAT and MCP credentials cannot create acceptance evidence. Blocked or unknown census users do not create acceptance rows. - Canonical Markdown under `lib/legal-documents/artifacts/` is immutable and each versioned page verifies its SHA-256 before rendering. Publish changed text as a new artifact, registry entry, and versioned route. Retain every prior version. - iOS and Android acceptance writes require the app build. Current-status checks match the document type, version, and content hash. - `LegalDocumentAcceptance` snapshots the subject, actor, acceptance capacity, guardian basis, organization context, artifact URL, and artifact hash. - Acceptance rows are append-only. After `bun run db:push`, run `bun run db:migrate --legal-acceptance-append-only` to install the ID-format and acceptance-capacity constraints plus the database update, delete, and truncate guards. ## iOS Signup Gating The iOS app is **only for invited plan members** (employees whose employer has purchased a Rivendell health plan). Founders sign up and purchase plans exclusively on the web. Signups originating from the iOS app are **blocked unless** the request includes a valid invitation or census entry. This is enforced in the `user.create.before` database hook in `lib/auth.ts`. **How it works:** - The Better Auth Expo client plugin sends an `expo-origin` header on every request. - `isExpoRequest()` in `lib/invite-signup.ts` checks for this header. - If the request is from Expo and has no invite context (no `inviteId` or `censusEntryId`), the hook returns `false` and user creation is blocked. - Web signups without an invitation are still allowed (founders creating new organizations). **Key files:** - `lib/invite-signup.ts` — `isExpoRequest()` helper and invite validation - `lib/auth.ts` — `databaseHooks.user.create.before` hook