canopy-notices API Reference

On this page

Overview

Cross-link: canopy-notices Data Model (#419)

Generates, stores, and delivers notices (Notices of Action, appeal acknowledgments, ABAWD warnings, etc.) as PDF documents. Uses Typst templates (ADR-010) with the Orchard design system. PDFs are stored in S3-compatible object storage.

Per ADR-029, canopy-notices is also the project’s general signed-document renderer: POST /v1/documents/render renders any allow-listed (non-NOA) template from free-form JSON inputs and optionally signs it (ES256 detached JWS). The audit "Cite for hearing" citation is its first non-NOA consumer.

Base URL

http://localhost:8008/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

caseworker

Swagger UI

http://localhost:8008/swagger-ui

Database

canopy_notices

Receiver contract (OIDC S-notices, #1437 / ADR-043 §C)

canopy-notices is the thirteenth service on the ADR-043 receiver contract (canopy_auth::ReceiverContract) — a TERMINAL exchange target with ZERO user-only routes (the enforce flag is inert, set for fleet consistency). The notices specifics:

  • require_service_or_exchanged on ONE route — the ADR-029 citation render RPC (POST /v1/documents/render) — with a DEDICATED role bar (admin / studio_admin / auditor) mirroring the web-side citation-download gate; an exchanged caseworker-set bearer is 403 by design. The BFF sends the worker’s exchanged bearer (fail-on-denied, #1560 dispatch via InternalClient::into_neutral).

  • The machine surfaces (generate / delivery-queue / resend) and the web SSR reads stay service-only (FU-B / ADR-023 D4). The portal’s applicant reads + mark-read carry the #1441 portal arm (require_service_or_portal on portal:notices:read / portal:notices:ack) and, since #1442, the origin-enforced ownership binding: the list requires the claim’s household, and the {id} routes compare the stored household post-load with a UNIFORM 404 on mismatch (denial never confirms a foreign notice exists; the BFF pre-check stays as defense-in-depth).

  • Azp allowlist: canopy-web-exchanger only.

Endpoints

POST /v1/notices

Generate a notice.

Request: GenerateNoticeRequest

{
  "household_id": "uuid",
  "recipient_person_id": "uuid",
  "notice_type": "approval",
  "subject": "SNAP Benefits Approved",
  "template_key": "snap/approval",
  "regulatory_basis": "7 CFR 273.10",
  "program": "snap",
  "application_id": "uuid",
  "determination_id": "uuid",
  "effective_date": "2026-04-01",
  "continued_benefits_available": false,
  "program_data": {
    "benefit_amount": 535.00,
    "effective_date": "2026-04-01",
    "household_size": 3
  }
}

Required fields: household_id, recipient_person_id, notice_type, subject, template_key, regulatory_basis. Optional: program, application_id, determination_id, effective_date, continued_benefits_available, program_data — and, since #1101 (epic &72 MR 3.1), the adverse-action binding fields adverse_action_id, adverse_action_generation, action_reason_code, exemption_authority, and effective_date_policy.

Adverse-action binding (#1101): adverse_action_id binds the notice to an enrollment adverse action — the spine id the notice row persists and the evidence events carry back to enrollment (None for non-action notices). adverse_action_generation records the action’s source generation at notice time (provenance — a regenerated source is a NEW action); action_reason_code (machine-readable reason) and exemption_authority (PAMMS 3705, when the action skips advance notice on the adequate-notice path) are persisted for audit. effective_date_policy is typed clamp | verbatim (default clamp): clamp is the ADR-010 legacy behavior — an effective date earlier than notice_date + advance_notice_days is pushed out to the floor and the row marked advance_notice_adjusted; verbatim is the adverse-action pipeline’s path — it computed the legal date against the action’s own policy snapshot, so the notice renders and persists it untouched.

Notice types: approval, denial, termination, change, expedited, abawd, sanction, time_limit, expungement, ivd_referral, continued_benefits, change_in_circumstances, overpayment, appeal_acknowledgment, renewal.

The program_data object is passed to the Typst template as variables (PROTOCOL EXCEPTION: opaque serde_json::Value, schema defined per-template not by Rust). Required fields depend on the notice type.

Since #1091 a render/upload failure fails the request — a program-bearing notice can no longer persist as a PDF-less row. An unknown (program, template_key) pair (no manifest entry) is a 400; other render/storage failures are 500s and nothing is stored. The direct HTTP path renders the legacy "Notice Recipient" placeholder block (no persons lookup); event-routed notices go through the work-item worker, which resolves the real recipient first — and, since #1146, reads the household’s confidentiality election from canopy-applications (fail-closed) so an address_confidential household’s mail routes through the [notices.acp] substitute address (see the data-models page’s ACP section). Since #1188 the worker also stamps the per-letter acp_applied provenance (TRUE iff the substitute block actually rendered), surfaced on the Notice wire shape (serde(default) additive field) — the applicant portal reads it to suppress inline PDF streaming of real-address letters for address-confidential households (fail-closed; per-jurisdiction override CANOPY_PORTAL__ALLOW_CONFIDENTIAL_ADDRESS_LETTER_STREAMING).

Response (201): Notice (includes pdf_storage_path for download; delivery_status starts pending — the dispatcher loop stamps dispatched_at after commit, #1091). Since #1101 the Notice also carries the persisted action binding — adverse_action_id, adverse_action_generation, action_reason_code, exemption_authority — and cb_election_deadline, the last day (inclusive) the household may elect continued benefits (notice_date + continued_benefits_election_days, Chart B2), rendered on the letter and persisted as the legal record. notice_date — the legal issuance date all of this anchors on — derives in the [jurisdiction].timezone via the gated decision clock (#1121; like dispatched_date since #1102 — a UTC date is already tomorrow from ~7pm Eastern). dispatched_at is likewise a gated-clock stamp (#1141): wall-clock-identical in production, logically coherent on a test-clock stack — the enact gate’s advance-days evidence no longer depends on dispatch completing before any clock advance.

GET /v1/notices

List notices newest-first, keyset-paginated (#1214). Filter by household_id and optionally program / programs.

Query parameters: household_id, program, programs (repeated keys, #1517), limit (default 50, max 200), after_created_at + after_id (keyset cursor — pass the previous page’s next_cursor fields together; omit both for the first page)

The list is ordered (created_at DESC, id DESC) — newest first, with the UUID-v7 id as a stable tiebreak — and keyset-paginated over that compound cursor. There is no offset (deep-offset scans don’t survive the multi-million-row notices table); the unfiltered default page rides the idx_notices_active_created_at_id partial index, an index scan with no top-N sort. The program filter scopes results to a single program slug (snap/tanf/medicaid/chip/caps/wic) so case-detail tabs for cross-program households don’t show notices from unrelated programs. The plural programs filter (#1517, repeated programs=snap&programs=chip keys) scopes to a SET of slugs — the query-time surface canopy-web’s ADR-044 worker program scoping consumes (post-filtering an already-limited page is not a control). An unknown slug in either filter is a 422 naming the offending value (the canopy-applications invalid_programs contract), never a silently-empty page; the two filters compose by AND.

Response (200): NoticePageitems (array of Notice, newest-first) + next_cursor ({after_created_at, after_id} when a full page may have more; null at the end).

GET /v1/notices/{id}

Get a notice with appeal rights information.

Response (200): NoticeWithAppealRights — includes notice details and the appeal filing deadline.

GET /v1/notices/{id}/pdf

Download the notice PDF.

Response (200): Binary PDF (Content-Type: application/pdf).

The PDF is served from S3 via canopy-store. A notice with no stored PDF — a failed render at generation time, a seed-loaded notice, or a stale storage path whose object was evicted — triggers an on-demand re-render (#581): the bytes go straight back to the caller without being persisted. Returns 404 only when the notice does not exist or the on-demand render also fails.

POST /v1/notices/{id}/mark-read

Mark a notice read (#721). Sets read_at on first open; idempotent (COALESCE(read_at, now()) preserves the first-read time across repeat calls). Service-or-portal (require_service_or_portal on portal:notices:ack, #1441) — and since #1442 the ORIGIN enforces the household binding itself: the stored notice’s household is compared against the citizen’s signed ownership claim BEFORE the stamp, with a uniform 404 on mismatch (denial never confirms a foreign notice exists). The applicant-portal BFF keeps its own pre-check as defense-in-depth. No audit event: this is the applicant’s own read of their own letter, not a worker data access.

Response (200): the updated Notice (with read_at now set). Returns 404 if the notice does not exist (or is soft-deleted).

POST /v1/notices/{id}/resend

Resend a notice: flips delivery_status back to pending, which the dispatcher loop (#1091) picks up on its next pass and re-dispatches (a fresh dispatched_at + notice.dispatched event).

Response (200): Notice re-queued for delivery. Returns 404 if the notice does not exist, 400 if it has no PDF to deliver.

GET /v1/notices/queue

List notices pending delivery — the dispatcher loop’s view (delivery_status = 'pending' with a stored PDF). On a healthy service this drains within seconds (#1091).

Response (200): array of Notice (those with a pending delivery_status).

POST /v1/documents/render

Render an allow-listed document template to a (optionally signed) PDF — the general signed-document path (ADR-029). Service-token only (require_service_caller); called by the BFF (canopy-web), not by end users.

Request: RenderDocumentRequest

{
  "template_key": "audit-citation",
  "inputs": { "...": "free-form JSON injected as Typst inputs.*" },
  "sign": true
}

template_key is allow-listed service-side (resolved to a .typ under the notices root — it is NOT a free filesystem path; unknown keys → 400). inputs is opaque serde_json::Value (schema defined per-template). When sign is true the service canonicalizes inputs (JCS) and produces an ES256 detached JWS over that canonical payload (key id canopy-notices-current); the JWS is embedded in the PDF and returned in the X-Canopy-Signature response header.

Allow-listed templates: audit-citation (the #503 "Cite for hearing" audit-event citation — event provenance + the ADR-014 hash chain + a GET /v1/security/chain/attest attestation, #1205).

Response (200): application/pdf bytes (Cache-Control: no-store); X-Canopy-Signature: <jws> when signed. 400 on an unknown template_key.

Error Codes

Code Meaning

400

Invalid notice type, missing required context fields, or an unknown template_key for the requested program (#1091)

401

Missing or invalid JWT

403

Insufficient role

404

Notice not found, or PDF not in storage

500

Typst template compilation error (the notice is NOT stored, #1091)

Template Resolution

Templates are loaded from rulesets/{jurisdiction}/notices/{program}/ via NoticeManifest (reads manifest.toml). Template key is derived from notice type — e.g., approvalsnap/approval.typ.

Events Subscribed

canopy-notices subscribes to upstream events and auto-generates notices:

  • determination.completed.snap → SNAP Notice of Action (approved → noa-approval, denied → noa-denial)

  • tanf.determined → TANF Notice of Action (the shared NOA templates with program = "tanf"; canopy-tanf emits tanf.determined, not determination.completed.tanf)

  • medicaid.determined → Medicaid Notice of Action (#1510 — the medicaid NOA gap closed): approved → noa-approval (42 CFR 431.206), denied → noa-denial (42 CFR 431.210, hearing rights in the shared template); the approved route forwards assigned_coa / assigned_coa_track / benefit_type (coverage, not cash — no benefit_amount exists; the denied route forwards nothing, sibling-consistent). Trigger-discriminated adverse-vs-benign routing is deliberately NOT wired yet: an adverse change-report re-determination of an ENROLLED household needs 42 CFR 431.211 advance notice, machinery medicaid does not have (enrollment supersession #1133; CMD escalation #1511) — until then every denial gets the immediate denial NOA. chip.determined stays a registered topology gap: its publisher is dead code (CHIP family #779–#784) and its payload carries no household_id/person_id, so the recipient gate could never pass.

  • appeal.filed → Appeal Acknowledgment notice

  • appeal.continued_benefits_granted → Continued Benefits notice

  • ipv.disqualification_imposed → Sanction notice

  • renewal.material_change → Change in Circumstances notice (change-in-circumstances, T2-7 #680) — an informational recert nudge raised when a mid-cert reported change is material (canopy-renewals). It is not an adverse action: it carries no effective_date and no fair-hearing rights (the subsequent recert determination, if it reduces benefits, issues its own adverse NOA with appeal rights), so the 10-day advance-notice floor never applies. Forwards change_reasons / previous_benefit_amount / new_benefit_amount to the template.

  • snap.overpayment_claimed → OverpaymentNotice (overpayment, T2-8 #681) — a worker’s retroactive fact correction triggered an in-boundary overpayment recompute that established a #382 claim (canopy-snap). The notice establishes the claim and renders claim-appeal rights (a hearing on the claim, 7 CFR 273.15/273.18); it carries no effective_date (a debt-establishment notice, not a 273.13 advance-benefit-reduction notice), so the advance-notice floor never fires. Forwards claim_id / overpayment_amount (pre-formatted) / claim_basis to the template; the payload carries IDs + amount only — no FTI/facts (ADR-004).

Since #1107 the routing table supports a second payload discriminator, created_source (the exact parallel of status): an enrollment.adverse_action_scheduled event whose payload carries created_source = "periodic_report" routes to the 3730 combined reminder + termination letter (pr-combined, notice_type periodic_report_termination, verbatim) while worker-sourced actions keep the generic noa-termination. Since #1128 a third discriminator, reason_code, COMPOSES with created_source — within one provenance the letter fans by the action’s reason: (periodic_report, failure_to_provide_verification) routes the Chart 3730.1 row-2 verification-failure termination letter (pr-vcl-termination, notice_type periodic_report_verification_termination), the nonfiler reason falls through to the combined letter, and a worker action with the same generic reason keeps the plain NOA. Lookup precedence: (created_source, reason_code) match → created_source match → status match → discriminator-less entry; a discriminated entry never catches non-matching payloads. Two new routes back the periodic-report calendar: renewal.snap_periodic_report_duepr-due (informational, NOT action-bound) and the discriminated combined route above (its dispatch IS the action’s enact-gate evidence — no duplicate NOA exists by construction).

The events above route through the config-driven notices/manifest.toml table. Since #1091 the subscriber does NOT render in the inbox transaction: it persists a work item (notice_work_items, atomic with the inbox row, idempotent on (source_event_id, notice_type)) and acks. A worker loop then claims items under a FOR UPDATE SKIP LOCKED lease, resolves the recipient from canopy-persons — legal name, household-membership validation, mailing-first address choice, redacted/incomplete addresses rejected — renders/scans/uploads OUTSIDE any transaction, and commits the notice + appeal-rights + notice.generated outbox rows in one short claim-fenced transaction. Resolution or render failures retry with exponential backoff (terminal failed + last_error after 8 attempts) — a notice is never generated unaddressed and never persists without its PDF. A separate dispatcher loop drains committed pending notices through the delivery adapter and stamps dispatched_at + publishes notice.dispatched in a fenced transaction (dispatch evidence = provider acceptance; delivered_at remains reserved for a receipt-capable production carrier — an epic &72 production gap). The recipient is the event’s person_id (the head-of-household stamped by the program service); the subscriber still skips any event without a resolvable household_id + person_id. Adding a program/event is a manifest entry only (ADR-003); the subscriber binds to every event type at startup.

Events published: notice.generated (staged in the worker’s persist transaction) and notice.dispatched (staged with the dispatch stamp; consumed by the epic &72 Phase-3 enactment gate).

Recovery side-channel (Plan 3 MR8c, ADR-026)

A dedicated second subscriber (queue canopy-notices.recovery) handles application.applicant.recovery_initiated — the applicant-portal lost-credential recovery flow (ADR-026; applicant-portal design ref §3.4-3.8). This is an email/SMS to the application-time contact, not a Typst PDF, so it bypasses the manifest routing table and gets its own queue + handler.

The event carries IDs only (ADR-004); the handler reads the contact + the one-tap kill-switch token back from canopy-applications (GET /v1/applicants/recover/{recovery_id}, ADR-019 service token) and delivers a notice containing the kill-switch link ({portal_base_url}/recover/kill/{token}) and the 24h reveal time — never the passcode (the reveal is a separate 24h-gated step). For UAT the delivery adapter is a logging stub (the contact is redacted; the kill link is the demo payload); a real email/SMS gateway replaces it post-UAT. Idempotency is the event_inbox. The subscriber is registered only when the service-token credentials are configured; otherwise it degrades gracefully (the rest of canopy-notices still serves).

Edit this page · default