Milestone: M5 — Disclosure and Verification | Requirement Clarity: 🟡 Flow and schema confirmed against DataChecker's own public docs (2026-09-22); still need real account access before building. Client-requested CR, not originally scoped for MVP. | Dev Status: ❌ Not started — planning only, see the artifact plan for the full write-up this doc summarises
M5-19 — Right to Work: DataChecker SecureIDLink Integration¶
Why this exists¶
The right_to_work package component has existed since M5-01/M5-02 (named packages: Disclose Credentials / Right to Work / Reclaim Employment) but was always explicitly "for info only" — DisclosurePackage.hasUnavailableComponents flags it, and the New Request dialog tells the recruiter it "runs credential disclosure only until it ships" (guide §10 risk #10). This CR (raised by the client 2026-09-22) is what actually ships it: when a disclosure request uses a package that includes Right to Work, the candidate should be sent a QR code (in the disclosure email) that runs a real DataChecker identity/right-to-work check.
The API, confirmed against DataChecker's own docs¶
Two separate DataChecker integration patterns exist — do not conflate them:
| SecureIDLink | SDK Token | |
|---|---|---|
| What it is | A shareable link + QR code sent to the candidate; they complete capture on DataChecker's own hosted page | An unlock token for embedding DataChecker's client-side capture SDK directly in our UI |
| Matches this CR? | Yes — this is what "QR code in the disclosure email" means | No — would mean building our own document/face-capture screens, a materially different and larger task |
| Endpoint | POST /api/v2/secureidlink |
GET /api/v2/sdk/token |
Flow:
1. OAuth 2.0 client-credentials grant, POST /api/v2/oauth/token (Basic Auth, scope productapi.secureidlink.write)
2. POST /api/v2/secureidlink — request body: product (required), emailAddress (required), name (required); phoneNumber is optional, only relevant if communicationModes includes MODE_SMS, which we don't use. Response: base64 PNG qrCode + a plain HTTPS link fallback.
3. Candidate scans/clicks, completes capture on DataChecker's hosted page — entirely outside our app.
4. Result: GET /api/v2/poll, then GET /api/v2/result/{resultId} for full detail. A webhook option exists but is not the plan — see "Polling, not webhooks" below.
Result schema (confirmed, not guessed)¶
Top-level result: UNKNOWN | APPROVED | DISAPPROVED | TECHNICAL_REJECT. Relevant sections:
identity.result+identity.data(name, DOB, document type/number, nationality, etc.) +identity.documentValidation(per-feature checks: hologram, watermark, font, etc. — eachAPPROVEDorNOT_APPLICABLE, same pattern as Velocity'sNOT_APPLICABLE-counts-as-pass convention we already use for verification checks)rightToWork.result+rightToWork.data(vsFromDate/vsDueDate/twvFromDate/twvDueDate, jurisdiction-specific fields) +rightToWork.rtwType:RTW_NL | RTW_BE | RTW_DE | RTW_UK— we needRTW_UK, which implies theproductrequested at step 2 must be a product that includes an RTW_UK check; the exact product code isn't in the public docs and has to come from whatever DataChecker provisions on our account (see Blockers).faceVerify.result— Premium-tier products onlymessages(reject/disapprove reasons),images(base64 document scans — do not persist these, same reasoning as never storing Velocity's raw signed credential; keep only the curated result fields we actually display)
Testing without live credentials — Mocked Responses¶
DataChecker's staging environment (developer.staging.datachecker.nl) accepts a dc-test request header that returns an immediate mocked result — no real document processing, no production credentials needed:
| Header value | Result |
|---|---|
APPROVED |
Passing result |
APPROVED_EU / APPROVED_NON_EU / APPROVED_RP |
Approved, specific document types (Polish passport / Ukrainian passport / Dutch residence permit) |
DISAPPROVED |
Failing result |
TECHNICAL_REJECT |
Rejected result |
This means Phase 1 and Phase 2 (see below) can both be built and fully tested against realistic, varied outcomes before we have a single real production transaction — matching the same "verify against real staging before trusting docs" discipline the Velocity integration was built with all milestone.
Polling, not webhooks¶
Consistent with every other async result in this codebase — ClaimSyncService (issuance) and DisclosurePollingService (disclosure/verification) both poll because CIH has no webhooks at all. DataChecker does offer a webhook option, but there is no webhook receiver anywhere in this app yet, and introducing the first one is a bigger architectural change than extending an existing 5-minute poll cycle to also check DataChecker. Recommendation: extend DisclosurePollingService, don't build a new receiver.
What needs building¶
DataCheckerApiService— typed client, same shape asCihApiService/InspectorApiService(token fetch/cache, structured error mapping)datachecker.config.ts— client ID/secret, API base URL,product/rtwTypecode, env-driven, mirrorsdisclosure.config.ts- Schema: new columns on
PresentationRequest(or a small new entity) for the DataChecker session id, QR/link, and result status - Trigger:
DisclosureService.openNewShareAttempt()— whenpkg.components.includes('right_to_work'), call SecureIDLink alongside the existing depot/presentation-link creation - Email: inline
<img>QR in the HTML template + the fallbacklinkin both HTML and plain text (same "always ship a real https fallback" rule the wallet-link fix already established — Gmail/webmail strip non-standard-scheme links) - Poller: extend
DisclosurePollingService's existing 5-minute cycle to also check DataChecker for any request with a pending RTW result - UI: a second, independent status on the disclosure detail page — RTW and credential verification are separate checks on separate timelines, never merged into one status (decision below)
- Cleanup: drop
right_to_workfromDisclosurePackage.hasUnavailableComponentsand the frontend'sUNAVAILABLE_COMPONENTSset once live; update M5-00/M5-01/M5-02 and the architecture doc's known-limitations table
Decisions made this round¶
- RTW status is independent of credential verification, never blocking. Two separate checks with separate timelines shown side by side — client deferred to our recommendation (2026-09-22).
- Polling, not a webhook receiver — see above.
- SecureIDLink, not SDK Token — see the comparison table above; SDK Token would mean building our own capture UI, which is out of scope for what was actually requested.
- No new form field for phone number — confirmed optional, not required, by DataChecker's own schema.
Blockers & Risks¶
- No live DataChecker credentials yet. The client says DataChecker will provide admin account access, which should let us self-serve a staging (and eventually production) client ID/secret — but nothing exists yet as of 2026-09-22. Building against the
dc-testmock header does not require this, so Phase 1/2 build work isn't blocked on it; a real end-to-end candidate test is. - Exact
product/rtwTypecode unconfirmed. The public docs don't list product codes — this is account-specific and will only be known once DataChecker provisions the account. Needed before the config/trigger call can use real values (mocked testing can use a placeholder in the meantime). - Images/documents returned by DataChecker must not be persisted — same data-minimisation posture already applied to Velocity's raw credential JWTs; store only the curated result fields the UI actually shows.
Suggested build order¶
Phase 1 — trigger + QR delivery. DataCheckerApiService + config, call SecureIDLink on request creation, QR + fallback link in the disclosure email. Result ingestion stubbed. Fully testable now via dc-test mocked responses.
Phase 2 — result ingestion + display. Poller extension, result model (built from the confirmed schema above), detail-page status. Also fully testable via mocked responses before any real account access exists.
Related¶
- M5-00 implementation guide — parent doc
- M5-01 disclosure request setup / M5-02 disclosure policy configuration — where the
right_to_workpackage component was first defined as for-info-only - DataChecker developer docs — primary source for this whole doc