canopy-applications API Reference
On this page
Overview
Cross-link: canopy-applications Data Model (#419)
Application intake service. A single application can request multiple programs (SNAP, TANF, Medicaid, CHIP, CAPS, WIC). Handles expedited SNAP screening (7 CFR 273.2(i)), per-program processing deadlines, and authorized-representative designation per ACA §1413.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT) — service-class tokens only post-ADR-019 cutover (#439)
- Minimum role
-
Varies per endpoint (see below)
- Swagger UI
- Database
-
canopy_applications(isolated per ADR-001)
Receiver contract (OIDC S-applications, #1429 / ADR-043 §C)
canopy-applications is the fifth service on the ADR-043 receiver
contract — the fleet’s first ZERO-swap adoption: no pure human-role
gates exist here, so no route’s minimum role changed. The
exchanged_gate (threaded through the shared app::build_router
assembly) vets exchanged-shaped bearers for exact audience
(canopy-applications) and allowlisted azp (canopy-web-exchanger
only — least privilege); the service-only guards then 403 exchanged
bearers, and the dual sections/ele-consent role bars admit well-formed
exchanged workers with no handler changes. Since C1 (#1443) the
document review trio (accept/reject/scan-override) rides
require_service_or_exchanged with an in-handler human projection —
the reviewer is the exchanged bearer’s own sub, bare service traffic
is 403, and the retired X-Canopy-Actor verifier is gone (the
middleware 401s any request carrying the header).
See the tanf API page
for the bearer-shape and guard-family description; receiver knobs are
in the configuration reference.
Applications
POST /v1/applications
Create a new application. Atomic — the applications row, all application_programs rows, and any expedited screening result commit in a single transaction (regression covered by create_with_three_programs_and_expedited_is_atomic; see #314).
Minimum role: caseworker (or service-class token).
Request:
{
"household_id": "uuid",
"programs_requested": ["snap", "medicaid"],
"submission_channel": "in_person",
"submitted_by": "uuid",
"submitted_by_role": "caseworker",
"authorized_representative_id": null,
"received_at": "2026-05-10T14:30:00Z",
"expedited_screening": {
"gross_monthly_income_cents": 15000,
"liquid_assets_cents": 10000,
"monthly_rent_or_mortgage_cents": 50000,
"monthly_utilities_cents": 10000,
"is_migrant_farmworker": false
},
"tanf_service_type": null
}
programs_requested is validated against canopy_reference::Program (snake_case strum) — invalid values return HTTP 422 with every bad value listed (#399).
Response (201): ApplicationWithPrograms — the application row plus per-program rows with deadlines.
GET /v1/applications
List applications. Empty filter returns the most recent 50 rows ordered by received_at desc.
Minimum role: caseworker.
Query parameters (#402):
| Parameter | Meaning |
|---|---|
|
UUID — applications for a single household |
|
UUID — applications submitted by a specific worker |
|
snake-case program code — applications requesting this program (validated; returns 422 on unknown) |
|
plural form (MR3) — repeatable |
|
application status ( |
|
plural form (MR3) — repeatable |
|
inclusive lower bound on |
|
inclusive upper bound on |
|
max rows (default 50, capped at 200) |
|
pagination offset (default 0) |
Plural programs[] / statuses[] are parsed via axum_extra::extract::Query (the stock axum::extract::Query collapses repeated keys to the last value). Filters combine with AND. The program filter uses an EXISTS join against application_programs.active = true; the partial indexes added in 20260510000000_add_list_filter_indexes.sql keep this index-only at scale.
Response (200): array of Application.
POST /v1/applications:batchGet
Get the COMPACT reporting core for a set of applications in one round-trip
(#1203, D5 row 2) — one id = ANY($1) primary-key probe replacing the
federal extracts' per-case application GET N+1. Capped at 500 IDs per
request (422 on overflow); duplicates collapse to one entry; the
response follows first-occurrence request order. Service callers only
(§B4 bulk-read posture) — 403 for worker JWTs.
ApplicationCore is deliberately NOT ApplicationWithPrograms: no program
rows, no interview/waiver detail, no confidentiality — exactly the fields
the extract folds read, so a 500-application response stays bounded under
the 2MiB idempotency-replay cache.
Absent semantics: a missing or inactive (withdrawn/soft-deleted)
application is simply ABSENT from the result — consumers diff the requested
id set (the reporting fold counts each absence in its cert_type_unknown
bucket, #1155). expedited_eligible is null when the application was
never expedited-screened — counted expedited_unknown downstream, never a
fabricated false.
Request: BatchGetApplicationsRequest
{ "application_ids": ["uuid", "uuid"] }
Response (200): Vec<ApplicationCore> —
[{ id, household_id, status, expedited_eligible, received_at }].
GET /v1/applications/caseload-trend
Application-inflow time-series for the supervisor/analyst dashboard trend sparkline (#718). Counts applications.received_at into day or week buckets over a look-back window, zero-filled via generate_series so the series is continuous (a bucket with no inflow is count: 0, never omitted). This is the operational caseload feed; it is owned by canopy-applications (the holder of received_at), distinct from the federal reporting surfaces in canopy-reporting.
It measures application inflow over time (the workload arriving) — not open-queue-depth over time, which would require point-in-time caseload snapshots that do not exist yet.
Minimum role: caseworker (service-caller).
Query parameters:
| Parameter | Meaning |
|---|---|
|
look-back as |
|
|
|
optional snake-case program code; counts only applications requesting that program (validated; 422 on unknown). |
The day/week granularity is chosen by matching the validated bucket against a closed set, so no caller input is interpolated into the date_trunc SQL. No migration — the query is a GROUP BY date_trunc(…) read over the existing received_at index.
Response (200): CaseloadTrend — { buckets: [{ bucket_start, count }], bucket }, oldest bucket first.
GET /v1/applications/{id}
Get an application with its per-program rows.
Minimum role: service-class, or the scoped portal credential (portal:applications:read, #1441; since #1442 the portal arm also requires the signed ownership claim binding this application) — there is no direct worker arm on this route.
Response (200): ApplicationWithPrograms.
PUT /v1/applications/{id}
Update application metadata. Only submission_channel, authorized_representative_id, and programs_requested can be changed (the latter via the same transactional sync used at intake — #400).
Minimum role: caseworker.
Request:
{
"submission_channel": "online",
"programs_requested": ["snap", "medicaid"]
}
When programs_requested is present it’s the complete desired set: programs already on the application stay (preserving processing_deadline, expedited, tanf_service_type); programs in the list but not on the application get a fresh row with a freshly-computed deadline; programs on the application but not in the list are soft-deleted (active = false — history row retained for audit). Same #399 validation applies — invalid program codes return 422.
Response (200): ApplicationWithPrograms post-update.
DELETE /v1/applications/{id}
Withdraw an application (soft-delete: active = false, status = "withdrawn").
Minimum role: caseworker.
POST /v1/applications/{id}/interview/waive
Waive the interview requirement.
Minimum role: caseworker.
Request:
{ "reason": "elderly_disabled" }
Valid reasons: elderly_disabled, hardship, homebound.
POST /v1/applications/{id}/interview/complete
Mark the interview as completed (stamps interview_completed_at).
Minimum role: caseworker.
POST /v1/applications/{id}/programs/{program}/determination
Record a program-level determination result on the application. Called by the canopy-web worker BFF after a POST /v1/eligibility/determine succeeds (the orchestrator returns the per-program determination_id in the DetermineResponse); not called directly by humans.
Minimum role: service-class token.
Request:
{
"determination_id": "uuid",
"status": "approved",
"denial_reason_codes": null
}
status ∈ approved / denied / pending. denial_reason_codes required when status = "denied".
Writing the per-program row and recomputing the parent applications.status happen in one transaction (Plan 4 MR4 / G5): when every active application_programs row is terminal (determined / approved / denied / withdrawn) the application flips to determined, which drops it from the worker queue (GET /v1/applications?statuses=submitted&statuses=processing); the first non-pending program moves a submitted application to processing. 404 if no active application_programs row matches {program}.
Intake Sections (Plan 1 worker intake, #620)
Per-program intake-section storage backing the worker-portal guided intake. Each section is one row per (application_id, program, section_name) while active = true; soft-delete via active keeps the audit trail. section_name is a closed-set slug — one of household_composition, identity, citizenship, residency, income_employment, resources, expenses_shelter, work_registration, special_circumstances, tanf_child_support, tanf_personal_responsibility, tanf_time_limits. Per-section applicability filters by program slug (the storage column is TEXT[]).
GET /v1/applications/{id}/sections
List the active intake sections for an application.
Minimum role: caseworker (or service-class token).
Response (200): array of ApplicationSection. Returns 404 if the application is unknown.
PUT /v1/applications/{id}/sections/{program}/{section}
Upsert a typed-per-section JSON payload for one (program, section) pair. The body payload is validated against the matching per-section struct at write time; the stored DTO surfaces it as JSON. last_edited_by records the worker’s Keycloak sub (not FK’d — workers are not persisted in canopy-persons).
Minimum role: caseworker (or service-class token). The bearer must be service-class or caseworker-or-above; otherwise 403.
Request: typed-per-section JSON payload (shape varies by section). The three SNAP verification sections (income_employment, resources, expenses_shelter) carry a verifications array that is min = 0 and serde(default) — a caller may omit it entirely and it deserializes to [] (a no-income / categorically-eligible / homeless household legitimately collects none). household_composition requires a members array of at least one { person_id, relationship_to_head, … } ref; the worker-portal BFF synthesizes this from the canopy-persons household membership rather than asking the worker to re-type it.
Response (200): the upserted ApplicationSection. 401 when the bearer is missing/invalid or lacks sub; 403 when the bearer is neither service-class nor caseworker-or-above; 422 for an unknown section, a program not on the application, a section not applicable to the program, or a payload that fails validation.
POST /v1/applications/{id}/programs/{program}/complete-data-collection
Gate that marks a program’s data collection complete. On success it returns the updated ApplicationProgram row plus the recomputed container applications.status string. On gate failure it returns 422 with the list of missing section slugs instead.
Minimum role: caseworker (or service-class token).
Response (200): CompleteDataCollectionResponse — { "application_program": ApplicationProgram, "applications_status": "…" }. 404 if the application is unknown or the program is not active on this application; 422 if one or more applicable sections lack a completed_at.
Applicant Credentials (Plan 3 MR5a, ADR-026)
The applicant-portal login / resume primitive. The privacy-first portal (canopy-portal, ADR-026) forwards an applicant-entered credential; canopy-applications verifies it against the reserved-id credential tables (application_id_codes + passcode_hashes) and returns the reserved application_id. The applicant has no token of their own at this point — this is how they authenticate — so the portal posts with a service token (ADR-019).
POST /v1/applicants/verify-credential
Verify an HH-[a-f0-9]{8} Application ID code + a 12-digit passcode (NNNN-NNNN-NNNN).
Minimum role: the portal’s citizen-class credential only, since #1441 (azp allowlist + portal:intake scope — the applicant authenticates through the portal; there is no applicant bearer yet). Any other principal, service class included, returns 403 portal_only_route.
Request:
{
"code": "HH-0a1b2c3d",
"passcode": "4821-0073-9156"
}
The passcode is normalised (dashes + whitespace stripped) before verification, so IVR-keypad / read-aloud entry without the cosmetic dashes succeeds. Verification requires an active (revoked_at IS NULL) passcode_hashes row — a rotated/revoked passcode never authenticates even though its row persists for audit.
Response (200): VerifyCredentialResponse — { "application_id": "uuid" }. The reserved id may belong to an in-flight draft (no applications row yet, per the ADR-026 reserved-id lifecycle) or a submitted application.
401 — returned for both an unknown code and a wrong (or malformed) passcode, with an identical status + body (the standard RFC 9457 problem response): the endpoint is deliberately not a credential oracle, and the store layer equalises argon2 verify timing for unknown codes so response time cannot enumerate valid codes. 403 — caller is not the scoped portal credential (portal-only since #1441).
Applicant Drafts (Plan 3 MR6, ADR-026)
The Apply-form draft endpoints back the privacy-first incremental application flow. The portal collects the multi-step form in the WASM client, encrypts each step’s partial payload under an Argon2id key derived from the applicant’s passcode, and stores only ciphertext here — the server never reads the plaintext until finalize (MR6c). Every endpoint in this group is portal-only since #1441 (OIDC P2): the applicant authenticates through the portal (a Redis session minted at create-draft), and the portal vouches for the reserved id with its citizen-class credential (azp allowlist + the portal:intake operation scope), the way verify-credential does — ordinary service bearers are 403 portal_only_route here (the reaper trigger is operator tooling and stays service-only). Since #1442 every {id}-keyed call additionally carries the portal’s signed X-Canopy-Applicant ownership claim binding the session’s application — no claim is 403 ownership_claim_missing, a foreign binding is 403 ownership_mismatch. Create-draft is exempt (no id exists yet). See ADR-026 for the reserved-id lifecycle.
POST /v1/applicants/drafts
Create-draft: mint a reserved application_id + HH-[a-f0-9]{8} code + 12-digit passcode + an empty client-side-encrypted draft row (keyed on the reserved id, no FK to applications). The HH-… code is only ~32 bits, so the store-layer insert is wrapped in a bounded collision-retry (10 attempts) on the application_id_codes.code UNIQUE constraint — the retry the MR4 generator deferred to its live-DB caller.
Minimum role: the portal’s citizen-class credential only, since #1441 (azp allowlist + portal:intake scope). Any other principal, service class included, returns 403 portal_only_route.
Response (201): CreateDraftResponse — { "application_id": "uuid", "code": "HH-…", "passcode": "NNNN-NNNN-NNNN", "kdf_salt": "<base64>", "enc_version": 1 }. The raw passcode is returned once, over TLS, so the client can derive the Argon2id draft key; the server persists only the one-way hash + the non-secret kdf_salt. 403 — caller is not the scoped portal credential (portal-only since #1441).
PATCH /v1/applicants/drafts/{id}
Patch-draft: replace the per-step ciphertext for a reserved-id draft and slide the 30-day expiry (expires_at = now() + 30 days).
Minimum role: the portal’s citizen-class credential only, since #1441 (azp allowlist + portal:intake scope). Any other principal, service class included, returns 403 portal_only_route.
Request:
{
"ciphertext": "<base64 XChaCha20-Poly1305 ciphertext>",
"nonce": "<base64 nonce>",
"enc_version": 1,
"current_step": 2
}
The server stores ciphertext / nonce blind — it never decodes the plaintext.
204 — draft updated. 404 — no draft with that reserved id. 422 — malformed base64 ciphertext / nonce, or current_step outside 1–4 (rejected at the handler, not as a DB CHECK 500). 403 — caller is not the scoped portal credential (portal-only since #1441).
GET /v1/applicants/drafts/{id}
Get-draft (#727 resume): return the stored ciphertext blob so the passcode-holding WASM client can decrypt the saved step locally and pick up where it left off. Shares the {id} path with patch-draft (GET vs PATCH are distinct operations on the same path item). The server reads the ciphertext blind — only the client decrypts. The portal POST /apply/resume proxy supplies {id} from a freshly verified credential (verify-credential → reserved id), never client input — the IDOR boundary lives in the BFF.
Minimum role: the portal’s citizen-class credential only, since #1441 (azp allowlist + portal:intake scope). Any other principal, service class included, returns 403 portal_only_route.
Response (200): GetDraftResponse —
{
"application_id": "<reserved uuid>",
"current_step": 2,
"ciphertext": "<base64 XChaCha20-Poly1305 ciphertext>",
"nonce": "<base64 nonce>",
"kdf_salt": "<base64 per-draft Argon2id salt>",
"enc_version": 1,
"expires_at": "2026-07-08T12:00:00Z",
"last_saved_at": "2026-06-08T12:00:00Z"
}
ciphertext / nonce are empty strings for a draft created but never saved (the client treats that as a blank form at step 1, still bound to the same reserved id). 404 — no draft with that reserved id, or it has expired (expires_at ⇐ now()): a stale-but-unreaped row is never served. 403 — caller is not the scoped portal credential (portal-only since #1441).
POST /v1/applicants/drafts/{id}/finalize
Materialise-at-finalize (Plan 3 MR6c). The client submits the plaintext application (FinalizeRequest — the primary applicant, household_members, income, assets, expenses (each serde(default) — omittable), programs_requested, ele_consent, optional notify_email / notify_phone_e164, optional address (#1137 — the apply-wizard address, claimed as the head’s residential canopy-persons fact effective from the filing date, so a portal-filed household resolves a notices recipient without worker intervention; omittable — filing is never conditioned on an address), and confidentiality (#1137 — the wizard’s safety election, standard/confidential/address_confidential/both, persisted to applications.confidentiality; confidential/both disable self-serve recovery per applicant-portal design ref §3.8)); canopy-applications creates persons → household → members → address → income / assets / expenses over the canopy-persons service API (ADR-019 service token), then in one transaction inserts the applications row with the reserved id (status submitted) + per-program rows and deletes the application_drafts row — keeping the credentials, which become the submitted application’s login. The reserved-id draft is SELECT … FOR UPDATE-locked first, so this serialises with the reaper.
The income/asset/expense entries are authored into the canopy-persons version corpus (T1-7 #675) as applicant self-report claims — Author::Applicant (the finalize-resolved household_id, ADR-027 §9 — no portal-session identity), self_attestation source, auto-accepted accepted_unverified (which feeds determinations) — each emitting an attributed {income,asset,expense}.claimed event (per-fact, unbatched; see canopy-persons § Events Published). Every fact’s person_index + amount is validated before any cross-service write, so a malformed fact 422s without orphaning persons rows.
Minimum role: the portal’s citizen-class credential only, since #1441 (azp allowlist + portal:intake scope). Any other principal, service class included, returns 403 portal_only_route.
The persons writes span canopy-persons (a separate service/DB per ADR-001) and are not in the local transaction — the single-transaction guarantee is specifically the applications INSERT + draft DELETE pair (ADR-026 §5). A finalize runs an early non-locking existence check before those writes, so a draft that is already gone 404s without orphaning persons rows.
Two paths behind finalize_saga_enabled (default off until epic &71 MR8; see ADR-038). With the flag on, finalize runs as the recoverable saga: a linearizable draft-row-locking claim on finalize_operations serialises concurrent attempts; every persons write carries the idempotent (operation, generation, step) receipt tag; a resumed attempt skips receipted steps (local finalize_steps cache) and rebuilds its remaining writes from the pinned basis_date and the digest-validated original request (a keyed HMAC digest binds each generation to its request bytes — an edited request cannot resume); the final transaction is network-free (draft lock → applications INSERT → lease-fenced mark_completed → draft DELETE); persons events stay held until the post-commit release, so downstream never sees a partial finalize. With the flag off, the pre-saga flow runs (persons writes untagged, the ADR-026 §5 orphan window retained).
Response (201): FinalizeResponse — { "application_id": "uuid", "household_id": "uuid" }. The application_id equals the reserved draft id. When ele_consent is true, finalize emits application.ele_consent_recorded (consent_source = "applicant_portal"). On the saga path a retry of an already-completed finalize replays the identical 201 (retry-after-lost-response) with no new persons writes.
404 — no draft with that reserved id (reaped / already finalized). 422 — unknown program, an income/asset/expense person_index out of range, an unparseable amount/value, or a malformed address (blank line_1/city, a non-two-letter state, or a ZIP outside 5–10 chars — validated before any cross-service write, #1137). 403 — caller is not the scoped portal credential (portal-only since #1441). 409 — on the saga path, the request differs from the attempt already in flight for this draft (pinned request-digest mismatch); on any path, an Idempotency-Key reused with a different body (the platform idempotency layer wraps every service route). 503 + Retry-After — on the saga path, another attempt holds the operation lease or the reconciler is compensating; on any path, an in-flight Idempotency-Key duplicate. Retry after the indicated seconds.
POST /v1/applicants/drafts/reap
Draft-reaper admin trigger (Plan 3 MR6d, ADR-026 §6). Deletes every expired draft (expires_at < now(), where expires_at is last_saved_at + 30 days rewritten on every save) and its reserved credentials (application_id_codes + passcode_hashes) on demand, and returns the count. The same sweep runs unprompted on a daily background tick (leader-elected across replicas via run_with_advisory_lock); this endpoint forces it (e.g. after a retention-policy change) and makes the timer-driven job HTTP-testable.
The sweep is one transaction that SELECT … FOR UPDATE SKIP LOCKED`s the expired drafts, then does explicit ordered deletes (`passcode_hashes → application_id_codes → application_drafts) — deliberately not ON DELETE CASCADE, because finalize deletes a draft while keeping its credentials (they become the submitted app’s login), so the cascade direction must not exist. SKIP LOCKED serialises with finalize on the draft row: a draft whose finalize is in flight is skipped this sweep, so the reaper never deletes the login of an application that is about to be submitted.
Lease/compensation-aware (ADR-038, epic &71 MR6). The sweep also excludes any expired draft whose finalize_operations row is non-terminal (in_progress / compensating) — the saga may still commit (its final transaction needs the draft row) or the reconciler is mid-compensation. The draft becomes reapable again once the operation is terminal (aborted — by which point the draft’s own 30-day expiry has long passed — or completed, where finalize already deleted it). Relatedly, draft_exists (the finalize fast-fail) is live-only: an expired-but-unswept draft reads as absent, aligned with get_draft and the claim’s expiry-checking lock.
Minimum role: service-class token only — operator tooling, not an applicant-reachable route.
Response (200): ReapDraftsResponse — { "drafts_reaped": 0 }. 403 — caller is not service-class.
Applicant Recovery (Plan 3 MR8a, ADR-026 / applicant-portal design ref §3.4-3.8)
The lost-credential self-serve recovery flow — a safety-critical intimate-threat defense. Both endpoints are portal-only since #1441 (citizen-class credential + portal:intake; any other principal, service class included, is 403 portal_only_route): the portal /recover wizard (MR8b) proxies them; the portal owns the public-facing controls (the rate-limit cascade + the Turnstile step-up gate). Recovery applies to submitted applications only — a reserved-id draft (no applications row) never matches.
The security boundary is not the challenge answers (applicant-portal design ref §3.7 says so explicitly): it is the Application-ID gate + the date-of-birth second factor + the confidential-case block + the 24-hour delayed reveal + the kill-switch + the side-channel notification to the application-time contact. See ADR-026.
POST /v1/applicants/recover/initiate
Runs the recovery challenge: App-ID gate (resolve the HH-… code to a submitted application) → confidential/locked short-circuit → DOB second factor (verified cross-service against the submitter’s canopy-persons record). On success, mints a 24-hour pending recovery (idempotent against the active-per-app index — a re-initiation while one is pending is absorbed and does not re-notify the contact) and stages application.applicant.recovery_initiated.
Always returns 200; the outcome is in the body, never the status code, so the endpoint is not a case-enumeration oracle:
-
{ "outcome": "pending", "recovery_id": "uuid", "reveal_at": "rfc3339" }— challenge passed. The passcode is not revealed now (it becomes available atreveal_at, MR8c) and a kill-switch notification is sent to the application-time contact. -
{ "outcome": "confidential_blocked" }— the case is confidential or recovery-locked; route to the helpline (applicant-portal design ref §3.8). A worker-visibleapplication.applicant.recovery_confidential_blockedevent fires. This is a deliberate, §3.8-accepted disclosure (hiding the affordance would itself leak case existence). -
{ "outcome": "challenge_failed" }— no case matched the code, or the DOB was wrong. Deliberately uniform across both so the response does not distinguish an unknown code from a wrong second factor.
recent_letter_id / approx_decision_year are accepted (the §3.7 friction challenges) but not yet server-verified — additive, tracked in #662. 403 — caller is not the scoped portal credential (portal-only since #1441).
POST /v1/applicants/recover/kill/{token}
The "this wasn’t me" one-tap kill-switch (applicant-portal design ref §3.4/§3.7). Cancels the active pending recovery for that kill-switch token (a 256-bit secret carried in the notification) and locks the case (applications.recovery_locked — a worker must clear it before self-serve recovery is available again), staging application.applicant.recovery_killed.
Response (200): RecoverKillResponse — { "application_id": "uuid" }. 404 — no active recovery for that token (unknown / already terminal; the token is a secret, so this is not a meaningful oracle). 403 — caller is not the scoped portal credential (portal-only since #1441).
GET /v1/applicants/recover/{recovery_id} (Plan 3 MR8c)
The internal read the canopy-notices recovery subscriber calls to compose the side-channel notification. The application.applicant.recovery_initiated event carries only IDs + the reveal timestamp (ADR-004), so the application-time contact + the kill-switch token are read from the recovery_pending row here — off the broadcast bus. Service-caller only; never applicant-reachable.
Response (200): RecoverDetailResponse — { "application_id": "uuid", "kill_switch_token": "…", "reveal_at": "rfc3339", "notify_email": "…", "notify_phone_e164": "…" } (notify_* may be null). No passcode (value or hash) is ever returned. 404 — no recovery with that id. 403 — caller is not service-class.
Authorized Representatives (ACA §1413)
Per-household designation. Soft-deleted via active = false — rows persist for Pub 1075 §9 audit retention even after the designation ends.
POST /v1/households/{household_id}/authorized-representatives
Designate an authorized representative on a household.
Minimum role: caseworker.
Request:
{
"representative_person_id": "uuid",
"relationship": "attorney",
"written_consent_on_file": true,
"effective_date": "2026-05-01",
"expiration_date": null
}
GET /v1/households/{household_id}/authorized-representatives
List active representatives for a household.
Minimum role: caseworker.
Household Assignments (#408 Pub 1075 AC-6)
Per-worker case assignment. Sole source of truth for the household-RBAC gate consumed by canopy-enrollment’s GET /v1/households/{id}/issuances (and, on adoption, by other services with household-scoped reads).
All endpoints require a service-class JWT (claims.require_service_caller()). The mutations (POST/DELETE) are system-provisioning surfaces — post-#1443 a service bearer never transports a human, so the old delegated-supervisor bar is deleted (it could no longer fire); seed / scheduled-assignment workflows call as a bare service, and a future worker-delegated surface would flip to require_service_or_exchanged with a supervisor/admin bar.
Soft-delete via unassigned_at (ADR-016). The (worker_id, household_id) unique index is partial on WHERE unassigned_at IS NULL, so reassigning a worker to a household after unassignment is allowed.
POST /v1/workers/{worker_id}/assignments
Assign a worker to a household.
Request:
{
"household_id": "uuid"
}
Response (201): the new HouseholdAssignment row.
DELETE /v1/assignments/{id}
Soft-delete an assignment. Returns 204 on success, 403 if the on-behalf-of actor is not supervisor/admin, 404 if the id is unknown or the row was already unassigned.
GET /v1/workers/{worker_id}/assignments/household-ids
One keyset page of the worker’s ACTIVE-assignment household ids (#596) — the IDs-only authorization projection (ADR-001 Amendment 1 §B4 data minimization) consumed by canopy-eligibility’s cross-program-alerts scoping. Query params: limit (clamped [1, 200], default 50) and after (keyset cursor, strictly-greater household id). Response {items, next_cursor} per §B2: a full page carries next_cursor = the last item; a short page is the end. Ordered by household_id ascending, riding the active-only partial unique index. Service-caller only.
Application Documents (Plan 3 MR9a)
Applicant + worker document uploads. (As on every mutating route, an Idempotency-Key reused with a different body also surfaces as 409 via the platform idempotency layer — distinct from the quarantine 409s documented per-endpoint below.) Upload + list serve services OR the scoped portal credential since #1441 (portal:documents:write / portal:applications:read — the portal arrives citizen-class now); since #1442 the portal arm also requires the signed ownership claim binding this application, and the UPLOAD additionally binds the form’s person_id to the session’s own submitting person before any object is written (the ownership defense #665 tracked). Content stays service-reachable; accept and reject are worker-review surfaces on the EXCHANGED bearer since #1443 (see below). The portal BFF still derives the path application_id from the server-trusted session (never client input, the finalize pattern) — and since #1442 the ORIGIN independently verifies that binding via the signed ownership claim, so the BFF is defense-in-depth rather than the sole boundary. Reads + accept/reject are application_id-scoped in the store, so a document id can never resolve under a different application. The WORKER review surface requires the reviewing worker’s EXCHANGED bearer (#1443 — the ADR-019 actor JWT is retired): the reviewer is the bearer’s own subject, and bare service traffic is refused. The applicant-side ownership check #665 tracked is BUILT: the portal-applicant keypair is the applicant-token signer that ADR-026’s opaque sessions were thought to preclude, riding its own X-Canopy-Applicant channel (the worker actor channel it was once contrasted with is retired — #1443).
POST /v1/applications/{id}/documents
Upload a document as multipart/form-data. The file flows through the zero-trust canopy-store::validate_upload pipeline (#435 — size, magic-byte verification, MIME allowlist, SHA-256, filename sanitisation); on success the object is stored under {program}/{application_id}/{sha256} and the metadata row is returned quarantined at scan_status = pending (ADR-042/#1006): scanning is asynchronous — the promotion worker settles pending → clean | infected | skipped | error with full provenance (scan_backend, scan_backend_version, scanned_at, scan_detail), and every serving/review surface gates on the derived scan_viewable (clean, or skipped carrying the audited supervisor override).
Parts: file (required — the bytes, with a filename + Content-Type), person_id (required — the person the document is about), document_type (required — identity / income / residency / citizenship / other), document_kind (optional — photo_id / ssn_card / pay_stub / lease / other), program (optional — storage namespace, defaults to snap), uploaded_by_source (optional — applicant_portal (default) / worker_intake).
Response (201): ApplicationDocument — the post-validation metadata, including the lowercase-hex sha256, the derived review_status (pending / accepted / rejected), the typed scan_status (ADR-042 — the noop token is dead), and scan_viewable (always false at upload). scan_detail (AV signature names / skip reasons) is worker-facing: the portal BFF projects it away before anything reaches an applicant’s browser. The storage path (s3_bucket / s3_key) is intentionally not exposed — fetch the bytes via the content endpoint. 404 — unknown application (checked before any object is written, so a bad id never orphans an object). 422 — empty / oversize file, MIME mismatch or disallowed type, or a missing/invalid required field. 403 — caller is neither a service-class principal nor the scoped portal credential (#1441).
The service-wide request body limit is raised to 11 MiB on canopy-applications (10 MiB per-file ceiling + multipart-framing headroom); validate_upload is the authoritative per-file size gate.
GET /v1/applications/{id}/documents
List an application’s documents, newest first. Response (200): [ApplicationDocument]. 403 — caller is neither a service-class principal nor the scoped portal credential (#1441).
GET /v1/applications/{id}/documents/{document_id}/content
Serve the stored bytes back (worker preview) with the original Content-Type and Content-Disposition: inline (the global nosniff header still applies). Since ADR-042 this endpoint is the quarantine’s byte gate: it refuses 409 unless the document is scan-viewable, re-reads the FULL object (bounded by the 10 MiB upload cap) and re-verifies sha256+size against the row before serving — a replaced object is unservable regardless of scan state — and stamps Cache-Control: no-store on every response (success and error), so a browser can never replay bytes past a later quarantine. 404 — no document with that id under this application. 409 — quarantined (pending / infected / un-released skipped / error). 403 — caller is not service-class.
POST /v1/applications/{id}/documents/{document_id}/accept
Worker accept. No request body (#1009, pre-1.0 break — the former AcceptDocumentRequest { accepted_by } was spoofable by any trusted service-class caller): the reviewing worker arrives ONLY as the verified ADR-019 X-Canopy-Actor JWT (minted by canopy-web with its web-actor key; this service verifies signature/audience/expiry via its ActorVerifierAdapter and projects the verified sub onto accepted_by). Sets accepted_at / accepted_by and clears any prior rejection (accept + reject are mutually exclusive). Response (200): the updated ApplicationDocument (review_status = accepted). Since ADR-042 the viewability predicate lives INSIDE the UPDATE (no check-then-update race; ck_docs_accepted_viewable backstops in the DB): 409 — quarantined, not acceptable until scan-viewable. 404 — no document with that id under this application. 403 — caller is not service-class, or no verified actor accompanied the request (a review must name a real reviewer — Pub 1075 §9). 422 — the verified actor subject is not a worker UUID. (A PRESENT-but-invalid actor JWT — expired, wrong audience, bad signature — is 401 at the auth middleware before the handler runs; only a wholly ABSENT actor reaches the 403 here.)
POST /v1/applications/{id}/documents/{document_id}/reject
Worker reject. Body: RejectDocumentRequest — { "rejection_reason": "…" } (required, non-empty). The rejecting worker comes from the verified X-Canopy-Actor claim exactly like accept (#1009) and lands on the new rejected_by column. Sets rejection_reason / rejected_by and clears any prior acceptance. Response (200): the updated ApplicationDocument (review_status = rejected, rejected_by populated). 409 — quarantined: review is a content judgment, so rejection too requires a scan-viewable document (ADR-042). 404 — no document with that id under this application. 422 — empty rejection_reason, or a non-UUID actor subject. 403 — caller is not service-class, or no verified actor.
POST /v1/applications/{id}/documents/{document_id}/scan-override
Release a quarantined-skipped (unscannable — e.g. password-protected) document: the ADR-042/ADR-041 audited accountable override. Requires a verified X-Canopy-Actor whose realm roles include supervisor or admin — enforced at THIS origin, not just the BFF affordance. Body: ScanOverrideRequest — { "reason": "…" } (8–500 chars after trim). Valid only from skipped with no prior override; the reason lands on the row and its SHA-256 digest rides the application_document.scan_overridden audit event (never the prose — ADR-004). Response (200): the updated ApplicationDocument (scan_viewable = true). 409 — not skipped, or already overridden (a duplicate POST is a clean conflict). 404 — unknown document. 403 — caller not service-class, no verified actor, or the actor lacks a release-authorized role. 422 — reason out of bounds, or a non-UUID actor subject.
POST /v1/applications/{id}/documents/{document_id}/rescan
Service-only requeue for a fresh scan (the supported recovery for terminal error rows — no direct SQL): atomic reset to pending under a bumped scan generation; provenance, attempts, claim, override, and any standing acceptance clear (the verdict is in doubt; application_document.scan_requeued — plus acceptance_revoked when an acceptance fell — stage in the same transaction). ADR-007 CLI parity: canopy application document-rescan --application-id … --document-id …. Response (200): the updated ApplicationDocument (scan_status = pending). 409 — already pending. 404 — unknown document. 403 — caller is not service-class.
Expedited SNAP Screening
Driven by ExpeditedScreeningData on the create request. The intake handler evaluates 7 CFR 273.2(i): gross income < $150 AND liquid assets ≤ $100 (the regulation’s edges are asymmetric — "less than" income vs resources that "do not exceed"; #1150), OR combined income+assets < shelter+utilities, OR migrant farmworker (assets ≤ $100). If any condition matches the application is flagged expedited = true and the SNAP processing_deadline tightens from the standard window to the expedited one (both day counts from [shared.application_processing] per ADR-011). Deadline dates anchor to the jurisdiction’s legal receipt day — date_in(received_at, [jurisdiction].timezone), never the UTC calendar date, which is already tomorrow from local evening (#1581). The expedited result is committed inside the intake TX — no half-loaded reader state.
Error Codes
| Code | Meaning |
|---|---|
400 |
Malformed JSON or missing required field |
401 |
Missing or invalid JWT, or a bearer with no |
403 |
Insufficient role for the endpoint (caller not caseworker-or-above / not service-class; assignment mutations require supervisor/admin actor) |
404 |
Application, representative, assignment, or program-on-application not found |
409 |
Status transition not allowed (e.g., update after |
422 |
Invalid |
Events Published
ADR-042 (#1006) adds the document scan lifecycle (all staged in the owning transaction; canopy-security indexes them by document_id with the releasing supervisor as actor where present):
-
application_document.scan_completed— a verdict settled (scan_status,scan_backend,scan_backend_version; includes terminalerrorclasses — budget exhaustion, content-identity mismatch, missing object) -
application_document.scan_overridden— a supervisor released quarantined-skippedcontent (overridden_by,reason_sha256— the prose reason stays on the row) -
application_document.acceptance_revoked— a standing acceptance fell because its verdict is in doubt (cause; verification-fact unresolve automation is #1416) -
application_document.scan_requeued— a settled document was requeued via the rescan endpoint (cause:manual). The boot-time backend-switch sweep is deliberately event-less: it requeues noop-settled rows (revoking their acceptances) in bulk UPDATEs and logs a WARN with the count — the rescan endpoint is the audited per-document path. -
application.submitted(IDs only — no PII per ADR-004) -
application.expedited_identified(when 7 CFR 273.2(i) matches at intake) -
application.withdrawn