canopy-enrollment API Reference
On this page
Overview
Cross-link: canopy-enrollment Data Model (#419)
Manages the post-determination enrollment lifecycle: creating enrollment records, issuing benefits to EBT, tracking issuance history, and handling expungement (12 months unused per 7 USC §2016(h)(9)).
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
eligibility_specialist
- Swagger UI
- Database
-
canopy_enrollment
Receiver contract (OIDC S-enrollment, #1435 / ADR-043 §C)
canopy-enrollment is the eleventh service on the ADR-043 receiver
contract (canopy_auth::ReceiverContract) — see
the tanf API page for the bearer-shape and
guard-family description — a TERMINAL exchange target with ZERO
user-only routes. The enrollment specifics:
-
The #408 household gate runs on
EffectiveUser: its worker-actor arms were unreachable pre-slice (no actor verifier — every issuance/annual-summary read passed). An exchanged worker now hits the live assignment check (deny audited; supervisor/admin bypass by design); service/system traffic (the portal BFF) keeps the pass-through arm. -
require_service_or_exchangedon the #408-gated issuances read and the two worker-actioned adverse-action writes (schedule, cancel); the annual summary adds the #1441 portal arm on top (require_service_or_exchanged_or_portalonportal:enrollment:read— the portal’s ONE enrollment surface; issuances is portal-killed). The widening is what makes the gate reachable for worker bearers — the BFF sends the worker’s exchanged bearer; the cancel attribution string now carries the worker’s own sub. Stay/reopen (appeals nested hops, FU-B), enact-sweep, the batchGet aggregate, and the enrollments CRUD stay service-only (ADR-023 D4). -
Azp allowlist:
canopy-web-exchangeronly.
Endpoints
POST /v1/enrollments
Create an enrollment record (typically auto-created after approval).
Request: CreateEnrollmentRequest
{
"household_id": "uuid",
"determination_id": "uuid",
"application_id": "uuid",
"certification_start_date": "2026-04-01",
"certification_end_date": "2026-09-30",
"monthly_issuance_amount": "535.00",
"expedited": false,
"application_date": "2026-03-15"
}
Response (201): SnapEnrollment object.
Errors: 409 Conflict when the household already has a live enrollment (pending_issuance/active/suspended) — the snap_enrollments_one_live_per_household partial unique (#1130). One live enrollment per household is structural; re-determination adjustment semantics are #1133.
POST /v1/enrollments/{id}/issue
Issue benefits for a month (EBT provisioning).
Request: IssueBenefitsRequest
{
"benefit_month": "2026-04-01"
}
Response (200): SnapBenefitIssuance with proration details if mid-month.
409 when the enrollment’s status is not pending_issuance/active (#1092): terminated/suspended/expired rows keep active = true (the soft-delete flag), so the lifecycle status is the issuance guard — a closed case can no longer draw allotments. pending_issuance stays allowed for the expedited first-issuance flow (the first issuance is what activates the enrollment).
Pending-issuance settlement (background, #1138). The auto-enroll subscriber creates the first issuance pending inside the inbox transaction (no adapter call there — a network round-trip inside the inbox tx is the #1091 anti-pattern); an in-service advisory-locked settlement pass (5 s tick) drives it: EBT adapter outside any transaction, then the lifecycle_revision-fenced mark + first-issuance activation in one short tx — the manual path’s exact sequence. The same pass is the missed-signal reconciliation sweep: a pending issuance on a terminated/suspended enrollment is deliberately NEVER auto-settled (the money may already have moved — #1095’s reconciliation surface) and is surfaced by the hourly aged-pending operator alert instead.
GET /v1/households/{household_id}/issuances
List all benefit issuances for a household across all of its enrollments. Optional query parameters from=YYYY-MM and to=YYYY-MM (both required if either is set) restrict the window; include_all=true adds pending / failed / reversed issuances (default omits them so overpayment math sees only money the household actually received).
RBAC (#408 Pub 1075 AC-6): service bearer or exchanged worker bearer. When the request resolves a HUMAN (EffectiveUser, an exchanged worker — #1443 retired the actor header), they must either carry supervisor/admin role OR have an active household_assignments row in canopy-applications for this household. Bare service = system traffic, allowed unconditionally. Allow and deny paths both emit audit events (enrollment.household_issuance.read / .access_denied) which canopy-security persists via its wildcard subscriber.
POST /v1/households/issuances:batchGet
Get the issued-benefit aggregate for a set of households for ONE benefit
month in one round-trip (#1203, D5 row 3) — one household_id = ANY($1)
set query replacing the federal extracts' 2-hop
list-enrollments-then-list-issuances walk per universe row. 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.
GET-OR-ZERO, exact-set: every requested UNIQUE id gets exactly one
entry — a household with no issued allotments on file for the month
(unknown ids included) ships issued_total: "0" / issuance_count: 0, an
honest zero, never a dropped entry, so consumers assert exact id-set
equality. benefit_month normalizes to its month start server-side (any
day selects that whole month — the QC snapshot date is mid-month), applied
as a half-open [month, month+1) range.
Deliberate semantics ruling ("issued is issued", #1203): there is NO
enrollment-status predicate — an issued allotment under a now-suspended/
terminated/expired enrollment IS counted. Today’s FNS-388 sums only
issuances reached through active/pending_issuance enrollments, a 2-hop
walk artifact that understates issued benefits (QC’s walk never filtered);
the cutover is named again in the MR5 CHANGELOG entry.
RBAC / audit posture: unlike the two #408-gated interactive reads
above, this is a service-tier reporting surface with no worker-actor path —
it emits zero enrollment.household_issuance.read/.access_denied
events (the same unaudited posture as the service-tier enrollment list
reads). The #408-gated endpoints and their gate are untouched.
Request: BatchHouseholdIssuancesRequest
{ "household_ids": ["uuid", "uuid"], "benefit_month": "2026-03-15" }
Response (200): Vec<HouseholdIssuedSummary> —
[{ household_id, issued_total (string Decimal), issuance_count }].
GET /v1/households/{household_id}/annual-summary
Home "Your year" recap (#719). Returns the total + per-month SNAP benefits issued to a household in a calendar year (?year=YYYY, defaults to the current year). Sums allotment_amount for the household’s issuance_status = 'issued' rows grouped by benefit_month within [year-01-01, (year+1)-01-01). Expunged issuances are included — the money was issued (matches the /issuances "issued" listing, which also doesn’t subtract expungements). Shares the {household_id} path with the issuance list (GET annual-summary vs GET issuances are distinct operations under sibling paths). The applicant-portal /home/state proxy calls this with the household derived from the session.
Response (200): AnnualBenefitSummary — { household_id, year, total_issued (string Decimal), months: [{ benefit_month, amount }] }. An unknown / no-issuance household is a 200 with total_issued: "0" and an empty months (never 404).
RBAC: as the issuance list PLUS the #1441 portal arm — require_service_or_exchanged_or_portal on portal:enrollment:read (the portal arrives citizen-class here; on issuances it is 403 portal_on_non_portal_route) — and since #1442 the portal arm requires the signed ownership claim binding this household; the same #408 least-privilege gate + read-audit applies (a resolved worker needs supervisor/admin or an assignment; a bare service or portal read is allowed and unaudited).
POST /v1/enrollments/{id}/terminate
410 Gone (#1095, epic &72): direct termination was removed. A termination is an ADVERSE ACTION — it requires a first-class action row (enrollment_pending_terminations), a dispatched adequate/advance notice, and a stay-free enactment window (7 CFR 273.13; PAMMS 3705); the bare status flip skipped every one of those legal protections. The route stays registered so callers get this explanation instead of a mute 404. Scheduling arrives with the MR 1.2 action API; guarded enactment with the MR 3.2 enact primitive. PAMMS 2415 partial-retention semantics move with it (the cutoff math stays unit-pinned in closure.rs).
Adverse Actions (#1096, epic &72)
The action surface canopy-appeals validates filings against (Phase 2) and canopy-web schedules through. All service-caller. Since #1102 (epic &72 MR 3.2) the schedule command STAGES enrollment.adverse_action_scheduled in its own transaction (producer ACTIVE — the notices consumer deployed at MR 3.1), and every legal DATE on the action spine derives in [jurisdiction].timezone, never UTC.
POST /v1/adverse-actions
Schedule an adverse action. The policy terms (advance-notice days from [notices], CB availability per P4, the jurisdiction@version policy string) are SNAPSHOTTED onto the row — later policy edits never govern an existing action. Default effective date = today (gated decision clock) + advance days; a caller-supplied date may only push LATER for non-exempt actions (earlier → 400). One OPEN action per enrollment (P8): a second schedule 409s. idempotency_key is the worker-source generation key — a retried schedule with the same key returns the SAME action (200; 201 on first creation). 422 when the enrollment has no head_of_household_person_id (pre-#1096 row awaiting the MR 1.3 backfill); 409 against non-terminable enrollments.
System-source provenance (#1107): the optional source claim (ActionSourceClaim { created_source, source_reference, source_generation }) creates the action under the claimant’s own idempotent triple instead of the worker/idempotency-key triple. periodic_report is the only vocabulary value: its source_reference must be the certification UUID and the handler refuses (409) when the (certification, generation) completion tombstone exists — the completion-before-trigger no-op, serialized against the tombstone consumer on a per-source advisory lock. periodic_report-sourced actions snapshot cb_available = false with a PAMMS 3730:37 cb_rule regardless of shape (P4: periodic-report failure is never CB-continuable).
GET /v1/adverse-actions and GET /v1/adverse-actions/{id}
Cursor-paginated list (after_id = last id of the previous page; UUID v7 time order; household_id scope; open=true for scheduled-only) and the single-action lookup a filed appeal must validate against. Both carry active_stays (links currently stayed) and the provenance triple.
POST /v1/adverse-actions/{id}/cancel
Cancels a scheduled action and moots its links ATOMICALLY — every stayed link releases in the same transaction, so no appeal is left staying a dead action. Idempotent (an already-cancelled action returns 200); enacted/vetoed actions 409.
PUT /v1/adverse-actions/{id}/stays/{appeal_id}
The synchronous fenced stay surface: {"command": "stay" | "restay" | "release" | "veto", "actor": …} → a StayReceipt (link_status, active_stays, action_status). built for canopy-appeals to call AT FILING TIME (Phase 2, MR 2.1): the filing flow persists the receipt BEFORE its continued-benefits grant commits, which is what closes the grant-vs-sweep race once that caller lands — the fence and receipt are established here. A NEW stay is refused (409) against a non-scheduled action, so cancel’s links-released invariant holds over time. Idempotent: replaying a terminal link status returns the stored state. A veto also moots a still-scheduled action (action_status: vetoed) — durable and order-independent (it lands even after a release). restay (#1132) is the EXPLICIT judicial re-stay — the one legal released → stayed transition (timely final appeal, Chart B3), idempotent on a stayed link, refused (409) on a vetoed/absent link or a non-scheduled action; the plain stay command keeps its monotonic refusal, so released → stayed happens only through this distinct, audited command (any service caller may send it — per-service command authz is #1008 territory).
GET /v1/adverse-actions/{id}/stays/{appeal_id}
Read one appeal’s link state, receipt-shaped (#1100, epic &72 MR 2.3): the per-appeal ground truth canopy-appeals' reconciliation scanner compares its persisted stay receipts against. Service-caller only; read-only (no signal appended). 404 when no link exists between the pair.
POST /v1/adverse-actions/{id}/reopen
The NARROW periodic-report reopen (#1108, epic &72 MR 5.3 — PAMMS 3730 Chart 3730.1 rows 4/5): un-terminate the enrollment an ENACTED periodic_report-sourced action closed, because the late form/verification arrived. canopy-renewals validates the 30-day window (it owns the 3730 calendar) and calls here; this endpoint enforces the enrollment side’s invariants — source scope (anything else is #1113 restoration territory → 422), action enacted (409), enrollment terminated by THIS action (enactment is the only path to terminated; the terminated_reason == reason_code belt turns any violation into a 409), no live successor enrollment (the household re-applied → 409; a successor committing CONCURRENTLY with the reopen trips the snap_enrollments_one_live_per_household partial unique instead and 409s as the typed raced refusal, #1130), receipt not in the future (400). One transaction under the global lock order + the per-(certification, generation) advisory lock: status back to active (or suspended when a live suspension survived), terminated_* and the PAMMS 2415 retention pair cleared, lifecycle_revision BUMPED (stale pre-reopen fenced EBT writes die), the append-only enrollment_reopens receipt inserted (UNIQUE per action — replay returns it, 200), a reopened signal appended. The action row stays enacted — history is truth. received_date is the PRORATION ANCHOR: the issuance path prorates the receipt month’s allotment from it; months missed between termination and receipt are NOT restored (#1113). No event rides the reopen (the caller holds the synchronous receipt; no consumer exists — topology-first). Service-caller only.
Request: { "received_date": "2026-07-15", "actor": "worker:jdoe" } → 201 AdverseActionReopenView (200 on replay).
POST /v1/adverse-actions/enact-sweep
Run one guarded enact-sweep pass NOW (#1102). Every claimed due action goes through THE enact gate: zero active stays from any appeal (veto refuses), dispatch evidence for the CURRENT notice version (dispatched_date ≤ noticed_effective_date − required_advance_days; exempt actions need only a dispatched adequate notice), and the enact window (past the noticed month = no enact-late). Pass ⇒ terminated with terminated_date = the NOTICED legal date, PAMMS 2415 partial retention, and the lifecycle_revision fence bump; failure ⇒ a successor legal window (re-anchored enact_not_before, notice_repair signal, re-published scheduled event) + an operator alert. Always 200 + the typed EnactSweepReport bucketed by outcome: since #1220 (scale audit H14, ADR-001 A1 §B6-ii) the sweep claims due actions in bounded batches via FOR UPDATE SKIP LOCKED leases, so concurrent passes — replicas, the hourly loop, this trigger — share the due cohort instead of electing an advisory-lock leader (the pre-#1220 202 skipped arm is gone); rows a concurrent pass claimed are simply absent from this report — they are handled by whichever pass claimed them (an API-triggered pass returns its report; the background loop’s report surfaces only in its log line). Double-enact stays impossible at the gate (the lease is a work-sharing hint, not the correctness authority). Service-caller only.
Error Codes
| Code | Meaning |
|---|---|
400 |
Invalid request body, or invalid |
401 |
Missing or invalid JWT |
403 |
Requires eligibility_specialist role or above; or actor not authorized for the household (#408) |
404 |
Enrollment not found |
400 |
|
409 |
Duplicate issuance for benefit month; enrollment not issuable (#1092); the lifecycle_revision fence tripped mid-issuance (#1095 — the EBT transfer stays |
410 |
Direct terminate (removed by #1095 — see above) |
422 |
Unknown field in |
Benefit Proration
If the effective date is mid-month, benefits are prorated: (monthly_amount / days_in_month) * remaining_days, rounded DOWN to the nearest lower whole dollar (7 CFR 273.10(a)(1)(ii); the mode is the cited snap.issuance.proration_rounding jurisdiction key, #1129). Two anchors share the math (issuance::prorate_month_from): the persisted application_date for the first issuance in the application month, and — since #1108 — a periodic-report reopen’s received_date for the receipt month (Chart 3730.1: "prorate benefits from the date … received"; the issuance path reads the enrollment_reopens anchor whose receipt month equals the requested benefit month). An INITIAL month prorating under the snap.issuance.initial_proration_minimum_cents floor ($10) is refused with a 409 per 7 CFR 273.10(a)(1)(iii) — the reopen anchor is deliberately NOT suppressed (Chart 3730.1 sits outside the initial-month text; suppressing would take money from the household without federal basis).
Events Published
-
enrollment.adverse_action_scheduled(#1102 — the pinnedAdverseActionScheduledV1: action id, provenance triple incl.created_source/source_reference, recipient, verbatim legaleffective_date, pre-formatted benefit amount,cb_available; published at schedule time and for every successor legal window minted by evidence repair or post-decision reschedule) -
enrollment.adverse_action_terminated(#1107 — pinnedAdverseActionTerminatedV1: action/enrollment/household ids, reason code, the NOTICEDterminated_date, and the full provenance triple; staged in the SAME transaction as the guarded enact’s termination writes, so the event exists iff the termination does. Consumed by canopy-renewals' periodic-report terminal consumer.) -
enrollment.adverse_action_vetoed(#1107 — pinnedAdverseActionVetoedV1withveto_source=stay_command|appeal_reversal+ the provenance triple; staged with the veto write, only when the action actually mootedscheduled → vetoed. Routes aperiodic_report-sourced action’s cycle to renewals' re-determination worker queue.) -
enrollment.adverse_action_cancelled(#1107 — pinnedAdverseActionCancelledV1with the cancelreason+actor+ the provenance triple; staged with the cancel write on newly-cancelled only.reason = periodic_report_completedmarks the tombstone consumer’s benign supersession — consumers must not route it to a worker.) -
enrollment.created,enrollment.benefits_issued,enrollment.expungement_pending,enrollment.household_issuance.read/.access_denied -
benefit.issued,benefit.expunged
Events Consumed
-
renewal.snap_periodic_report_processed(#1107, queuecanopy-enrollment.events) — records the per-(certification, generation)completion tombstone inperiodic_report_completionsAND cancels a still-scheduled matchingperiodic_report-sourced action in the same inbox transaction (reason = periodic_report_completed, cancelled event staged with it). An already-ENACTED match with a reopen receipt on record is the expected #1108 epilogue (reopen first, then the worker completes — debug-quiet); an enacted match with NO reopen receipt is the un-reopened late completion → operator alert naming the reopen endpoint. -
determination.completed.snap— auto-enrollment (#1014 typed consumption). -
notice.generated/notice.dispatched— order-independent notice-evidence stamps ontoadverse_action_notices(#1101; legacy notices without anadverse_action_idskip, present-but-garbage nacks). -
appeal.decision_recorded/appeal.withdrawal_finalized— Phase-2 appeal-resolution convergence (#1102), inside the inbox transaction so the enact gate reads its own link writes: reversal = durable veto (tombstoningtermination_appeal_linksso a late stay grant hits the monotonic refusal; a reversal after enactment appends thereversal_after_enactmentrestoration signal + operator alert), agency-favorable with a recorded CB cessation reschedules the action to the next issuance cycle under post-decision ADEQUATE notice (Chart B1 — the successor letter never re-offers CB), withdrawal finalization releases the stay and runs the gate (a lapsed window repairs with the resumption re-notice).