canopy-web API Reference
On this page
Overview
Backend For Frontend (BFF) serving the worker portal. Renders HTML pages via Askama templates with htmx for dynamic updates and Alpine.js for client-side interactions. Not a JSON API — returns HTML.
For caseworker workflow documentation, see SNAP Caseworker Guide. For visual reference of the page surfaces + login (layout diagrams, Orchard color tokens, htmx interactions), see Worker Portal Mockups.
canopy-web is a server-rendered htmx BFF with no utoipa decorators — its routes are not part of an OpenAPI snapshot. The exception is the JSON composition surface (/v1/composition/*, documented at GET /api-doc/openapi.json via the CompositionApi doc) and DELETE /v1/composition/{surface}/user/me. The tables below document the route surface in narrative form rather than as per-endpoint response-code tables.
- Base URL
- Authentication
-
Keycloak OIDC session (redirects to login page)
- Session
-
PostgreSQL-backed, 8-hour TTL, HttpOnly cookie
- Database
-
canopy_web(sessions only — no domain data)
primary_programs claim: case detail
and its fragments gate on household participation (the union of
programs_requested across the household’s applications, any-of — the same
authority as the #1516 fact-write gate; no participation overlap is a 403,
an EMPTY union an honest 404 on the full page — a nonexistent household
and an application-less one are deliberately indistinguishable — an
unparseable union a 422, a lookup failure refuses the view; the tab and
fact-history FRAGMENTS keep their banner denial shapes until #1526); case
search and the command palette apply the participation gate per row
(unknown participation drops fail-closed; case search degrades the
fragment so an outage never reads as "not found", the palette omits
silently by design); the application/notices/appeals indexes,
team queue, renewals queue and dashboard panels are query-time scoped
(programs= storage slugs or per-program route legs — no unfiltered fetch
exists); notice PDFs and document bytes are authorized by their owning
case before a byte streams. The census is machine-enforced:
READ_SCOPE_POLICY (30 protected GETs, cargo xtask route-authz)PANEL_SCOPE_POLICY (22 panels, exhaustiveness test). The audit surfaces
are scoped since #1519 (the programs= row-visibility predicate upstream;
citation-by-id authorizes against the row’s program set); /sse is scoped
since #1520 (minimal invalidation messages — never the envelope; a
per-connection fail-closed filter on the event’s #1519 program metadata;
the stream terminates on mid-connection scope change and the browser
reconnects); read-denial shape unification is #1526.
Pages
| Method / Path | Page | Description |
|---|---|---|
GET / |
Dashboard (3 surfaces dispatched per WorkerRole) |
Composition-driven dashboard. Surface dispatch via |
GET /team-queue |
Team Queue (supervisor + admin only) |
Stage 5 MR2 #496 FU-13: full-page route surfacing all |
GET /cases |
Case Search |
htmx live search (name, case number, last 4 SSN). Results update after 300ms / 2+ characters. |
GET /cases/search?q= |
Search Results (htmx partial) |
Returns HTML table rows for the search results panel |
GET /command-palette/search?q= |
Command Palette (htmx fragment, #502) |
Powers the always-mounted ⌘K modal in |
GET /cases/{household_id} |
Case Detail |
Composition-driven section list (Georgia’s TOML populates the full 20-section case_detail surface; shell — tabs / scroll / card-grid — per the jurisdiction’s |
GET /cases/{household_id}/tab/{tab_id} |
Tab Content (htmx partial) |
Returns HTML for a single tab. 13 explicit arms — |
GET /cases/{household_id}/fact-history/{resource} |
Fact Change History (htmx partial, T1-6 #674) |
Backs the case-detail "Change history" section’s resource sub-tabs ( |
GET /applications |
Application List |
Queue of pending applications with expedited flags |
GET /applications/{id}/process |
Application Processing |
Review page with eligibility result, approve/deny buttons |
GET /applications/{id}/intake/{program} |
Per-Program Intake (Plan worker-intake-program-independence MR4b) |
Program-scoped intake page ( |
GET /notices |
Notice List |
Notice history across the jurisdiction (canopy-notices) |
GET /appeals |
Appeal List |
Fair-hearing / appeal queue (canopy-appeals) |
GET /renewals |
Renewal Queue |
Certifications due within 30/60/90 days, color-coded by urgency |
GET /renewals/list?window= |
Renewal List Fragment (htmx partial, #530) |
Returns the renewal table body for the 30/60/90-day filter swap (no full-page chrome) |
GET /audit-log |
System Audit Log (#503, epic &53) |
System-wide audit stream read from canopy-security. Admin / StudioAdmin / Auditor roles only (403 for everyone else). Filters: an allow-listed |
GET /studio |
Composition Studio Home (Stage 6 MR1, #499, epic &51) |
Jurisdiction composition Studio landing. StudioAdmin/Admin only ( |
GET /studio/onboarding |
Studio Onboarding Start |
Entry point for the composition onboarding wizard. |
GET, POST /studio/onboarding/step/{step} |
Studio Onboarding Step (htmx wizard) |
GET renders a wizard step; POST advances it (session-stored |
GET /studio/onboarding/bundle |
Studio Onboarding Bundle (#530 fragment) |
Renders the generated composition bundle preview. |
GET /dashboard/customize |
Customize My Dashboard (Stage 5 MR3 #498, ADR-024) |
Per-worker dashboard composition editor. Hide / reorder / resize panels within the role’s baseline; HTML5 native DnD + keyboard pickup (Space + arrows). Save persists via |
SSR deadlines & degraded states
Six read routes run under an aggregate request deadline (#1306, plan SSR aggregate request deadline + honest degraded states): every upstream read a page makes — gate wait, retries, token acquisition, body/decode — is bounded by one absolute per-request cutoff, and an upstream failure renders an honest per-component state instead of a fabricated success or a whole-page 500.
Budgets
| Budget | Default | Env knob | Applies to |
|---|---|---|---|
Page |
12 000 ms |
|
|
Fragment |
8 000 ms |
|
|
Boot validation bounds the knobs (page ∈ [1 000, 14 000] ms, fragment ∈ [1 000, 9 000] ms;
out-of-range rejects at boot). CANOPY_WEB__DEADLINE_OVERRIDE=true is the per-control
accountable override (deployment owns the risk; loud warn every boot; a hard 600 s cap
still applies). Raising budgets is never the fix for a slow upstream. Per-component caps
come from each plugin manifest’s data.timeout_ms (default 5 000 ms) — the binding
constraint is always min(page deadline, component cap).
Session-refresh budget (#1340)
The auth extractor’s token-refresh slow path runs under the same one-cutoff model,
with its own 3 s constant budget (REFRESH_SLOW_PATH_BUDGET, minted at slow-path
entry — the extractor runs before any page deadline exists). One budget covers the
per-session single-flight mutex wait, the session re-read, and the RFC 6749
refresh_token grant (whose transport is bounded by the residual, always tightening
the shared client’s 10 s default). Elapse at any stage fails closed to /login —
an expired token is never served — and a waiter whose budget was exhausted queueing
never fires a grant, so a hung IdP costs each request at most the budget instead of
the pre-#1340 worst case (10 s × queue depth, every waiter re-firing serially).
Post-grant validation and session persist deliberately run to completion
un-cancelled (#475 gap 2: an abandoned persist after a rotated grant manufactures an
invalid_grant cascade).
Honest state vocabulary
Every covered component (dashboard panel, case-detail section, hero, search rows,
?program=all row) renders exactly one of:
-
populated — real data.
-
empty — the upstream answered and there is genuinely nothing (the EARNED empty; an outage is never rendered as "All caught up" / "No determinations").
-
degraded (partial) — some legs failed while others delivered; rows stay visible under an incomplete-list banner with a real per-component Retry.
-
error — the component’s spine failed; a static error block with Retry. Copy is time-aware — a time-class failure reads "Took too long to load — try again", any other failure "Couldn’t load right now" — and NEVER quotes upstream text (#715).
Telemetry
Instruments on the canopy_web meter (all label values are closed enum names or
compile-time slugs — upstream text and diagnostics never cross into metrics):
| Instrument | Labels | Recorded |
|---|---|---|
|
|
Once per logical upstream call, in the client verb tail after status + decode. |
|
|
Once per rendered component — panels on |
|
|
Once per handler exit on each of the six covered routes — including redirects, authz denials, and error exits. |
|
|
hit/miss/coalesced once per cache-scoped GET (exactly one disposition per
consultation; a hit never also ticks |
|
|
Evictions under the R6 byte/entry budgets
( |
The #1218 panel-data cache sits at the InternalClient JSON-GET seam: fail-closed
guards (page deadline, auth-unavailable) run BEFORE any cache read; entries are keyed
(service, full URL, sha256(credential)); hits are judged against the CALLING
panel’s resolved TTL (composition item override, else the manifest author default;
0 bypasses); per-key single-flight coalesces cold bursts into one upstream call;
only successfully-decoded 2xx bodies are ever cached.
A page records degraded when it rendered with at least one failure-class component
(error / timeout / partial), error on a non-2xx exit (and on the /cases/search
fragment whose single component errored outright), else ok.
Dashboard composition (worker overrides)
ADR-024 introduces a user_delta_v1 semantic envelope for the user-layer composition row on dashboard surfaces. case_detail and other surfaces continue using RFC 6902 ops verbatim.
| Body shape | Description |
|---|---|
|
Sent as the body of |
RFC 6902 |
Unchanged path for non-dashboard surfaces (e.g. |
|
Reset to baseline. 204 on success or when no user-layer row existed (idempotent). Audit fires only when a row was actually removed. |
The composition JSON sub-router (the only utoipa-documented surface; merged CSRF/require_auth-bypassed per Decision 11 + 17, authenticated via session-based JSON extractors) exposes the three DB-backed override layers from ADR-022:
| Method / Path | Description |
|---|---|
GET, PUT, PATCH, DELETE /v1/composition/{surface}/live |
Jurisdiction-live override layer. GET reads; PUT replaces; PATCH applies RFC 6902 ops; DELETE removes the row. |
POST /v1/composition/{surface}/live/archive |
Explicit-archive the live override after a Studio promote-merge (ADR-022 lifecycle). |
GET, PUT, PATCH /v1/composition/{surface}/role/{role} |
Role override layer. Role filtering applies after override merge (ADR-021). 404 for an unknown |
GET, PUT, PATCH, DELETE /v1/composition/{surface}/user/me |
Per-worker user layer. Dashboard surfaces use the |
Action Endpoints (POST / PUT)
These endpoints process caseworker actions (and BFF proxies) and redirect back to — or swap a fragment into — the relevant page.
/actions/income/), the asset/expense editors (/actions/{asset,expense}/), the member editors (/actions/{member,person}/), and the address editor (/actions/address/) — authorizes against the resource, never the posted program label: the household’s participating programs (the union of programs_requested across its applications, looked up per request) must intersect the worker’s WorkerProgramScope (any-of — shared facts are not per-program). The pre-#1516 gate trusted the caller-supplied program field, so a SNAP-only worker could post program=snap and edit facts on a household that participates only in TANF; that bypass is closed, and the posted program survives only as the post-redirect tab. Denials are 403 (out of scope) or 422 (the household has no usable participation set — fail-closed, never a permitted write), always before the canopy-persons call. Structurally, the write verbs on the internal clients are module-private: every mutation must present an AuthorizedResource proof to obtain a write-capable client, so deleting an authorization check fails to compile, and cargo xtask route-authz additionally requires every mutating route’s scope classification. This is BFF-level enforcement; server-side enforcement at the canopy-persons boundary is epic &52 / #424. Facts are authored as worker claims (auto-accepted accepted_verified) into the canopy-persons version corpus. Every fact editor also enforces the household-membership IDOR guard: the income/asset/expense/address editors bind the posted person_id to the posted household’s roster (#996), and since #1523 the member editors do too — edit_person runs the same person↔household binding, and remove_member binds the posted membership fact_id to the household’s roster (canopy-persons independently enforces that binding at the system of record via require_member_ownership, a 404 before any mutation — the BFF check gives tampered ids the uniform not-a-member 403 and spends no upstream mutation round-trip). add_member posts no person id at all: the person is created and attached to the just-authorized household in one flow, so there is nothing to bind (the #1523 ruling).
Application lifecycle + intake proxies
| Endpoint | Description |
|---|---|
POST /applications/{id}/approve |
Approve an application — calls canopy-eligibility orchestrator |
POST /applications/{id}/deny |
Deny an application — records denial with reason code |
POST /applications/{id}/run-determination?program={program} |
Per-program Run Determination (Plan worker-intake-program-independence MR4). |
POST /applications/{id}/record-ele-consent |
Worker-attested ELE (Express Lane) consent (#977). Gated on write + SNAP-or-TANF program scope. BFF proxy that POSTs a typed |
PUT /applications/{id}/sections/{program}/{section} |
BFF proxy: per-program intake section save. The htmx form posts |
POST /applications/{id}/programs/{program}/complete-data-collection |
BFF proxy: complete data collection for a program. Forwards to canopy-applications' |
POST /cases/{household_id}/request-verification |
"Request Verification" Action ▾ mini-form (demo-dataset-seed Step 9e). POSTs a |
POST /cases/{household_id}/file-application |
"File application" Action ▾ modal (#978). Files a NEW application for the EXISTING household across one or more programs (the worker-side intake; the applicant portal always mints a fresh household). One checkbox per fileable program (deployed ∩ the worker’s scope), named by the bare program slug; gated on write + per-program scope (fail-closed — any out-of-scope program is denied). Resolves |
Generic / SNAP caseworker actions
Program-specific actions (#392)
Each program tab on case detail exposes its own action handlers. Program ∈ {tanf, medicaid, caps, wic}.
| Endpoint | Description |
|---|---|
POST /actions/tanf/interim-contact |
Record a TANF interim contact — calls canopy-renewals |
POST /actions/tanf/change-report |
Submit a TANF change report — calls canopy-renewals |
POST /actions/tanf/work-activity |
Record a TANF work activity — calls canopy-tanf |
POST /actions/tanf/resolve-discrepancy |
Resolve a TANF income discrepancy — calls canopy-tanf |
POST /actions/medicaid/interim-contact |
Record a Medicaid interim contact — calls canopy-renewals |
POST /actions/medicaid/change-report |
Submit a Medicaid change report — calls canopy-renewals |
POST /actions/medicaid/cmd-update |
Ingest a Change in Medicaid Determination (CMD) update — calls canopy-medicaid |
POST /actions/medicaid/resolve-quarantined |
Re-queue a quarantined Medicaid determination — calls canopy-medicaid |
POST /actions/caps/interim-contact |
Record a CAPS interim contact — calls canopy-renewals |
POST /actions/caps/change-report |
Submit a CAPS change report — calls canopy-renewals |
POST /actions/caps/update-authorization |
Partial-update a CAPS authorization — calls canopy-caps |
POST /actions/caps/switch-provider |
Switch a CAPS authorization’s provider — calls canopy-caps |
POST /actions/wic/interim-contact |
Record a WIC interim contact — calls canopy-renewals |
POST /actions/wic/change-report |
Submit a WIC change report — calls canopy-renewals |
POST /actions/wic/schedule-appointment |
Schedule a WIC certification appointment — calls canopy-wic |
POST /actions/wic/nutritional-risk |
Record a WIC nutritional-risk assessment — calls canopy-wic |
Studio (StudioAdmin/Admin only)
| Endpoint | Description |
|---|---|
POST /studio/onboarding/step/{step} |
Advance the composition onboarding wizard (session-stored |
POST /studio/onboarding/cancel |
Cancel the onboarding wizard and clear the draft |
All action endpoints validate CSRF tokens and require an active session. Studio routes additionally require StudioAdmin/Admin via the StudioAdminOnly extractor.
Other routes
| Method / Path | Description |
|---|---|
GET /notices/{id}/pdf |
Stream a generated notice PDF (proxied from canopy-notices) |
GET /audit-log/export.csv |
CSV export of the audit stream — same Admin/StudioAdmin/Auditor gate and the same source/limit/date filters as |
GET /audit-log/citation/{event_id}/pdf |
Signed "Cite for hearing" citation PDF for one audit event (#503 slice 8, ADR-029) — rendered via canopy-notices' |
GET /documents/{application_id}/{document_id}/content |
Worker content-proxy for applicant-uploaded documents (Plan 3 MR9b) — the applicant-side object is service-token-gated, so the BFF fetches the bytes from canopy-applications with its service identity and serves them |
GET /sse |
Server-Sent Events stream for live dashboard / case-detail updates |
GET /login, GET /logout |
OIDC session establish / tear-down. |
GET /auth/callback, GET /auth/landing, GET /auth/select, GET /auth/local-login |
OIDC sign-in surface (Stage 4 #493/#494 — htmx IdP discovery + chip-select + local-account stub). |
GET /v1/auth/discover |
htmx IdP-discovery fragment for the sign-in page |
GET /livez, GET /readyz |
Liveness / readiness probes |
GET /theme.css |
Dynamic jurisdiction-specific theme stylesheet (served as a real stylesheet for strict-CSP compatibility) |
GET /api-doc/openapi.json |
OpenAPI JSON for the composition surface only ( |
/static/* |
Static asset serving ( |
Error Codes
Status codes emitted across the page, action, and composition surfaces. Page/action routes mostly return HTML (or a 303 redirect back to the originating page); the /v1/composition/* JSON surface returns the precise codes below with a structured error body.
| Status | Meaning |
|---|---|
200 OK |
Page or htmx fragment rendered; composition GET returned a document |
204 No Content |
Composition DELETE succeeded (or was idempotent no-op — no row existed) |
303 See Other |
Action handler / auth route redirect back to the originating page ( |
400 Bad Request |
Malformed action form / missing required field (e.g. |
401 Unauthorized |
No active session on a protected JSON composition request |
403 Forbidden |
Role gate failed — |
404 Not Found |
Unknown route (fallback handler), unknown household/application, unknown composition surface, or |
412 Precondition Failed |
|
415 Unsupported Media Type |
PATCH against a dashboard composition surface (the |
422 Unprocessable Entity |
|
428 Precondition Required |
Composition write missing a required |
429 Too Many Requests |
Per-IP rate limit exceeded (governor middleware) |
500 Internal Server Error |
Composition loading failed or an unexpected template-render error occurred. On the six deadline-covered read routes an upstream data failure no longer 500s — it renders the honest per-component degraded/error state (see SSR deadlines & degraded states); action (POST/PUT) proxies and the not-yet-covered read pages (#1319) still surface upstream failures as 5xx |
501 Not Implemented |
|
Backend Service Clients
canopy-web proxies all data requests to backend JSON API services:
| Service | Purpose |
|---|---|
canopy-persons |
Household and member data, income CRUD |
canopy-applications |
Application queue, processing, per-program intake sections + complete-data-collection |
canopy-eligibility |
Eligibility determination orchestration, run-determination, case-status, cross-program alerts, determinations |
canopy-verification |
Verification requests (request-verification action) |
canopy-snap |
ABAWD tracking, discrepancies, SNAP parameters |
canopy-tanf |
Work activities, TANF discrepancy resolution |
canopy-medicaid |
CMD ingest, quarantined-determination re-queue |
canopy-caps |
Authorization updates, provider switch |
canopy-wic |
Certification appointments, nutritional-risk assessments |
canopy-renewals |
Certification periods, interim contacts, change reports |
canopy-notices |
Notice history and PDF download, command-palette search |
canopy-appeals |
Appeal filing and status |
canopy-security |
Audit events for activity tab |
Security
-
Strict CSP: no
unsafe-inlineorunsafe-eval(Alpine.js CSP build, nonce-based styles) -
CSRF protection on all POST routes
-
HttpOnly session cookie (SameSite=Lax)
-
Per-IP rate limiting — replica-aware Redis fixed window when
CANOPY_WEBRATE_LIMIT_REDIS_URLis set (#1227: the effective limit is invariant under replica count; on Redis failure it degrades to the process-local governor limiter, never to unlimited); rpm viaCANOPY_WEBRATE_LIMIT_RPM -
All backend calls authenticated via the user’s JWT (forwarded from session)