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

http://localhost:8005

Authentication

Internal adapter routes use the X-Service-Api-Key header (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-protected verifications paths; the internal adapter routes carry no utoipa decorators)

Database

canopy_verification (verifications + ievs_hits tables; 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_exchanged on POST /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 /v1 routes are dual and guard-unchanged behind the exchanged_gate.

  • The portal’s two surfaces (pending list, respond) carry the #1441 portal arm: require_dual_or_portal on portal: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/v1 X-Service-Api-Key surface (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-exchanger only.

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):

  1. Georgia DOL State Wage Records (SWR)

  2. Georgia DOL Unemployment Insurance (UI)

  3. SSA SDX (Supplemental Security Income)

  4. 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).

NOTE

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 (/v1/verifications…​ routes)

403

JWT lacks the required role — service caller (POST /v1/verifications) or service-caller-or-caseworker-or-above (other /v1/verifications…​ routes); or POST /v1/verifications/{id}/respond with an application_id that does not match the verification’s scope

404

POST /v1/verifications/{id}/{resolve,respond} — verification id not found

422

Unresolvable household_id on POST /v1/verifications or POST /internal/v1/ievs/match (ADR-025 cross-service referential check); or POST /v1/verifications/{id}/respond with an empty response / a verification not scoped to an application

500

Upstream data source unavailable (internal IEVS / SAVE / SSA SOLQ adapter error)

502

POST /internal/v1/ievs/match — upstream/transport error reaching canopy-persons during ADR-025 household validation

Adapter selection (configuration)

The IEVS and SAVE adapters are selected at boot per ADR-012 config (Plan 3 MR10):

Env var Values Effect

CANOPY_VERIFICATION__IEVS_ADAPTER

noop (default) | scripted

noop = SSN-suffix-keyed deterministic test data (NoopIevsAdapter). scripted = fixture-driven per-person responses (ScriptedIevsAdapter).

CANOPY_VERIFICATION__SAVE_ADAPTER

noop (default) | scripted

As above for SAVE (NoopSaveAdapter / ScriptedSaveAdapter).

CANOPY_VERIFICATIONIEVS_SCRIPTS_PATH
CANOPY_VERIFICATION
SAVE_SCRIPTS_PATH

path

TOML fixture loaded at boot when the matching adapter is scripted. Defaults devstack/fixtures/{ievs,save}-scripts.toml.

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 lightweight ievs_hits audit row (member name + source + hit type, best-effort) for the worker "IEVS alerts" panel (#522)

  • The verifications work-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

Edit this page · default