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

http://localhost:8010/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

caseworker

Swagger UI

http://localhost:8010/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_exchanged on 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 actor and zero-attribution flags in the authorization inventory stand as follow-on work.

  • Azp allowlist: canopy-web-exchanger only.

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 as cb_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 by cb_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 carry granted_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): ReconcileReportreceipts_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).

PUT /v1/ipv/cases/{id}/withdraw

Withdraw an IPV case.

Response (200): IpvCase. Errors: 401, 404 (case not found).

GET /v1/ipv/disqualifications/active

Check for active disqualifications for a person.

Query parameters: person_id (required)

Response (200): ActiveDisqualificationResponse — active disqualification details (disqualified flag, end date, program, IPV case ID). Errors: 401.

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 legacy appeal.decision_issued/appeal.decision_reversed pair 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 still computed past 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 its assessment_id)

  • ipv.referred, ipv.adh_scheduled, ipv.decided, ipv.disqualification_imposed

Events Consumed

  • snap.overpayment_claimed / tanf.overpayment_claimed / medicaid.overpayment_claimed (#1105, #1035; queue canopy-appeals.claim-acks) — the program service’s in-claim-tx acknowledgment; flips the referenced assessment computed → 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.

Edit this page · default