validate_identity_document (Entrust / Onfido)

Business purpose: let a supplier owner verify their identity themselves through Entrust Identity Verification (Onfido) — document capture plus a liveness check — then persist the captured artifacts and the verdict back into Payability.

Au10tix / Entrust coexistence. Entrust Identity Verification (Onfido) is the eventual replacement for Au10tix identity-document validation. During the rollout both vendors run under the same validate_identity_document process, so the GSI, dedup logic and admin reporting keep working unchanged. ToolType.get_by_process_name therefore still maps validate_identity_document to AU10TIX; the Entrust code paths reference ToolType.ENTRUST directly. Do not switch that mapping until Au10tix is fully retired.

Shape of the flow

Unlike the other processes this is not a two-step ProcessFactory chain:

Stage Implementation

"Request"

POST /entrust/sessions — endpoint handler, not a task. Mints the Onfido applicant + workflow run and persists the pending task and tool rows.

Trigger

POST /entrust/webhook — a thin, signature-verified receiver that only schedules the async task.

"Handle"

entrust/handle_identity_document_validation_response — the SWF activity that does all the work. Registered in TaskFactory, absent from PREDEFINED_PROCESS_STEPS.

Rerunning is possible: POST /tasks/execute with task_name = "entrust/handle_identity_document_validation_response" reprocesses the supplier’s latest Entrust run (internal callers only) — see api/tasks.adoc#entrust-reprocess.

Session creation

Summarised here; full request/response contract in api/entrust.adoc#create-session.

  1. Authorize by source — INTERNAL needs DEV/FINANCE; SUPPLIER_DASHBOARD and ONBOARDING are self-service.

  2. Resolve identity — supplier-dashboard callers are pinned to their JWT identity; internal and onboarding callers must pass supplier_key + person_id.

  3. Reuse a pending session whose link_expires_at is still in the future, otherwise enforce the 3-sessions-per-person cap for self-service sources.

  4. Fetch the owner’s first/last name from the Payability internal API (Onfido requires them).

  5. EntrustClient.create_applicant then create_workflow_run, tagging the run with custom_data = {supplier_key}.

  6. Persist: TaskExecutionTable.save(task_id=workflow_run_id, …) and ToolTable.update(ENTRUST, PENDING).

  7. Best-effort external syncs → HubSpot entrust_verification_status = in_progress, plus a supplier event with the same status.

Webhook receiver

interface/web_api/endpoint_handling/handle_entrust_webhook.py:

  • Verify — X-SHA2-Signature must equal HMAC-SHA256(raw_body, ENTRUST_WEBHOOK_TOKEN) in hex, compared with hmac.compare_digest. Missing or mismatched → 401. A body that is not valid JSON → 400.

  • Filter — only payload.resource_type == "workflow_run" and payload.action == "workflow_run.completed" are processed; anything else is acknowledged with 200 {"status": "ignored"} so Onfido stops retrying.

  • Correlate — payload.object.id is the workflow_run_id, i.e. the TaskExecutionTable task_id. The row supplies supplier_key, person_id and applicant_id. No row → 200 {"status": "ignored"} without scheduling.

  • Enqueue — B2BTaskManager.start_swf with a deterministic workflow id Automatic UW Tool - Entrust - {workflow_run_id}, so concurrent redeliveries dedup inside SWF; a CannotRunWorkflow "already started" is treated as already-scheduled and still answered 200 {"status": "accepted"}.

  • Any unexpected error → 500, which makes Onfido retry the delivery.

{
  "payload": {
    "resource_type": "workflow_run",
    "action": "workflow_run.completed",
    "object": {
      "id": "b1f7d0c2-3f4e-4a1b-9c8d-2e5f6a7b8c9d",
      "status": "approved",
      "completed_at_iso8601": "2026-08-12T09:14:03Z",
      "href": "https://api.us.onfido.com/v3.6/workflow_runs/b1f7d0c2-3f4e-4a1b-9c8d-2e5f6a7b8c9d"
    }
  }
}

The aftermath task

task/entrust/handle_identity_document_validation_response/. Input: {supplier_key, person_id, workflow_run_id, applicant_id?}. correlation_id == workflow_run_id.

1. Idempotency guard

If the persisted row is already processed, a redelivered webhook produced a duplicate run: the task logs and returns the existing output (raw_result_s3_path, status, document_actions) without touching anything. A missing row is tolerated — processing continues and the terminal update upserts it.

2. Re-read and archive the run

EntrustClient.get_workflow_run is the source of truth for status (the webhook body carries only a minimal object). get_workflow_run_tasks then hydrates every step with its input and output, and the merged document ({…​run, tasks: […​]}) is archived to:

entrust/identity_document_validation/supplier_key=<sk>/person_id=<pid>/<timestamp>.json

Step hydration is best effort: it fans out into one request per step, and if it fails the run summary is archived without tasks rather than blocking the verdict.

3. Document handling (required)

Guarded by additional_data.documents_uploaded; skipped entirely when the session recorded no applicant_id. A failure anywhere in this step fails the task so SWF retries — the raised error carries the upstream response body so the rejection reason is visible.

  1. Archive stale captures — list_supplier_documents(IDENTIFICATION_DOCUMENT), then a single update_supplier_document_statuses(…​, 'archived') over every document whose compliance.status is not already archived (both pending and approved). The fresh capture must fully supersede prior attempts.

  2. Upload the identity documents + selfie — get_applicant_files(applicant_id) downloads the Onfido documents and live photos, and upload_supplier_documents posts them as one multipart request.

  3. Upload the liveness media — get_applicant_liveness_files(applicant_id) (live videos and motion captures) in a second request, after the documents.

Both uploads use document_type = IDENTIFICATION_DOCUMENT and the KYC identity compliance_key, and carry positionally-aligned per-file metadata (one entry per files part, empty string where absent):

Field Value

side

front / back — identity documents only.

document_types

The Onfido document type (e.g. driving_licence); video_selfie for a liveness video; empty otherwise.

issuing_country

The ISO issuing country — identity documents only.

expiration_date

The run’s date_of_expiry, taken from the document-check step’s output.properties; applied to documents and liveness media, empty for the selfie.

The ids returned by the uploads land on additional_data.uploaded_document_ids, and a summary on additional_data.document_actions:

{
  "existing_documents_archived": 2,
  "id_files_uploaded": 3,
  "liveness_files_uploaded": 1,
  "uploaded_total": 4,
  "approved": 4
}

4. Verdict

Onfido status Result

review

Manual review pending. The ENTRUST tool stays PENDING, the task row stays pending (only the guard flags and document_actions are persisted), no final status is written and no ProcessFinishedEvent is emitted. External syncs → in_review.

approved

Verdict VERIFIED. The freshly uploaded documents are promoted with update_supplier_document_statuses(…​, 'approved') (guarded by additional_data.documents_approved); the internal API cascades the "Upload ID" compliance status once all identity documents are approved. External syncs → verified.

anything else (declined, abandoned, error, …)

Verdict NOT_VERIFIED; the uploaded documents stay pending for manual handling. External syncs → denied.

Onfido gives one overall verdict, so all four identity-document final-status fields (DocumentAuthenticity, IDNameMatch, IsDocumentExpired, OverallQuality) mirror it — keeping the existing override schema intact.

On a terminal verdict the task:

  • upserts FinalStatusTable (idempotent: save if absent, update if present),

  • sets ToolTable ENTRUST → VERIFIED / NOT_VERIFIED,

  • marks the task processed with an enriched output (S3 pointer plus the salient run fields: workflow_run_id, applicant_id, workflow_id, workflow_version_id, status, dashboard_url, output, reasons, error, created_at, updated_at, document_actions; None values are dropped) and additional_data.full_result_s3_path,

  • emits ProcessFinishedEvent,

  • syncs HubSpot and emits the supplier event.

The SWF activity result itself is compact so the SWF console is useful on its own:

{
  "raw_result_s3_path": "entrust/identity_document_validation/supplier_key=9b1c.../person_id=44f2.../2026-08-12T09:15:11.204812.json",
  "status": "approved",
  "verdict": "VERIFIED",
  "document_actions": {
    "existing_documents_archived": 2,
    "id_files_uploaded": 3,
    "liveness_files_uploaded": 1,
    "uploaded_total": 4,
    "approved": 4
  }
}

5. Failure path

_update_task_failure_in_task_execution_table marks the task failed and the ENTRUST tool ERROR, preferring the identity captured at the start of execute() so it still works when the persisted row is missing. Neither HubSpot nor EventLogger can cause a failure — both clients swallow their own errors.

External status syncs

Two downstream sinks receive the same lifecycle status. Both are best effort — a failure in either is logged and reported to Sentry but never fails the request or the task:

Moment Status value Sinks

POST /entrust/sessions succeeded

in_progress

HubSpot contact property + supplier event

Run approved

verified

both; the event also carries verdict = VERIFIED

Run review

in_review

both; no verdict yet

Any other terminal status

denied

both; verdict = NOT_VERIFIED

In the aftermath task one __sync_external_status call drives both. See HubSpot and Supplier event logger.

Source files

  • interface/web_api/endpoint_handling/create_entrust_session.py

  • interface/web_api/endpoint_handling/handle_entrust_webhook.py

  • interface/web_api/router/entrust_router.py

  • task/entrust/handle_identity_document_validation_response/

  • components/entrust/client.py, components/hubspot/client.py, components/event_logger/client.py

  • Tests: development/unit_tests/task_/entrust/**, development/unit_tests/components_/entrust/test_client.py, development/unit_tests/components_/test_hubspot_client.py, development/unit_tests/components_/test_supplier_event_logger.py, development/unit_tests/interface_/web_api/endpoint_handling/test_handle_entrust_webhook.py, …/test_create_entrust_session_hubspot.py, …/test_execute_task_entrust_reprocess.py