canopy-appeals API Reference
On this page
Overview
Cross-link: canopy-appeals Data Model (#419)
Manages fair hearing requests (7 CFR 273.15) and Intentional Program Violation cases (7 CFR 273.16). Enforces the 90-day FILING window, the 60-day conduct-and-decide SOP (7 CFR 273.15(c)(1), extendable by recorded household postponements — #1099), the 30-day ADH notice requirement, and the Chart B2 continued-benefits election with its stay discipline.
As of T2-8 (#681), canopy-appeals reads a frozen determination’s hearing-scoped projection in-boundary from the owning program service (GET /v1/determinations/{id}/hearing-view on canopy-snap, via a service-token SnapHearingClient) rather than pulling the sealed snapshot — so a hearing displays the determination as it stood without FTI ever entering canopy-appeals (ADR-028 §70, ADR-004).
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
caseworker
- Swagger UI
- Database
-
canopy_appeals
Receiver contract (OIDC S-appeals, #1439 / ADR-043 §C)
canopy-appeals is the fifteenth — and final — 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 appeals specifics:
-
require_service_or_exchangedon the two web-driven worker writes — the filing (POST /v1/appeals) and the hearing decision (PUT /v1/appeals/{id}/decision) — the BFF sends the worker’s exchanged bearer (fail-on-denied, #1560 dispatch); a direct worker bearer stays 403. The decision action’s ownership pre-check read stays service-class (FU-A). -
Everything else stays service-only (FU-B / ADR-023 D4): the hearing lifecycle, the withdraw lifecycle, postponements, the internal triggers, and all ten IPV routes. The body-string
actorand zero-attribution flags in the authorization inventory stand as follow-on work. -
Azp allowlist:
canopy-web-exchangeronly.
Fair Hearing Endpoints
POST /v1/appeals
File a fair hearing request (#1098, epic &72 MR 2.1: action-bound filing + the Chart B2 continued-benefits election).
Request: FileAppealRequest
{
"household_id": "uuid",
"requestor_person_id": "uuid",
"program": "snap",
"determination_id": "uuid",
"notice_id": "uuid",
"adverse_action_id": "uuid",
"request_method": "phone",
"continued_benefits_waived": false,
"good_cause_claimed": false,
"repayment_obligation_disclosed": true
}
request_date is server-stamped from the gated clock — it left the wire with #1098 (deny_unknown_fields: an old caller still sending it, or adverse_action_effective_date, is rejected loudly). program is the typed snake_case Program vocabulary.
adverse_action_id binds the filing to an enrollment adverse action — when present it is validated hard against enrollment (unknown action, wrong household, or program mismatch is a 422). Omitting it files a narrative grievance: the server cannot know an action exists to demand its id, so the binding requirement is enforced structurally — continued benefits flow ONLY through an action-bound filing (the MR 3.3 worker UI makes action selection mandatory for termination appeals).
Continued benefits (Chart B2, [appeals].continued_benefits_election_days = 14): the election window runs 14 days from the action’s noticed_date — the latest DISPATCHED notice version’s legal notice date served on the enrollment action view (#1164), the SAME date the letter’s printed deadline anchors on, so printed == enforced. Only while no dispatch evidence exists does the constructive fallback apply (enact_not_before − required_advance_days; enact_not_before itself for advance-notice-exempt actions — the pre-dispatch filing path). Continuation is assumed unless waived (Form 118). Outcomes land in cb_election:
-
granted_timely— in-window election on a scheduled action: appeals takes a synchronous fenced stay on the action (PUT /v1/adverse-actions/{id}/stays/{appeal_id}) and commits the grant only WITH the receipt (persisted ascb_stay_link_status/cb_stay_receipt_at). -
pending_stay— enrollment was unreachable: the filing commits ungranted and a retry worker completes the grant when the stay lands (never granted without a stay receipt). -
granted_reinstate— the action had already enacted: adequate-notice path; benefits must be reinstated bycb_reinstate_by(5 working days from the election). -
pending_good_cause— election past the window with good cause claimed; the grant requires OSAH approval (MR 2.2). -
waived/not_electable/not_applicable— Form 118 waiver (a timely waiver stays eligible; a late one does not); policy bar (P4 unavailability, expired window, dead action); narrative grievance. Pre-#1098 grants carrygranted_legacy(no action binding, no stay receipt).
Response (201): AppealWithTimeline — includes decision_due_date (60 days from filing per 7 CFR 273.15(c)(1), extendable by recorded postponements) and the CB election block. Errors: 400 (outside the 90-day appeal window), 401, 422 (action binding rejected), 503 (enrollment unavailable for the lookup — retry; the filing date is server-stamped so a same-day retry loses nothing).
GET /v1/appeals
List appeals. Filter by household_id.
Query parameters: household_id, limit, offset, and (#1518)
optional repeated programs= keys (snake_case Program slugs) — the
same contract as the queue’s #1308 filter: ANDed with the other
filters, empty/absent = unscoped (additive), unknown slugs → 422. The
BFF’s worker appeals index passes the worker’s scope (storage-slug
expansion, #733).
Response (200): array of AppealRequest. 422 on an unknown
program slug.
GET /v1/appeals/queue
List all pending appeals (work queue for hearings officers).
Query (#1308): optional repeated programs= keys (snake_case Program
slugs) restrict the queue to those programs — the BFF passes the worker’s
primary_programs scope (with the medicaid→{medicaid, chip} storage-slug
expansion, #733) so a scoped worker never receives cross-program appeals.
Empty/absent = unscoped, jurisdiction-wide (the pre-#1308 contract; additive).
Unknown slugs → 422.
Response (200): array of AppealRequest sorted by decision deadline.
GET /v1/appeals/hearings/upcoming
Server-side-filtered hearings list for the supervisor dashboard’s Pending Hearings panel (demo-dataset-seed Step 9d). Returns appeals with hearings scheduled within the requested window.
Query parameters: days (days from today, inclusive; defaults to 30, clamped to 1..=365), limit (defaults to 50, clamped to 1..=200), and (#1518) optional repeated programs= keys — same contract as the list/queue filters (empty = unscoped; unknown slugs → 422).
Response (200): array of AppealRequest. 422 on an unknown program slug.
GET /v1/appeals/{id}
Get an appeal with timeline details (filing date, hearing date, decision due date).
Response (200): AppealWithTimeline. Errors: 401, 404 (appeal not found).
GET /v1/appeals/{id}/hearing-view
Proxy canopy-snap’s FTI-safe hearing-view for the appeal’s frozen determination (T2-8 #681, ADR-028 §70). Appeals resolves the appeal to its determination + program, then fetches canopy-snap’s service-gated /v1/determinations/{id}/hearing-view with its own service identity — the non-restricted, no-sealed-leaf projection is the only thing that crosses the boundary, and the caller never directly reaches the program endpoint. Service-caller token required (require_service_caller; canopy-web calls it on the worker’s behalf). SNAP-only for now — tanf/medicaid have no replay/hearing-view path yet.
Response (200): HearingDeterminationView. Errors: 401, 403 (caller is not a service), 404 (no such appeal, or the determination has no input snapshot — legacy/pre-ADR-028), 422 (non-SNAP appeal).
PUT /v1/appeals/{id}/schedule
Schedule a hearing.
Request: ScheduleHearingRequest
{
"hearing_date": "2026-04-20",
"hearing_officer_id": "uuid"
}
Response (200): AppealRequest. Errors: 401, 404 (appeal not found).
PUT /v1/appeals/{id}/decision
Record the hearing decision (#1099, epic &72 MR 2.2 — Chart B1).
Request: RecordDecisionRequest
{
"decision": "upheld_agency",
"decision_basis": "Evidence supports the original determination.",
"decision_signed_date": "2026-07-15",
"decision_received_date": "2026-07-18",
"actor": "worker@county",
"dismissal_basis": null,
"dismissal_good_cause": false,
"federal_policy_issue": false
}
decision is the typed vocabulary upheld_agency | reversed_household | dismissed (P12: withdrawn is a LIFECYCLE, not an outcome; pre-#1099 free-text values were migrated or quarantined as legacy_unmapped). Chronology is validated: decision_signed_date ≤ decision_received_date ≤ today, and decision_received_date ≥ request_date — Chart B1’s case-action clock runs from RECEIPT. A dismissal requires a dismissal_basis (7 CFR 273.15(j): abandoned | federal_mass_change | untimely_request); the good-cause flag records a failure-to-appear claim. federal_policy_issue (#1132, default false) types the Chart B3 row-3 fact — the decision determined the issue was federal law/regulation/policy — at decision time (the narrative decision_basis cannot be parsed for it); agency-favorable outcomes only, and a later final appeal never reinstates benefits when set.
Effects, all staged in the SAME transaction as the decision row: timeline event + decision events; for agency-favorable outcomes (upheld_agency/dismissed) with granted CB, the P2 cessation record (initial_decision, dated the NEXT issuance cycle after receipt — first of the following month; the adequate notice, which must NOT advertise another hearing, is rendered by the Phase-3 notices machinery from this record). Then the Chart B1 action command: a reversed_household decision vetoes the bound action (durable, moots every stay); an agency-favorable decision releases this appeal’s stay so the action proceeds next cycle — never an immediate enact. Command receipts persist to cb_stay_link_status + the timeline; refusals are timeline-recorded and failures logged — the receipt/link trail is the input the MR 2.3 reconciliation scanner (next in this phase) sweeps. Both commands are idempotent terminal-state replays on the enrollment side (#1096, pinned by enrollment’s stay-replay tests), so re-recording after a failure is safe.
Response (200): AppealRequest. Errors: 400 (chronology / dismissal fields), 401, 404 (appeal not open).
PUT /v1/appeals/{id}/final-appeal
Record a judicial (final) appeal of the hearing decision (#1132 — Chart B3 / PAMMS Appendix B Final Appeals; HB 790: OSAH decisions appeal only by petition for judicial review in Superior Court).
Request: RecordFinalAppealRequest — { "filed_date": "2026-07-20", "actor": "…", "continue_benefits": true }.
Timeliness: filed_date must fall within [appeals].final_appeal_window_days (30, cited) of decision_received_date — the recorded proxy for service of the initial decision; an untimely filing is refused naming the window. With continue_benefits (Chart B3 row 1), continuation is EXTENDED past the initial-decision cessation: the enrollment stay is re-granted synchronously before anything commits (the 2.1 discipline — never granted-without-stay) via enrollment’s explicit restay command — the initial decision RELEASED this appeal’s link, and the plain stay’s monotonic guard refuses released→stayed by design — then one transaction supersedes the recorded P2 cessation (reason/date/`continued_benefits_end_date back to NULL, the superseded pair audited in the timeline — the window end is unknown until the judicial outcome) and records the filing. If the action enacted before the stay landed, the Chart B2 working-day reinstatement SOP applies from the filing date (cb_reinstate_by persisted). The federal-policy row never reinstates: a decision recorded with federal_policy_issue refuses continue_benefits (the filing itself stays recordable without it). Continuation also requires benefits actually continued pending the initial decision — otherwise there is nothing to continue. The CB assessment worker HOLDS while the judicial review pends (the window end is unrecorded). No events are published — enrollment converges via the synchronous command + the reconciliation scanner, whose lingering-stay predicate exempts a decided appeal with a live extension.
Response (200): AppealRequest. Errors: 400 (untimely / chronology / federal-policy bar / no CB to continue), 401, 404, 409 (already filed / not decided / action dead), 500 (enrollment unavailable — continuation NOT granted; retry).
PUT /v1/appeals/{id}/final-decision
Record the Superior Court outcome of the final appeal (#1132).
Request: RecordFinalAppealDecisionRequest — { "outcome": "affirmed" | "reversed", "received_date": "2026-09-04", "actor": "…" } (remand is deliberately unmodeled pending SME guidance; the vocabulary is additive).
affirmed with an extended continuation records the P2 final_decision cessation at the next issuance cycle after receipt (the Chart B1 semantics the initial decision uses), re-arms the CB assessment against the extended window (final_decision_cessation trigger; a still-pending item is left alone — it computes against the new cessation), and releases the re-granted stay post-commit. reversed vetoes the adverse action (durable, moots every stay), leaves NO cessation, and re-arms the assessment item so the worker’s reversed arm VOIDS any completed assessment through the one writer path (void + released lines + $0 projection; the downstream claim voids via the staged event) — the household prevailed, so CB received pending review is never recouped.
Response (200): AppealRequest. Errors: 400 (chronology), 401, 404, 409 (no filing / outcome already recorded).
PUT /v1/appeals/{id}/withdraw
Open the P12 withdrawal lifecycle (#1099 — the empty-body immediate withdraw died).
Request: WithdrawAppealRequest — { "method": "written" | "oral", "actor": "…" }. The pre/post-OSAH-submission stage derives from the hearing-scheduling state. The appeal lands in status = "withdrawal_pending"; an oral pre-submission withdrawal records a written-confirmation due date (request + 10 days). Nothing here touches the enrollment stay — only finalization does.
Response (200): AppealRequest. Errors: 401, 404 (appeal not open).
PUT /v1/appeals/{id}/withdraw/confirm
Record the written confirmation notice for an ORAL PRE-SUBMISSION withdrawal (P12). Server-stamps the notice date; the household’s 10-day reinstatement window runs from it.
Request: ConfirmWithdrawalRequest — { "actor": "…" }. Response (200): AppealRequest. Errors: 401, 404, 409 (not an oral pre-submission withdrawal awaiting confirmation).
PUT /v1/appeals/{id}/withdraw/reinstate
The household reinstates the hearing. Allowed while the withdrawal pends and the 10-day window (when started) has not lapsed; the appeal returns to its pre-withdrawal status, the withdrawal audit trail stays on the row, and the stay was never touched.
Request: ReinstateAppealRequest — { "actor": "…" }. Response (200): AppealRequest. Errors: 401, 404, 409 (nothing pending / window lapsed).
PUT /v1/appeals/{id}/withdraw/finalize
Close the withdrawal — the ONLY transition after which the enrollment stay is released (P12). The fenced finalization commits FIRST, optimistically locked against any concurrent transition (enrollment’s link machine is monotonic — a stay once released cannot be re-taken, so releasing before the fence could strand a live appeal without its stay); the release then follows post-commit with its receipt persisted. A release failure leaves only a household-safe lingering stay (it delays the action) — the receipts-vs-links trail the MR 2.3 scanner sweeps. Refused while an oral pre-submission withdrawal’s confirmation notice is unsent or its reinstatement window is open — finalizing early would strip the household’s right.
Request: FinalizeWithdrawalRequest — { "actor": "…" }. Response (200): AppealRequest (status = "withdrawn"). Errors: 401, 404, 409 (nothing pending / window open / confirmation unsent / concurrent transition), 503 (no service identity to release the stay — refused before any commit).
POST /v1/appeals/{id}/postponements
Record THE household-requested postponement extending the 60-day decision SOP (P13, initial-hearings:53; 7 CFR 273.15(c)(4): ONE postponement of up to [appeals].max_postponement_days = 30 days — a second request is a 409). Append-only for audit; the appeal’s decision_due_date moves by days.
Request: RecordPostponementRequest — { "days": 14, "reason": "…", "actor": "…" } (days 1..=30). Response (201): AppealRequest with the extended due date. Errors: 400 (over cap), 401, 404 (appeal not open), 409 (a postponement is already on file).
POST /v1/internal/appeals/reconcile
Run the #1100 reconciliation sweep on demand (service-caller): compares appeals' persisted stay receipts against enrollment’s per-appeal links and flags CB elections parked in pending_stay past [appeals].pending_stay_alert_hours (24). Report-only — remediation lives in the appeals-reconciliation runbook.
Response (200): ReconcileReport — receipts_checked, lingering_checked, findings[] (receipt_link_disagreement | link_missing | lingering_stay — the failed-post-commit residue both sides agree on, caught by a lifecycle-vs-link predicate | pending_stay_past_sla), skipped_unreachable (per-row enrollment outages, counted visibly). Errors: 401, 503 (no service identity).
POST /v1/internal/appeals/clock-check
Internal endpoint — triggers the decision-deadline check (60-day SOP per 7 CFR 273.15(c)(1); #1099 corrected the prior 90-day figure, which was the filing window). Returns counts of approaching and overdue appeals.
Response (200): clock check results. Errors: 401.
IPV (Intentional Program Violation) Endpoints
POST /v1/ipv/cases
Create an IPV referral.
Request: CreateIpvReferralRequest
{
"household_id": "uuid",
"person_id": "uuid",
"program": "snap",
"allegation_type": "unreported_income",
"allegation_description": "Failure to report income from secondary employment",
"evidence_summary": "Employer cross-match showed wages not reported on application",
"overissuance_amount": "2400.00",
"referred_by": "uuid"
}
Response (201): IpvCaseWithTimeline. Errors: 400 (invalid request), 401.
GET /v1/ipv/cases
List IPV cases. Filter by person_id.
Query parameters: person_id (required)
Response (200): array of IpvCase.
GET /v1/ipv/cases/{id}
Get an IPV case with timeline.
Response (200): IpvCaseWithTimeline. Errors: 401, 404 (case not found).
PUT /v1/ipv/cases/{id}/schedule-adh
Schedule an Administrative Disqualification Hearing.
Request: ScheduleAdhRequest
{
"adh_date": "2026-05-15"
}
Response (200): IpvCase. Errors: 401, 404 (case not found).
PUT /v1/ipv/cases/{id}/send-notice
Record that the 30-day ADH advance notice has been sent. The system validates that the hearing date is at least 30 days from notice date (7 CFR 273.16(b)).
Response (200): IpvCase. Errors: 400 (30-day notice requirement not met), 401, 404 (case not found).
PUT /v1/ipv/cases/{id}/record-decision
Record the ADH decision.
Request: RecordAdhDecisionRequest
{
"decision": "ipv_confirmed"
}
Response (200): IpvCase. Errors: 400 (invalid decision value), 401, 404 (case not found).
PUT /v1/ipv/cases/{id}/waiver
Record a signed waiver (individual admits to IPV without hearing).
Request: RecordWaiverRequest
{
"waiver_signed_date": "2026-05-15"
}
Response (200): IpvCase. Errors: 401, 404 (case not found).
PUT /v1/ipv/cases/{id}/impose-disqualification
Impose the disqualification penalty after IPV confirmation. The penalty window (offense number, start/end dates) is derived server-side from the case’s prior IPV count — no request body.
Penalty schedule: 12 months (1st offense), 24 months (2nd), permanent (3rd).
Response (200): IpvCase. Errors: 401, 404 (case not found), 409 (case not in valid status).
Error Codes
| Code | Meaning |
|---|---|
400 |
Invalid request — missing required fields, invalid decision value, or 30-day ADH notice requirement not met |
401 |
Missing or invalid JWT |
403 |
Insufficient role (enforced by the caseworker-minimum authorization middleware) |
404 |
Appeal or IPV case not found |
409 |
Case not in a valid status for the requested transition (PUT /v1/ipv/cases/{id}/impose-disqualification) |
Events Published
-
appeal.filed,appeal.scheduled,appeal.decided,appeal.withdrawn -
appeal.decision_recorded/appeal.withdrawal_finalized(#1102, epic &72 MR 3.2 — the pinned Phase-2 payloads, ACTIVATED now that enrollment’s convergence consumer is deployed; one decision event for EVERY outcome carrying the typed decision + signed/received dates + the next-cycle cessation date when CB was granted. The legacyappeal.decision_issued/appeal.decision_reversedpair is deleted, pre-1.0) -
appeal.continued_benefits_granted -
appeal.overpayment_assessed(#1105, epic &72 MR 4.2 — emitted by the CB assessment worker in the apply tx, always with full provenance (appeal_id/adverse_action_id/assessment_id); the acknowledgment scanner RE-EMITS it for assessments stillcomputedpast the grace window, and the program-service subscriber re-acknowledges idempotently, so the assess → claim → acknowledge loop converges from either side’s loss. The #1104 interim inline emission — and its degraded legacy path — died at the #1105 cutover) -
appeal.overpayment_assessment_voided(#1105 — an assessment with a possible downstream claim was retired: veto / action-cancel / P6 reallocation supersession; program services void the claim stamped with itsassessment_id) -
ipv.referred,ipv.adh_scheduled,ipv.decided,ipv.disqualification_imposed
Events Consumed
-
snap.overpayment_claimed/tanf.overpayment_claimed/medicaid.overpayment_claimed(#1105, #1035; queuecanopy-appeals.claim-acks) — the program service’s in-claim-tx acknowledgment; flips the referenced assessmentcomputed → applied(idempotent) and timelines the claim id. Appeals was publish-only before this consumer; before #1035 only SNAP acked, so tanf/ medicaid assessments re-emitted forever.