canopy-verification API Reference
On this page
Overview
Service that brokers income verification requests to external federal and state data sources. The IEVS / SAVE / SSA SOLQ adapter brokering is transient — it does not persist the adapter wire payloads (per ADR-004, IEVS data is stored only in the requesting program service’s database). The service does carry a small worker-portal-facing domain surface: a verifications work-item table (feeds the dashboard "Pending verifications" panel, #519) and an ievs_hits audit table written best-effort on each IEVS match (feeds the "IEVS alerts" panel, #522).
- Base URL
- Authentication
-
Internal adapter routes use the
X-Service-Api-Keyheader (not JWT); worker-portal/v1/verifications…routes use service-class JWT (service caller or caseworker-or-above) - Public endpoints
-
None — internal adapter routes are service-to-service;
/v1/verifications…routes require a JWT - Swagger UI
-
http://localhost:8005/swagger-ui(documents the five JWT-protectedverificationspaths; the internal adapter routes carry no utoipa decorators) - Database
-
canopy_verification(verifications+ievs_hitstables; also used for health check)
Receiver contract (OIDC S-verification, #1434 / ADR-043 §C)
canopy-verification is the tenth 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 verification specifics:
-
require_service_or_exchangedonPOST /v1/verifications— the orchestrator’s service token OR the worker’s exchanged bearer (the BFF’s request-verification action now sends it; the G3 auto-resolve leg in accept_document likewise rides the exchanged bearer against the dual resolve route). Direct worker bearers stay 403 on the create. All other/v1routes are dual and guard-unchanged behind theexchanged_gate. -
The portal’s two surfaces (pending list, respond) carry the #1441 portal arm:
require_dual_or_portalonportal:verifications:read/portal:verifications:respond— the portal arrives citizen-class (azp + scope); the service and worker arms are unchanged. Since #1442 the citizen arm also carries the signed ownership claim: the list must scope to the claim’s application, and respond binds the stored application + the response’s subject person BEFORE the legacy 403/422 arms (a citizen-class probe of a foreign id is a uniform 404, closing their existence oracle). -
The
/internal/v1X-Service-Api-Keysurface (IEVS/SAVE/SOLQ) is OUTSIDE the JWT router — no Claims exist there, so no receiver arm applies; its retirement to ADR-019 service tokens is tracked separately. -
Azp allowlist:
canopy-web-exchangeronly.
Internal Endpoints
POST /internal/v1/ievs/match
Query IEVS data sources for a single person. Each requested source carries
its typed adapter class (#1497: state_wage / state_ui / ssa_sdx /
ssa_bendex), computed at the sender from jurisdiction config (ADR-003) —
the endpoint dispatches on class, never on the identifier literal, and a
class outside that vocabulary is rejected 422 at deserialization (never a
silent skip). Stored ievs_hits rows are labelled with the requested
source identifier verbatim.
Request:
{
"application_id": "uuid",
"person_id": "uuid",
"household_id": "uuid",
"match_request": {
"ssn": "123456789",
"first_name": "…",
"last_name": "…",
"date_of_birth": "1990-01-15",
"quarters": 4
},
"sources": [
{ "source": "georgia_dol_swr", "class": "state_wage" },
{ "source": "georgia_dol_ui", "class": "state_ui" },
{ "source": "ssa_sdx", "class": "ssa_sdx" },
{ "source": "ssa_bendex", "class": "ssa_bendex" }
]
}
Headers: X-Service-Api-Key: <CANOPY_INTERNAL_API_KEY>
Response (200):
{
"person_id": "uuid",
"match_status": "completed",
"wage_records": [
{
"employer": "Acme Corp",
"quarterly_wages": 7500.00,
"quarter": "2026-Q1"
}
],
"ui_record": { "weekly_benefit_amount": 0 },
"sdx_record": { "monthly_ssi_amount": 0 },
"bendex_record": { "monthly_benefit_amount": 0 }
}
Data sources queried (via IevsAdapter trait):
-
Georgia DOL State Wage Records (SWR)
-
Georgia DOL Unemployment Insurance (UI)
-
SSA SDX (Supplemental Security Income)
-
SSA BENDEX (Social Security benefits)
In UAT, NoopIevsAdapter returns deterministic test data keyed on the SSN
suffix. The scripted adapter (Plan 3 MR10) instead returns fixture-driven
per-person responses for the demo personas — see Adapter selection (configuration).
Per ADR-025, the handler validates household_id against canopy-persons before dispatching to the IEVS adapter. A non-existent household returns 422; an upstream/transport error reaching canopy-persons returns 502 (the internal endpoint surfaces raw HTTP status codes, not structured ApiError bodies).
POST /internal/v1/save/verify
Query DHS SAVE for immigration status verification.
Request:
{
"alien_registration_number": "A123456789",
"person_id": "uuid"
}
Response (200):
{
"person_id": "uuid",
"verification_status": "lawful_permanent_resident",
"step": "initial",
"category_code": "C05"
}
In UAT, NoopSaveAdapter returns deterministic test data keyed on the
alien-registration-number suffix; the scripted adapter returns fixture-driven
per-person responses (see Adapter selection (configuration)).
POST /internal/v1/save/additional-verification
Submit a second-step SAVE verification for a multi-step case (institution case-number returned by the initial /save/verify step).
Request:
{
"application_id": "uuid",
"person_id": "uuid",
"case_number": "SAVE-CASE-0001"
}
Headers: X-Service-Api-Key: <CANOPY_INTERNAL_API_KEY>
Response (200): same SaveVerifyHttpResponse shape as /save/verify (application_id, person_id, verification_response).
In UAT, NoopSaveAdapter returns deterministic test data keyed on the
alien-registration-number suffix; the scripted adapter returns fixture-driven
per-person responses (see Adapter selection (configuration)).
POST /internal/v1/ssa/solq
Query SSA SOLQ/BINDEX for a single person. Powers the ABD FBR SSA-linked Medicaid COAs (Pickle PAMMS 2120 / DAC PAMMS 2122 / Disabled Widow PAMMS 2124 / Widow 60-64 PAMMS 2126 / Former SSI Disabled Child PAMMS 2128) and feeds disability-onset data for waiver/institutional COAs (Phase E).
Per ADR-004, SOLQ is Medicaid-scoped under the Computer Matching Agreement — distinct from the IEVS SDX/BENDEX path on /internal/v1/ievs/match, which is SNAP-only under 7 USC §2025(e). The two surfaces have different legal authorities and cannot share storage.
Request:
{
"application_id": "uuid",
"person_id": "uuid",
"query": {
"ssn": "123-45-6789",
"first_name": "Jane",
"last_name": "Smith",
"date_of_birth": "1955-06-15"
}
}
Headers: X-Service-Api-Key: <CANOPY_INTERNAL_API_KEY>
Response (200):
{
"application_id": "uuid",
"person_id": "uuid",
"record": {
"ssi_active": false,
"monthly_ssi_amount": null,
"lost_ssi_due_to_cola_flag": true,
"benefit_category": "OASDI",
"monthly_benefit_amount": "1250.00",
"disability_onset_date": null,
"lost_ssi_as_disabled_child_flag": false
}
}
record is null when SSA has no record for the applicant.
In UAT, NoopSolqAdapter returns deterministic test data keyed on the last two digits of the SSN field:
| Suffix | Fixture persona |
|---|---|
00-19 |
No SSA record |
20-29 |
Active SSI recipient |
30-39 |
Pickle (lost SSI due to COLA) |
40-49 |
DAC — Disabled Adult Child |
50-59 |
Disabled Widow 50-64 |
60-69 |
Widow 60-64 non-disabled |
70-79 |
Former SSI disabled child (Zebley / age-18) |
80-99 |
OASDI only, no SSI loss |
Step 4(b) of the medicaid-ssa-orchestrator-wiring plan (#384) replaces this Noop adapter with a real SSA SOLQ/BINDEX transport — blocked on Computer Matching Agreement execution.
Worker-Portal Endpoints
JWT-protected /v1/verifications… surface (epic &51) feeding the worker dashboard. These ride the /v1 mount that canopy_api adds — distinct from the X-Service-Api-Key internal adapter routes above.
GET /v1/verifications
List pending verification work items. Feeds the worker-dashboard "Pending verifications" panel (#519) and the intake "Run determination" gate (worker-intake-program-independence MR2, which asks for limit=1 scoped to one application_id).
Minimum role: service caller or caseworker-or-above
Query parameters: status (defaults to pending; the only value rendered in Phase 1 — any other value returns an empty list), worker_id (UUID, optional scope), application_id (UUID, optional scope), document_id (UUID, optional — returns the pending verification(s) a given document was attached to via verification_responses; the worker portal queries this on document Accept to resolve the verification that document satisfied — Plan 4 G3), limit (1–50, default 10).
Response (200): array of Verification.
[
{
"id": "uuid",
"application_id": "uuid",
"household_id": "uuid",
"person_id": "uuid",
"worker_id": "uuid",
"verification_type": "income",
"status": "pending",
"requested_at": "2026-05-28T14:00:00Z",
"due_date": "2026-06-11",
"completed_at": null,
"completed_by": null,
"notes": null
}
]
POST /v1/verifications
Producer-create. canopy-eligibility’s orchestrator calls this once per verification_items_required entry on each program determination. Per ADR-025 the household_id is validated against canopy-persons — an unresolvable household returns 422. Idempotent on the OPEN natural key (application_id, household_id, verification_type) per ADR-002 Amendment 1 D6 (#1480): while an item for the triple is pending/in_progress, a replayed registration converges on it instead of duplicating the worker-queue row. Rows created without an application_id sit outside the dedup key.
Minimum role: service caller (service-class JWT only)
Request:
{
"application_id": "uuid",
"household_id": "uuid",
"person_id": "uuid",
"verification_type": "income",
"due_date": "2026-06-11"
}
Response (201): the persisted Verification row. (200): an open item already existed for the triple — returned unchanged (#1480 dedup convergence).
POST /v1/verifications/{id}/resolve
Worker marks a pending verification item complete. Returns 404 when the id does not exist.
Minimum role: service caller or caseworker-or-above
Request:
{
"completed_by": "uuid",
"notes": "Pay stubs received + filed under household docs"
}
Response (200): the resolved Verification row.
POST /v1/verifications/{id}/respond
An applicant (via the canopy-portal /verifications inbox) or a worker attaches previously-uploaded documents and/or a free-text note to a pending verification (Plan 3 MR10b). Writes one verification_responses row per attached document plus an optional text-only row.
application_id + person_id are supplied by the caller — the canopy-portal proxy derives them from the trusted session, never from raw client input — and since #1442 the ORIGIN independently binds the CITIZEN session first: the stored verification’s application must match the signed ownership claim (a foreign id gets the same uniform 404 as an absent one — never a coded response that confirms it exists) and the response’s subject person must be the session’s own. For the session’s OWN verification the legacy arms keep their meaning: a supplied application_id that mismatches the stored scope is 403, an unscoped verification is 422, and an empty response (no documents and no text) is 422. document_id is a cross-service reference to canopy_applications.application_documents.id; it is stored, not validated here (the worker re-fetches through the application-scoped content endpoint, which 404s a stale/foreign id).
Minimum role: service caller or caseworker-or-above
Request:
{
"application_id": "uuid",
"person_id": "uuid",
"document_ids": ["uuid", "uuid"],
"response_text": "Attached my ID and a pay stub.",
"responded_by_source": "applicant_portal"
}
Response (201): the VerificationResponse rows just written (document rows first, then the text row).
GET /v1/verifications/{id}/responses
Read back the responses submitted against a verification, newest first. Feeds the worker case-detail Verifications section (MR10c).
Minimum role: service caller or caseworker-or-above
Response (200): an array of VerificationResponse rows (id, verification_id, document_id, application_id, person_id, response_text, responded_at, responded_by_source).
GET /v1/verifications/ievs/discrepancies
List recent IEVS hits with status = 'unreviewed' (the subset that warrants worker review), newest first. Feeds the worker-dashboard "IEVS alerts" panel (#522). The producer write path is the internal POST /internal/v1/ievs/match handler, which persists one ievs_hits row per populated adapter record (best-effort).
Minimum role: service caller or caseworker-or-above
Query parameters: limit (1–50, default 10).
Response (200): array of IevsHit.
[
{
"id": "uuid",
"application_id": "uuid",
"household_id": "uuid",
"person_id": "uuid",
"member_name": "Jane Smith",
"source": "georgia_dol_swr",
"hit_type": "wage_record",
"hit_at": "2026-05-28T14:00:00Z",
"status": "unreviewed",
"reviewed_at": null,
"reviewed_by": null,
"notes": null
}
]
Error Codes
| Code | Meaning |
|---|---|
401 |
Missing or invalid X-Service-Api-Key (internal adapter routes) or missing/invalid JWT ( |
403 |
JWT lacks the required role — service caller (POST |
404 |
|
422 |
Unresolvable |
500 |
Upstream data source unavailable (internal IEVS / SAVE / SSA SOLQ adapter error) |
502 |
|
Adapter selection (configuration)
The IEVS and SAVE adapters are selected at boot per ADR-012 config (Plan 3 MR10):
| Env var | Values | Effect |
|---|---|---|
|
|
|
|
|
As above for SAVE ( |
|
path |
TOML fixture loaded at boot when the matching adapter is |
The scripted adapters key their fixture lookup on person_id (the one
identifier available to both the IEVS and SAVE handlers — SaveVerifyHttpRequest
carries no household_id; ADR-013 deviation from the plan’s household_id
sketch). Fixtures key on the fixed-UUID applicant-portal personas; the
RNG-driven dashboard archetypes get at-rest ievs_hits stamped by the seed and
never reach the adapter. Unknown person_ids yield an empty IEVS match; SAVE
errors loudly for an unscripted person (it has no benign empty status). Selection
is dynamic-dispatch-free — the trait uses RPITIT, so an IevsAdapterKind /
SaveAdapterKind enum dispatches per arm. The demo devstack flips both to
scripted in MR11; UAT/prod keep noop until the real Georgia-DOL / DHS-SAVE
adapters land behind the same trait.
Notes
-
The internal adapter routes are called by canopy-snap during
verification::run_verification() -
IEVS match wire payloads are stored in
canopy-snap.ievs_match_results— NOT in this service’s database; this service persists only a lightweightievs_hitsaudit row (member name + source + hit type, best-effort) for the worker "IEVS alerts" panel (#522) -
The
verificationswork-item table is owned here; the eligibility orchestrator is the producer and the canopy-web BFF is the reader (#519) -
Event bus payload validation blocks all IEVS field names from being published (27 restricted fields)
-
SSA SOLQ records are fetched by canopy-eligibility pre-dispatch for Medicaid requests (#384); per ADR-004 the records themselves are forwarded to canopy-medicaid and stored only in its database — never persisted here