Skip to content

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. — each APPROVED or NOT_APPLICABLE, same pattern as Velocity's NOT_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 need RTW_UK, which implies the product requested 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 only
  • messages (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 as CihApiService/InspectorApiService (token fetch/cache, structured error mapping)
  • datachecker.config.ts — client ID/secret, API base URL, product/rtwType code, env-driven, mirrors disclosure.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() — when pkg.components.includes('right_to_work'), call SecureIDLink alongside the existing depot/presentation-link creation
  • Email: inline <img> QR in the HTML template + the fallback link in 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_work from DisclosurePackage.hasUnavailableComponents and the frontend's UNAVAILABLE_COMPONENTS set 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-test mock 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 / rtwType code 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.