DoorID API Documentation

Production-safe reference for the DoorID address-verification integration API. This document is the same source rendered at /docs and returned by the Download docs (.md) button.

Quickstart

The smallest safe integration is:

  1. Keep an API key server-side and call POST /v1/verifications.
  2. Send the returned customer_link to the customer.
  3. Receive a signed verification.completed webhook, or poll GET /v1/verifications/:id until status is terminal.
  4. Fetch the evidence pack from GET /v1/verifications/:id/evidence. Request PDF with ?format=pdf; if the response is 202, wait for Retry-After.

The create response is intentionally a bare 201 response object. List, detail, mutation, and error responses use the envelopes described below. Do not put an API key in browser code or in the customer link.

bash
export DOORID_API_KEY='doorid_test_xxxxxxxxxxxx'
export DOORID_BASE_URL='https://www.doorid.ai'

curl -sS -X POST "$DOORID_BASE_URL/v1/verifications" \
  -H "Authorization: Bearer $DOORID_API_KEY" \
  -H "Idempotency-Key: 7c3f2a8b-9e4d-4a12-bc8f-d293e761f9a2" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Sarah",
    "last_name": "Johnson",
    "phone": "+447700900123",
    "reference": "APP-44218",
    "address": {
      "line_1": "14 Beach Road",
      "city": "Bournemouth",
      "postcode": "BH1 1AA",
      "country": "GB"
    },
    "callback_url": "https://your-app.example.com/webhooks/doorid"
  }'

The response includes verification_id, customer_link, slug, status, and expires_at. Replace verification_id in subsequent URLs with the returned value. The customer link is <base>/v/<slug>, contains no token query parameter, and is valid for 744 hours (31 days). slug is an opaque 27-character link code and is not the verification ID.

The same 744-hour window applies to every customer-facing link DoorID issues: verification links, licence pre-capture links, and Dealer Collection capture links. It is not the same clock as the 24-hour lender-supplied licence upload token or the 24-hour idempotency-key retention described below.

GET /v1 (discovery, requires an API key)

Returns a small snake_case endpoint map for programmatic discovery:

json
{
  "success": true,
  "data": {
    "name": "DoorID API",
    "version": "v1",
    "endpoints": {
      "verifications": "/v1/verifications",
      "address_assessment": "/v1/addresses/assess",
      "batch_assessment": "/v1/addresses/assess/batch",
      "webhook_deliveries": "/v1/webhooks/deliveries",
      "licence_upload": "/v1/licences/upload",
      "health": "/health"
    },
    "documentation": "https://docs.doorid.ai"
  }
}

This is a convenience map, not an exhaustive route list — several documented routes below (licence pre-capture, resend, cancel, erase) are not repeated here.

Base URL, authentication, and naming

The API base URL is the DoorID origin followed by /v1. All authenticated integration routes require either:

http
Authorization: Bearer doorid_test_xxxxxxxxxxxx

or:

http
X-API-Key: doorid_test_xxxxxxxxxxxx

Use test keys for integration work. Test keys use mock providers and never consume live provider credits; live keys run the configured providers. Keys are shown only once when generated and are stored as a prefix plus SHA-256 hash. Rotate a lost key.

Canonical request and response fields use snake_case. For the compatibility window, selected inbound camelCase aliases are accepted and warn-logged; responses remain snake_case. The date-of-birth field is date_of_birth in licence pre-capture requests and dob in the existing verification-create request. dateOfBirth is accepted as a legacy alias for the latter.

Health aliases (no API key)

/health and /v1/health share one handler; /healthz adds readiness (below):

  • GET /health
  • GET /healthz
  • GET /v1/health

They return:

json
{
  "status": "ok",
  "version": "1.0.0",
  "environment": "test",
  "testMode": true,
  "timestamp": "2026-09-07T10:00:00.000Z",
  "degraded": false,
  "missing_provider_keys": [],
  "missing_advisory_provider_keys": [],
  "providers": { "vision": "mock", "imagery": "mock" }
}

Provider degradation is still HTTP 200. HTTP 503 with "status":"error" is reserved for a database health failure.

GET /healthz is also the readiness probe. It returns the same body plus two additive fields:

json
{
  "ready": true,
  "checks": {
    "database":   { "state": "ok" },
    "migrations": { "state": "skipped", "reason": "schema is push-managed (not MIGRATIONS_AUTHORITATIVE)" },
    "storage":    { "state": "skipped", "reason": "no readiness probe for the replit backend" },
    "workers":    { "state": "skipped", "reason": "not the worker role (all)" }
  }
}

Each check is ok, failed or skipped; a skipped check does not apply to this deployment shape and never counts. /healthz answers 503 when any applicable check fails, so on a deployment where only the database check applies it answers exactly as /health does. reason is a fixed sentence or the probe's error message, never a value.

Create a verification

POST /v1/verifications

Creates a property verification. It also creates a Dealer Collection run when the request contains the dealership_* fields described below.

Required property-verification fields are first_name, last_name, phone, reference, and address.line_1, address.city. postcode and country are accepted; country defaults to GB. callback_url, customer_id, metadata, eligibility, force_manual_review, skip_id_check, mode, identity_mode, and licence_token are optional. When the check needs a driving licence and you do not pass one, DoorID reuses a current licence it already holds for that customer — see Automatic reuse of an on-file licence.

mode accepts property_only (default), full_journey, or licence_only. property_only and an omitted mode are identical: both leave identity mode at the client's configured default — property_only does not by itself skip identity capture on this route (pass identity_mode: "skip" explicitly for that). full_journey maps to identity_mode: "customer_capture" unless an explicit identity_mode is also given, which always wins. licence_only does not create a verification here; it returns 400 USE_LICENCE_PRECAPTURE_ENDPOINT directing the caller to POST /v1/licence-precapture/sessions. Any other mode value returns 400 INVALID_MODE.

identity_mode, when present, must be one of lender_supplied, customer_capture, or skip; any other non-null value returns 400 INVALID_IDENTITY_MODE. Omitting the field (or sending null) is not an error — it means "inherit the client's configured policy" (via mode above, or the account default).

address fields are line_1, optional line_2, city, optional region, optional postcode, and optional country. eligibility is advisory metadata; it does not itself approve an address.

Response: 201, bare JSON (not {success,data}):

json
{
  "verification_id": "did_3f2a4c91e8b7d0f2...",
  "customer_link": "https://www.doorid.ai/v/Ab3dEf4gHi5jK6mN7pQ8rS9tU0w",
  "slug": "Ab3dEf4gHi5jK6mN7pQ8rS9tU0w",
  "status": "pending",
  "expires_at": "2026-10-07T12:00:00.000Z"
}

The customer_link is the link to deliver. A phone can also cause an SMS to be sent when the client's SMS setting allows it; create success does not mean an SMS was delivered.

Dealer Collection on the same create route

This is the Handover check — the name the product carries on doorid.ai and in the portal. On the wire it keeps its original name: the request fields are dealership_*, the events are dealership_check.* and the read routes are under /v1/dealership-check/. None of those names change.

Include dealership_address, expected_registration_plate, and the other dealership_* values when collecting evidence at a dealership. The request still needs the normal customer identity, phone, reference, and address fields. The response is 201:

json
{
  "ok": true,
  "flow": "dealership_collection",
  "dealership_check_session_id": "dcs_...",
  "customer_profile_id": "cp_...",
  "customer_link": "https://www.doorid.ai/v/...",
  "capture_link": "https://www.doorid.ai/v/...",
  "status": "pending",
  "expires_at": "2026-10-07T12:00:00.000Z",
  "consent_logged": false,
  "sms_queued": false,
  "reused_existing": false
}

reused_existing is true when an active run for the same customer and plate already existed and was returned instead of a second link being sent (see Ending a Dealer Collection run).

dealership_address and expected_registration_plate are required for this flow. The events and evidence for Dealer Collection are a separate product contract and are not mixed into verification decision webhooks.

The full field list for this flow, after the shared first_name, last_name, phone, reference and address checks (400 MISSING_FIELD):

FieldRequiredNotes
dealership_addressyesPlain object: the address of the collection site. 400 MISSING_DEALERSHIP_ADDRESS when missing or empty.
expected_registration_plateyesThe plate the customer should be collecting. 400 MISSING_EXPECTED_PLATE when missing.
expected_make, expected_colournoShown in the report; never used to fail a run.
dealership_namenoShown to the customer and in the report.
prior_verification_referencenoThe reference of an earlier address verification with this client whose licence photo should be used for the face comparison, when it differs from reference.
licence_tokennoA token from POST /v1/licences/upload, stored as the customer's on-file licence for the face comparison. Same three failures as elsewhere: 400 licence_token_invalid, 409 licence_token_already_used, 410 licence_token_expired.
consentnoStored verbatim on the customer profile when supplied; never fabricated. The response reports consent_logged.
new_runnotrue starts another run for the same customer and plate; see Ending a Dealer Collection run.
callback_urlnoPer-run webhook URL, overriding the account's configured URL for this run's events. 400 INVALID_CALLBACK_URL when not an absolute http(s) URL.
dobnoThe customer's date of birth, stored on the profile.

The face comparison never asks the customer to photograph a licence at collection: it compares the collection selfie against a licence DoorID already holds for the customer (an on-file licence, or the one from an earlier verification with the same reference or prior_verification_reference). With no licence to compare against the face check reports unable, which makes the run referred rather than failing it.

Counting. A handover check counts as one check, whatever the product — there is no free product. It is counted and billed when the link is issued, exactly like an address check or a licence check, and it draws on the same allowance. A create refused because the free checks are used, or because a card is required, returns 402 TRIAL_LIMIT_REACHED or 402 CARD_REQUIRED with a plain message, and nothing is created.

The same run can also be created on POST /v1/dealership-check/sessions, documented under Dealer Collection results and evidence below. Both routes call the same creation code; an integration only ever needs one of them.

The Dealer Collection capture link uses the same 744-hour (31-day) lifetime as a verification link. dealership_check_session_id is the identifier used by the Dealer Collection result and evidence endpoints below; there is no verification record behind a Dealer Collection run.

Catch-hook (no-code intake)

POST /v1/public/catch-hook/:token is an unauthenticated, per-client alternative create route for no-code tools (Zapier, Make, a website form) that cannot hold an API key. The token in the URL is the credential and is generated/rotated from the portal or admin; it is revealed once. The route mounts ahead of the standard /v1 API-key + rate-limit layer and carries its own rate limit.

Field names are normalised loosely — both snake_case and camelCase, and both a nested address object and flat address fields (line1, street, address_line_1, etc.), are accepted. The request funnels through the same shared intake core as the API and the manual portal/admin form, so required fields and validation match POST /v1/verifications (property flow). The response is 201 { "success": true, "customer_link", "slug", "status", "expires_at" }.

Sending "flow": "dealer_collection" (or "dealership_collection") in the body instead creates a Dealer Collection run — this sub-mode requires first_name, last_name, phone, reference, dealership_address, and expected_registration_plate, and returns 403 DEALER_COLLECTION_NOT_ENTITLED unless the workspace has Handover switched on for no-code intake (an admin setting on the account, separate from the API, which needs no switch). A body that carries the dealership_* fields with no explicit flow is read as a Handover check too, behind its own admin setting, and returns 400 DEALER_COLLECTION_NOT_ENABLED when that is off. An unknown or rotated token returns 404 catch_hook_not_found without revealing whether a client exists.

Idempotency

Send Idempotency-Key on create and other supported mutations. The key is scoped to client + method + path and retained for 24 hours. Query strings and request bodies are not part of the fingerprint — reusing the same key on a genuinely different method or path is not detected as a conflict; it simply runs as an independent request scoped to its own fingerprint. A replay on the exact same client + method + path returns the stored response with X-Idempotency-Replayed: true and the original X-Request-ID. A concurrent first request in flight returns 409 IDEMPOTENT_REQUEST_IN_PROGRESS. 422 IDEMPOTENCY_KEY_REUSED is a defensive integrity check for a stored-tuple mismatch (for example a fingerprint collision), not a code path an ordinary integration should ever observe. Storage errors fail open.

List and retrieve verifications

GET /v1/verifications

Returns { "success": true, "data": { "verifications": [...], "total": 0, "limit": 20, "offset": 0 } }. Query parameters include limit (maximum 100), offset, status, failure_category, engagement, stage_sort, search, sort, and dir. search is a case-insensitive substring match on the verification's reference or declared address. List items are summaries; retrieve a detail record for the decision and webhook payload.

GET /v1/verifications/:id

Returns:

json
{
  "success": true,
  "data": {
    "id": "did_3f2a4c91e8b7d0f2...",
    "verification_id": "did_3f2a4c91e8b7d0f2...",
    "status": "completed",
    "reference": "APP-44218",
    "last_decision": null,
    "webhook_delivery": null,
    "captures": []
  }
}

data.verification_id is an additive alias for data.id. last_decision is the byte-equivalent decision payload when one was emitted, otherwise null. The detail response is the polling alternative to the decision webhook. Unlinked cross-tenant IDs are returned as 404 NOT_FOUND, indistinguishable from an unknown ID.

data.evidence_available_until is an additive field: an ISO 8601 timestamp, or null. It will report the date DoorID's evidence-retention window closes for that verification once the account's Retention Start Date (see Retention below) is configured. No account has a Retention Start Date set yet, so it is null on every response today — existing integrations can ignore it safely until retention configuration ships.

GET /v1/verifications/export

Bulk-downloads evidence packs for a date range as a single ZIP, plus a results.csv manifest, for scripted/offline record-keeping:

sh
curl -H "Authorization: Bearer doorid_test_dev_key_00000000" \
  "http://localhost:3001/v1/verifications/export?from=2026-01-01&to=2026-01-31" \
  -o export.zip

from and to are both required, ISO 8601 date/timestamp strings; a request missing either or with from after to returns 400 INVALID_DATE_RANGE. The response is application/zip containing:

  • evidence/<verification_id>.pdf — one per matching verification that has a rendered evidence pack (only completed/failed verifications ever do; reuses the same self-healing render path as GET /:id/evidence).
  • results.csv — one row per matching verification, including ones with no pack (still processing, cancelled, expired, declined), with columns verification_id,reference,client_id,status,decision,created_at,updated_at,evidence_included,exclusion_reason so the CSV always reconciles against what's bundled in the ZIP.

Matching is scoped to the caller's own (+ account-linked) verifications, exactly like the single-record evidence route; a scope with nothing readable returns 404 NOT_FOUND. The range is capped at 200 matching verifications per request — a larger range returns 400 TOO_MANY_RESULTS (with the actual match count) rather than silently truncating, so narrow the date range and retry. This endpoint has its own, tighter rate limit (2/minute, 10/hour) on top of the standard per-key limits, since one call can render up to 200 PDFs.

Mutations and webhook history

POST /v1/verifications/:id/cancel

Cancels only pending or in_progress verifications. Response 200 is { "success": true, "data": <cancelled verification> }. Cancelling a terminal record returns 409 INVALID_STATUS_TRANSITION. It emits the terminal verification.completed lifecycle event.

POST /v1/verifications/:id/resend-link

Returns 200:

json
{
  "success": true,
  "data": {
    "verification_link": "https://www.doorid.ai/v/...",
    "sms_sent": false,
    "had_phone": true
  }
}

The operation is limited to 2 requests per minute and 6 per hour per verification. It can be called with any API key for the owning tenant.

POST /v1/verifications/:id/resend-webhook

Re-sends the persisted verification.decision payload; it does not recompute the decision. Response 200:

json
{
  "success": true,
  "data": {
    "verification_id": "did_...",
    "event": "verification.decision",
    "delivery_id": "wd_...",
    "status": "delivered",
    "attempt_no": 1
  }
}

status is delivered, failed, or skipped. skipped means there is no callback URL or the client's decision sharing setting is off. If no decision payload has ever been emitted, the response is 404 NO_DECISION_TO_RESEND.

GET /v1/webhooks/deliveries

Returns { "success": true, "data": [...] }, up to the 50 most recent delivery rows for the authenticated tenant. An optional verification_id query parameter narrows the list.

Webhooks

DoorID sends JSON to the callback_url for the lifecycle events enabled by the client's settings. Every delivery includes:

http
Content-Type: application/json
X-DoorID-Signature: sha256=<legacy-hmac>
X-DoorID-Timestamp: 1800000000
X-DoorID-Signature-V2: t=1800000000,v1=<hmac>
X-DoorID-Schema-Version: 7
X-DoorID-Event: verification.completed
X-Request-ID: <delivery-id>

V2 signs the UTF-8 raw request body using HMAC-SHA256 over <timestamp>.<raw_body>. Reject malformed, mismatched, stale (older than five minutes), or replayed signatures. Dedupe on verification_id plus event; there is no event_id field and no X-DoorID-Event-Id header.

The lifecycle events are verification.opened, verification.consent_declined, verification.capture_completed, verification.completed, verification.decision, and verification.erased, each described below. One further event, webhook.test, is never sent by the flow: it is delivered only when someone triggers Send test webhook for the client in the DoorID portal, so an integrator can confirm their endpoint receives and verifies a signed delivery before any real verification runs. Its X-DoorID-Event header is webhook.test and its body is a fully-populated sample in the verification.completed shape, with verification_id "test_000000000000". Treat it as a connectivity check and do not reconcile it against a real verification.

The payload root uses event, verification_id, schema_version, status, reference, customer_informed, verification_type, and created_at. Decision-bearing payloads also use confidence_band, summary, and reasons. summary has the four branches identity, device_and_capture, address, and property.

When the customer did not allow their phone to share location, reasons includes location_permission_denied and risk_flags includes address_presence_inconclusive. Such a verification is returned as Refer, not Fail, so it can be told apart from a check that failed on its evidence.

Which result the first decision payload carries follows the account's decisioning mode. On manual review of every result (the default) it is always Refer until a review is recorded; when no check raised a concern, reasons is ["manual_review_all_results"]. On automated or automated with borderline review, it is the automated result: Pass only when the evidence score reaches the pass level and no check raised a concern, otherwise Refer or Fail with the concerns in reasons. A Refer or Fail from the evidence score alone, with no other concern, carries evidence_below_pass_threshold.

Every payload also carries the additive evidence_available_until field (same semantics as the detail-response field of the same name above): an ISO 8601 timestamp, or null. It is null on every payload today, since no account has a Retention Start Date configured yet. The verification.erased webhook does not carry this field — assets have already been purged by the time that event fires.

verification.capture_completed is sent as soon as the customer finishes their captures, for every address flow (house, rural, flat and apartment), before any checks have run. It carries no result. Use it to confirm to a customer on the phone that they have finished, and to show that the outcome is on its way:

json
{
  "schema_version": 7,
  "event": "verification.capture_completed",
  "verification_id": "ver_...",
  "customer_id": "cus_...",
  "reference": "APP-123",
  "status": "processing",
  "verification_type": "apartment",
  "capture_completed_at": "2026-09-14T12:03:00.000Z",
  "outcome_expected_by": "2026-09-14T12:13:00.000Z",
  "status_message": "The customer has completed the verification flow. The outcome and evidence pack will follow within 10 minutes."
}

It is sent at most once per verification. verification.completed and verification.decision follow as below.

verification.opened is sent the first time the customer opens their verification link, for every address flow. It is the address-verification counterpart of licence_capture.opened and dealership_check.opened, and like them it carries no result:

json
{
  "schema_version": 7,
  "event": "verification.opened",
  "verification_id": "ver_...",
  "customer_id": "cus_...",
  "reference": "APP-123",
  "status": "opened",
  "verification_type": "detached_house",
  "opened_at": "2026-09-15T12:00:00.000Z",
  "created_at": "2026-09-15T11:58:00.000Z"
}

It is sent at most once per verification; re-opening the link, or opening it on another device, does not send it again.

verification.consent_declined is sent when the customer declines on the consent screen (after confirming they want to). A decline is not final: the verification stays open, the link keeps working until expires_at, and reopening it shows the consent screen again. If the customer then agrees and finishes, verification.completed and verification.decision follow as normal; if they never return, the verification expires as usual. It carries no result:

json
{
  "schema_version": 7,
  "event": "verification.consent_declined",
  "verification_id": "ver_...",
  "customer_id": "cus_...",
  "reference": "APP-123",
  "status": "consent_declined",
  "verification_type": "detached_house",
  "declined_at": "2026-09-15T12:01:00.000Z",
  "expires_at": "2026-10-16T11:58:00.000Z",
  "status_message": "The customer declined consent. Their link stays valid until it expires, so they can still open it again and continue.",
  "created_at": "2026-09-15T11:58:00.000Z"
}

It is sent at most once per verification. GET /v1/verifications/:id reports the latest decision in consent.status, with consent.declined_at kept even if the customer later agrees.

verification.completed is sent for terminal lifecycle outcomes. verification.decision is sent only when the client's share_decision setting is on and the outcome has a decision. A terminal-only outcome such as cancelled or expired sends verification.completed and omits summary, confidence_band, reasons, and evidence links. Verifications declined before 2026-09-15 ended with the terminal consent_declined status; new declines no longer do.

When both events are sent for a verification, verification.decision is sent only after verification.completed has been delivered — or, if that delivery exhausts its retry schedule, after DoorID has stopped retrying it. A verification.decision therefore never arrives ahead of the verification.completed for the same verification. A resend from POST /v1/verifications/:id/resend-webhook is sent when requested and is not held behind anything.

Node receiver

Use a raw-body parser for this route; parsing and re-serialising JSON before verification invalidates the signature. Verification has three checks in order — signature validity, then timestamp freshness, then (optionally) replay — mirroring the reference verifier DoorID uses internally (verifyWebhookSignature in src/lib/webhookSignature.ts). The signature is checked before the freshness check so a forged payload can never advance a replay store.

js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();

const TOLERANCE_SEC = 300; // 5 minutes, matches DoorID's own default

app.post("/webhooks/doorid", express.raw({ type: "application/json" }),
  async (req, res) => {
    const timestampHeader = req.header("X-DoorID-Timestamp") ?? "";
    const sigHeader = req.header("X-DoorID-Signature-V2") ?? "";
    const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(sigHeader);
    if (!match || timestampHeader !== match[1]) {
      return res.status(401).json({ error: "malformed signature" });
    }
    const [, t, v1] = match;
    const expected = createHmac("sha256", process.env.DOORID_WEBHOOK_SECRET)
      .update(`${t}.${req.body.toString("utf8")}`).digest("hex");
    const sigOk = expected.length === v1.length &&
      timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
    if (!sigOk) return res.status(401).json({ error: "bad signature" });

    // Freshness check — reject a signature whose embedded timestamp is more
    // than TOLERANCE_SEC away from this server's clock, in either direction.
    const nowSec = Math.floor(Date.now() / 1000);
    if (Math.abs(nowSec - Number(t)) > TOLERANCE_SEC) {
      return res.status(401).json({ error: "stale signature" });
    }

    const payload = JSON.parse(req.body.toString("utf8"));
    // Dedupe on payload.verification_id + payload.event in durable storage.
    console.log(payload.event, payload.verification_id);
    return res.json({ received: true });
  });

Python receiver

python
import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
TOLERANCE_SEC = 300  # 5 minutes, matches DoorID's own default
SECRET = os.environ["DOORID_WEBHOOK_SECRET"].encode("utf-8")

@app.post("/webhooks/doorid")
def doorid_webhook():
    raw_body = request.get_data()  # raw bytes — do not re-serialise
    timestamp_header = request.headers.get("X-DoorID-Timestamp", "")
    sig_header = request.headers.get("X-DoorID-Signature-V2", "")

    match = re.match(r"^t=(\d+),v1=([0-9a-f]+)$", sig_header)
    if not match or timestamp_header != match.group(1):
        return jsonify(error="malformed signature"), 401

    t, v1 = match.group(1), match.group(2)
    signed_base = f"{t}.{raw_body.decode('utf-8')}".encode("utf-8")
    expected = hmac.new(SECRET, signed_base, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        return jsonify(error="bad signature"), 401

    # Freshness check — reject a signature more than TOLERANCE_SEC away from
    # this server's clock, in either direction.
    if abs(time.time() - int(t)) > TOLERANCE_SEC:
        return jsonify(error="stale signature"), 401

    payload = json.loads(raw_body)
    # Dedupe on payload["verification_id"] + payload["event"] in durable storage.
    print(payload["event"], payload["verification_id"])
    return jsonify(received=True)

Retry schedule

DoorID makes 8 attempts: immediately, then after 5 seconds, 30 seconds, 2 minutes, 5 minutes, 10 minutes, 30 minutes, and 2 hours (about 2 hours 48 minutes total). After the final failure the delivery is marked webhook_undelivered and remains visible in GET /v1/webhooks/deliveries. Return any 2xx response after durable receipt; do not perform long work before acknowledging.

Evidence

GET /v1/verifications/:id/evidence

The default response is an unwrapped JSON evidence pack. Request a PDF with ?format=pdf or Accept: application/pdf.

bash
curl "$DOORID_BASE_URL/v1/verifications/$VERIFICATION_ID/evidence" \
  -H "Authorization: Bearer $DOORID_API_KEY"
curl "$DOORID_BASE_URL/v1/verifications/$VERIFICATION_ID/evidence?format=pdf" \
  -H "Authorization: Bearer $DOORID_API_KEY" --output evidence.pdf

PDF responses are 200 application/pdf with integrity headers including X-DoorID-PDF-SHA256. While a PDF is being generated the response is 202:

json
{
  "status": "generating",
  "code": "EVIDENCE_PACK_GENERATING",
  "retry_after": 10,
  "message": "The evidence pack PDF is being generated. Retry after the indicated delay."
}

Read the Retry-After header and retry the same request. JSON is synchronously self-healed when a cached pack is missing.

GET /v1/verifications/:id/evidence/:asset_id

Streams one stored evidence asset. It returns 404 ASSET_NOT_FOUND when the asset or bytes are absent, and 410 asset_purged with details.purged_at after retention removes it.

GET /v1/verifications/:id/evidence/image/:role

The labelled identity-image roles are front_licence, back_licence, and selfie. This route requires the owning account's api_raw_media_access entitlement (admins bypass it). It returns a normalised JPEG attachment, 404 IMAGE_UNAVAILABLE for a missing role, and 410 asset_purged after retention.

There are no public pack.pdf, pack.json, or signed evidence-URL routes. Evidence access is authenticated, tenant-scoped, and audited.

503 PACK_RENDER_QUARANTINED

A verification evidence PDF request can return 503 with PACK_RENDER_QUARANTINED. This is a safety circuit breaker, not a transient outage: after repeated render failures the pack's render job is left dead on purpose so it cannot keep stalling the service.

  • It is not client-retryable. There is no Retry-After header, and a retry loop will not clear it.
  • It is not permanent. An operator replays the render job and the pack becomes available again.
  • The client action is therefore contact support — neither automatic retry nor treating the pack as permanently lost.

Do not confuse it with the 202 generating response above: 202 is pollable and carries Retry-After; the quarantine response carries neither. The quarantine body is the resolver's own shape, {"error": "...", "code": "PACK_RENDER_QUARANTINED"}, rather than the standard error.code envelope, so read code at the body root for this response.

A Dealer Collection evidence PDF (below) is rendered synchronously and has no quarantine state. Its own 503s are PDF_RENDER_TIMEOUT and PDF_RENDER_BUSY, which are transient and safe to retry.

Dealer Collection results and evidence

A Dealer Collection run is the Handover check (the product's name on doorid.ai and in the portal). These endpoints create and read one. They use the same API-key authentication as the rest of this reference and are tenant-scoped: an unknown session id and a session belonging to another tenant both return 404 SESSION_NOT_FOUND, indistinguishably. Every route takes the dealership_check_session_id returned at creation.

A Dealer Collection run is not a property verification. It carries no composite score, no confidence band, and no decision-engine result, and it never appears in the verification decision webhook. It also does not appear on GET /v1/verifications or GET /v1/verifications/:id, and it does not carry the status_plain / result_plain fields those routes have: its own vocabulary is run_status and verdict, below.

Run status

run_status (the status field on the list and detail routes below) is the run's lifecycle, derived from what has happened, in this order of precedence:

ValueMeaning
completedAll three captures are in. Checked first, so a finished run never reads as expired.
cancelledYou cancelled the run before it finished.
expiredThe link reached its 31-day window, or a newer run for the same customer and plate replaced it.
consent_declinedThe customer declined on the consent screen and has not agreed since. Not final: the link still works.
openedThe customer has opened the link but not finished.
pendingIssued, not yet opened.

POST /v1/dealership-check/sessions

The original creation route, kept for integrations that already use it. It creates exactly the same run as POST /v1/verifications with the dealership_* fields, through the same code, with the same counting rule; only the validation vocabulary and the response keys differ, as listed here.

Required: reference (400 MISSING_REFERENCE); full_name, or both first_name and last_name (400 MISSING_NAME); address as a plain object (400 MISSING_ADDRESS); consent as a plain object, stored verbatim (400 MISSING_CONSENT). Optional: phone (E.164 or a UK number; 400 INVALID_PHONE when unparseable; with no phone, no text is sent and no reminders are scheduled), date_of_birth, expected_registration_plate, expected_colour, expected_make, dealership_address (plain object; 400 INVALID_DEALERSHIP_ADDRESS otherwise), dealership_name, prior_verification_reference, callback_url (400 INVALID_CALLBACK_URL), new_run, licence_token (same three failures as above). Unlike the unified route, expected_registration_plate and dealership_address are not required here; a run created without them reports the plate and location checks as unable.

Response 201, bare JSON:

json
{
  "ok": true,
  "customer_profile_id": "cp_...",
  "dealership_check_session_id": "dcs_...",
  "capture_link": "https://www.doorid.ai/dc/...",
  "expires_at": "2026-10-17T08:40:00.000Z",
  "consent_logged": true,
  "sms_queued": true,
  "reused_existing": false,
  "prior_verification_linked": true
}

sms_queued says a first text was dispatched under the account's Handover text setting; it does not prove delivery. reused_existing is true when an active run for the same customer and plate already existed and was returned instead of a second link being sent. prior_verification_linked is a boolean only — the matched verification's id never leaves the server. The same 402 TRIAL_LIMIT_REACHED / 402 CARD_REQUIRED refusals apply. A key with no client scope returns 403 DEALERSHIP_CHECK_FORBIDDEN.

GET /v1/dealership-check/sessions

The caller's most recent runs, newest first, up to 50. Response 200:

json
{
  "ok": true,
  "items": [
    {
      "session_id": "dcs_...",
      "customer_profile_id": "cp_...",
      "full_name": "Priya Nair",
      "reference": "HO-2041",
      "client_id": "cli_...",
      "created_at": "2026-09-16T08:40:00.000Z",
      "expires_at": "2026-10-17T08:40:00.000Z",
      "opened_at": "2026-09-16T10:51:00.000Z",
      "consent_agreed_at": "2026-09-16T10:51:30.000Z",
      "status": "completed",
      "phone_e164": "+447700900123",
      "expected_registration_plate": "AB12 CDE",
      "expected_colour": "Blue",
      "expected_make": "Ford",
      "dealership_address": { "line_1": "14 Forecourt Way", "city": "Ipswich", "postcode": "IP1 2AB" },
      "used_at": "2026-09-16T10:51:00.000Z",
      "captures_completed_at": "2026-09-16T11:04:00.000Z",
      "has_building_image": true,
      "has_car_front_image": true,
      "has_selfie_image": true
    }
  ]
}

status is the run status above. There is no paging, filtering or search on this route; for a fuller record poll the detail, outcome or evidence routes by id.

GET /v1/dealership-check/sessions/:id

One run's stored record. Response 200:

json
{
  "ok": true,
  "session": {
    "session_id": "dcs_...",
    "client_id": "cli_...",
    "customer_profile_id": "cp_...",
    "full_name": "Priya Nair",
    "reference": "HO-2041",
    "phone_e164": "+447700900123",
    "status": "completed",
    "session_status": "used",
    "created_at": "2026-09-16T08:40:00.000Z",
    "opened_at": "2026-09-16T10:51:00.000Z",
    "consent_agreed_at": "2026-09-16T10:51:30.000Z",
    "consent_version": "2026-09-01",
    "consent_ip": "203.0.113.8",
    "consent_user_agent": "Mozilla/5.0 ...",
    "vehicle_ack_at": "2026-09-16T10:51:30.000Z",
    "vehicle_ack_version": "2026-09-01",
    "used_at": "2026-09-16T10:51:00.000Z",
    "captures_completed_at": "2026-09-16T11:04:00.000Z",
    "expires_at": "2026-10-17T08:40:00.000Z",
    "expected_registration_plate": "AB12 CDE",
    "expected_colour": "Blue",
    "expected_make": "Ford",
    "dealership_address": { "line_1": "14 Forecourt Way", "city": "Ipswich", "postcode": "IP1 2AB" },
    "dealership_name": "Demo Motors",
    "captures": [
      { "role": "building", "label": "Dealership building", "present": true, "mime_type": "video/mp4", "media_kind": "video", "url": "/v1/dealership-check/sessions/dcs_.../captures/building" },
      { "role": "car_front", "label": "Car front", "present": true, "mime_type": "video/mp4", "media_kind": "video", "url": "/v1/dealership-check/sessions/dcs_.../captures/car_front" },
      { "role": "selfie", "label": "Selfie", "present": true, "mime_type": "video/mp4", "media_kind": "video", "url": "/v1/dealership-check/sessions/dcs_.../captures/selfie" }
    ],
    "plate_match": { "status": "read", "raw": "AB12 CDE", "read_plate": "AB12CDE", "expected_plate": "AB12CDE", "match": "match" },
    "plate_ocr_enabled": true,
    "plate_frame_url": "/v1/dealership-check/sessions/dcs_.../plate-frame"
  }
}

status is the run status; session_status is the stored lifecycle (active, used, expired or cancelled) and is informational. plate_match is null until a read has been attempted, then carries a status of disabled, failed, unreadable or read, the raw text read, the normalised read_plate and expected_plate, and the comparison match. It is reported evidence for the run's own outcome, never a verdict on its own. The captures[].url and plate_frame_url values, and the /plate-ocr, /location and /face-match routes under the same prefix, are DoorID's own review surfaces: they are reachable with your key and tenant-scoped, but they are not part of the supported integration contract and may change. Use the outcome and evidence routes below for results.

POST /v1/dealership-check/sessions/:id/send-sms

Sends (or re-sends) the capture link by text, ignoring the account's automatic Handover text setting: this is the manual send. Body: optionally phone, which replaces the number on the run (400 INVALID_PHONE when unparseable). With no phone on the run and none supplied, 400 PHONE_REQUIRED. Response 200:

json
{ "ok": true, "sms_queued": true, "noop": false }

noop: true means nothing was sent because texting is switched off for the whole service; an error string is present when the provider refused the message. As everywhere in this reference, sms_queued does not prove delivery.

Dealer Collection webhooks

When a callback URL is configured, a Dealer Collection run sends dealership_check.opened when the customer first opens the link, then two events once they finish. If the customer declines on the consent screen it sends dealership_check.consent_declined (same payload as opened, with status: "consent_declined", declined_at, expires_at and a status_message). As on every stage, a decline is not final: the link keeps working and reopening it shows the consent screen again, so the finish events can still follow. It is sent at most once per run. Each is signed with the same X-DoorID-Signature, X-DoorID-Timestamp and X-DoorID-Signature-V2 headers as the verification webhooks, with X-DoorID-Schema-Version: 1 and X-DoorID-Event naming the event.

dealership_check.completed is sent as soon as the customer completes the collection flow, before any checks have run. Use it to confirm to a customer on the phone that they have finished, and to show that the outcome is on its way:

json
{
  "schema_version": 1,
  "event": "dealership_check.completed",
  "session_id": "dcs_...",
  "status": "completed",
  "outcome_status": "processing",
  "outcome_expected_by": "2026-09-14T12:13:00.000Z",
  "status_message": "The customer has completed the collection flow. The outcome and evidence pack will follow within 10 minutes.",
  "opened_at": "2026-09-12T14:26:00.000Z",
  "completed_at": "2026-09-14T12:03:00.000Z",
  "created_at": "2026-09-11T14:25:00.000Z"
}

It also carries client_id, client_reference, customer_name, phone_e164, expected_car and dealership_address. It never carries a verdict or check results.

dealership_check.outcome follows with the verdict, the three checks and evidence_pack_url, a signed link to the evidence pack. It has the same fields as the outcome endpoint below, with outcome_status: "final". DoorID only sends it once the registration read and the face comparison have finished processing, normally well within 10 minutes of completed_at. A check is never reported as unable because processing was slow: unable means the check genuinely could not be measured (for example no licence photo on file, or no registration readable in the capture). If processing has still not finished 30 minutes after completed_at, no outcome event is sent for the run: DoorID sends dealership_check.processing_failed instead and reviews the run manually. That run then receives at most one dealership_check.outcome: the DoorID reviewer's decision (status_line "Decided by a DoorID reviewer.", with evidence_pack_url as usual), or an automatic outcome if processing is later completed, whichever comes first. The outcome is sent once and never changed.

dealership_check.processing_failed is sent, at most once per run and instead of dealership_check.outcome, when DoorID could not finish processing the evidence. It carries no verdict and no checks. reason_code is processing_timeout (still processing 30 minutes after completed_at) or processing_error (the evaluation kept failing). The same notice is emailed to the client's evidence-pack recipients:

json
{
  "schema_version": 1,
  "event": "dealership_check.processing_failed",
  "session_id": "dcs_...",
  "client_id": "...",
  "client_reference": "YOUR-REF-123",
  "status": "completed",
  "reason_code": "processing_timeout",
  "status_message": "The customer completed the collection flow, but DoorID could not finish processing the evidence. No automatic outcome will be sent for this collection. DoorID is reviewing it and will be in touch.",
  "completed_at": "2026-09-14T12:03:00.000Z",
  "failed_at": "2026-09-14T12:33:00.000Z",
  "created_at": "2026-09-11T14:25:00.000Z"
}

GET /v1/dealership-check/sessions/:id/outcome

The run's own verdict and the three sub-checks behind it. Response 200:

json
{
  "session_id": "dcs_...",
  "run_status": "completed",
  "outcome_status": "final",
  "outcome_expected_by": "2026-09-08T10:10:00.000Z",
  "verdict": "approved",
  "outcome": "pass",
  "manual_review_required": false,
  "status_line": "No manual review required.",
  "status_detail": "All three checks passed: the registration, the customer's face and the collection location.",
  "checks": {
    "plate_match": { "key": "plate_match", "label": "Registration plate", "result": "pass", "headline": "...", "detail": "..." },
    "face_match": { "key": "face_match", "label": "Face match", "result": "pass", "headline": "...", "detail": "...", "similarity_pct": 96.2 },
    "gps_location": { "key": "gps_location", "label": "Location", "result": "pass", "headline": "...", "detail": "...", "distance_m": 18, "tolerance_radius_m": 150 }
  },
  "evaluated_at": "2026-09-08T10:00:00.000Z"
}

verdict is approved, failed, or referred — the client-facing word. outcome is the internal pass / refer / warning equivalent, retained for integrations that already read it; both come from one evaluation. run_status is the run's lifecycle state: pending, opened, completed, or expired.

Each sub-check's result is pass, fail, warning, or unable. unable means the check could not be measured — for example no licence photo was on file for the face comparison. warning means the check was measured but is not a contradiction on its own — today only gps_location, when the captures were recorded outside the accepted area around the dealership. Both pull the run to warning / referred for manual review rather than failing it; only a plate or face mismatch returns fail / failed. The outcome is evaluated on demand, so a run with no usable input returns 200 with unable checks, never an error.

While a completed run's outcome is still being processed, the response is 200 with outcome_status: "processing" and no verdict. This lasts until the registration read and the face comparison have finished — normally within 10 minutes, and it can pass outcome_expected_by. A run whose processing could not be completed (see the outcome event above) keeps reporting processing while DoorID reviews it manually:

json
{
  "session_id": "dcs_...",
  "run_status": "completed",
  "outcome_status": "processing",
  "outcome_expected_by": "2026-09-14T12:13:00.000Z",
  "verdict": null,
  "outcome": null,
  "manual_review_required": null,
  "status_line": "Outcome processing.",
  "status_detail": "The customer has completed the collection flow. The outcome and evidence pack will follow within 10 minutes.",
  "checks": null,
  "evaluated_at": null
}

Check outcome_status before reading verdict or checks. A face comparison that could not be carried out because the matching service failed is reported as unable, never as a low similarity score. outcome_expected_by is null for a run the customer has not completed.

GET /v1/dealership-check/sessions/:id/evidence/pdf

Returns the run's evidence pack as 200 application/pdf bytes, with a Content-Disposition attachment filename and X-DoorID-Pack-Cached showing whether the stored pack was reused. The pack is rendered on demand when it is not already cached. A render that exceeds its bounded budget or arrives while too many renders are in flight returns 503 PDF_RENDER_TIMEOUT or 503 PDF_RENDER_BUSY; both are transient and safe to retry. An unrecoverable render error is 500 PACK_RENDER_FAILED.

GET /v1/dealership-check/sessions/:id/evidence

The same evidence as JSON — one assembled evidence model projected two ways, so the PDF and the JSON can never describe the run differently. Response 200 is an unwrapped object with session_id, client_id, run_status, generated_at, truth, result (the same block as the outcome endpoint), dealership, vehicle, session_details, capture_window, location, face, and captures.

?format=pdf on this route is a convenience alias for the PDF route above and returns the same PDF bytes.

POST /v1/dealership-check/sessions/:id/evidence/regenerate

Forces a fresh render of the pack and returns the new 200 application/pdf bytes, discarding any cached copy (the response carries X-DoorID-Pack-Cached: 0). Use it when a run's underlying evidence has changed since the pack was first cached and you want the rebuilt PDF rather than the stored one; GET .../evidence/pdf self-heals a cache miss but otherwise serves the cached object, so this is the explicit rebuild. Same tenant scope and gate as the read routes (an unknown or cross-tenant session id returns 404), and the same transient 503 PDF_RENDER_TIMEOUT / 503 PDF_RENDER_BUSY and 500 PACK_RENDER_FAILED render outcomes.

Per-check reads

The three sub-checks behind a run's outcome — face match, location, and the number-plate frame — can each be read on their own, without pulling the whole evidence pack. They return the same evidence the pack carries, an à-la-carte version of it, and are read-only and tenant-scoped (an unknown or cross-tenant session id returns 404). Use them to build your own review view of a run; use GET .../outcome for the summary verdict and the PDF for the full record.

GET /v1/dealership-check/sessions/:id/face-match

The selfie-vs-licence comparison. Response 200 is { "ok": true, "face": {…} }. When a comparison exists, face.available is true and the block carries similarity (0–100), match (true/false/null), result (pass/refer/fail/unavailable), reasoning, confidence (high/medium/low/null), selfie_face_detected, licence_face_detected, licence_source (on_file_licence / linked_verification / test_licence_catalog), licence_provenance (a one-line plain-English origin, identical to the pack's wording), compared_at, provider, and selfie_url / licence_url (paths to the two images, same tenant scope). When no comparison could be made, face.available is false with a human-readable reason.

GET /v1/dealership-check/sessions/:id/location

The location evidence: { "ok": true, "location": {…} }. location.resolved says whether the dealership address anchored; when true the object carries the anchor ({lat,lng}) and address_line, when false a reason (NO_DEALERSHIP_ADDRESS / ADDRESS_NOT_GEOCODED / GEOCODE_FAILED) and message. trace is the projected GPS walk collected during capture (null for a run with no trace or where the customer denied location; treat as opaque unless you are drawing the map). tolerance gives radius_m, capture_cluster, distance_m, and out_of_tolerance (true = captures fell outside the ring, null = cannot be tested — never read null as inside). This is display evidence only: it is not scored, decisioned, webhooked or stored.

GET /v1/dealership-check/sessions/:id/plate-frame

The still the number-plate was read from — the sharpest frame of the vehicle capture — returned as image bytes (image/jpeg or image/png), not JSON. 404 CAPTURE_NOT_FOUND when the run has no vehicle capture; 404 PLATE_FRAME_UNAVAILABLE when no frame could be extracted.

Where the imagery lives

A run's imagery is in the evidence PDF, and the same images are also reachable individually: the face comparison's selfie_url / licence_url (above), the plate frame (above), and each labelled capture at GET /v1/dealership-check/sessions/:id/captures/:role (role = building / car_front / selfie), served as image bytes. The JSON evidence projection still only names each image rather than embedding bytes: every entry in captures reports kind, label, available, and, when unavailable, an unavailable_reason, and the face block reports availability and similarity_pct the same way. Fetch the PDF or the per-image routes above to see the pictures.

Ending a Dealer Collection run

A run ends in one of four ways:

  • Used — the customer completes the captures and the session is consumed.
  • Expired — the capture link reaches the 744-hour (31-day) window and the session becomes expired.
  • Superseded — an explicit new_run: true create for the same customer and expected registration plate replaces the active run. An ordinary repeat create without that flag reuses the existing active session instead of starting a second one. The replaced run's link stops working at once, and the run reports run_status: "expired".
  • Cancelled — you cancel it with the endpoint below.

The verification cancel route does not apply to a Dealer Collection run: a Dealer Collection create writes no verification record.

POST /v1/dealership-check/sessions/:id/cancel

Cancels a run the customer has not completed, so its capture link stops working immediately instead of remaining valid for the rest of its 31 days. The rules match POST /v1/verifications/:id/cancel. Response 200:

json
{ "ok": true, "session_id": "dcs_...", "status": "cancelled" }

Cancelling a run that is completed, expired or already cancelled returns 409 INVALID_STATUS_TRANSITION. An unknown run and a run belonging to another tenant both return 404 SESSION_NOT_FOUND. The route honours Idempotency-Key. A cancelled run reports run_status: "cancelled" on the outcome and evidence routes, and a customer who opens its link is told the link is no longer valid. No webhook is sent for a cancellation; the 200 response is the confirmation.

Licence pre-capture

POST /v1/licences/upload

Uploads a lender-supplied licence using either url or base64 data, plus optional content_type and customer_id. The real image is validated and a single-use token is returned:

json
{
  "licence_token": "lct_<64 hex chars>",
  "expires_at": "2026-09-08T10:00:00.000Z",
  "single_use": true,
  "image_summary": { "format": "jpg", "bytes": 412980, "sha256": "<64 hex chars>", "received_at": "..." },
  "ttl_ms": 86400000,
  "max_bytes": 10485760
}

Pass licence_token to create. It expires after 24 hours and is consumed once. A 1x1 or otherwise degenerate placeholder returns 400 LICENCE_IMAGE_PLACEHOLDER.

Automatic reuse of an on-file licence

When your account's identity mode expects a lender-supplied licence, you do not have to supply one for a customer whose licence DoorID already holds. The create call resolves the licence in this order and uses the first hit:

  1. Supplied on the request — a licence_token (or an accepted lender upload) always wins, exactly as before.
  2. Captured by DoorID — a current licence front the customer captured through DoorID's own licence pre-capture flow.
  3. Held for you — a current licence front already on file for the same customer under your account.

The customer is matched on the exact reference you send (or an explicit customer_profile_id), scoped to your account. Names, addresses and phone numbers are never used to match, and a licence held for another account is never reachable.

Only a licence with a retrievable front image inside your account's licence freshness window (90 days by default) is reused. A reused licence behaves exactly like one you supplied: the same face match, evidence pack and review surfaces, with its true origin recorded.

If nothing usable resolves, the create is still rejected with 400 licence_token_required, and error.details.licence_reuse_reason says why:

ReasonMeaning
no_customer_matchNo customer with that reference on your account (also returned for a customer held by another account).
nothing_on_fileThe customer exists, but no licence is on file.
outside_freshness_windowA licence exists but was captured outside the freshness window.
image_unavailableA licence record exists but its image could not be retrieved.

POST /v1/licence-precapture/sessions

Creates a standalone licence-capture session. Live and test API keys are accepted. Required fields are reference, either full_name or first_name + last_name, address, and consent. Optional fields are phone and date_of_birth. The response is 201:

json
{
  "ok": true,
  "customer_profile_id": "cp_...",
  "precapture_session_id": "ps_...",
  "capture_link": "https://www.doorid.ai/lpc/plc_...",
  "expires_at": "...",
  "consent_logged": true,
  "sms_queued": false
}

sms_queued means an SMS was accepted for dispatch when the client SMS setting allowed automatic delivery; it is false when no phone was provided or the setting is off, and does not prove delivery to the handset. Without a phone, no automatic SMS or reminders are scheduled. The capture link itself is returned so the integrator can send it by email or its own messaging channel.

The pre-capture link is valid for 744 hours (31 days), the same window as a verification link and a Dealer Collection capture link. This is not the lender-supplied licence upload token above, which is a different 24-hour clock.

GET /v1/licence-precapture/sessions

Returns { "ok": true, "items": [...] } for the 50 most recent sessions. There is no reference query filter and no licence_token in this response. Items include session_id, customer_profile_id, reference, lifecycle timestamps, status, and evidence URLs when the session's own capture is available.

status is one of pending (not yet opened), opened, consent_declined, captured, cancelled, or expired. expired covers both a link past its 31-day window and a link replaced by a newer session for the same reference. consent_declined means the customer declined and has not agreed since; the link still works, so it can move on to opened and captured. Dealer Collection run_status uses the same consent_declined value.

POST /v1/licence-precapture/sessions/:id/send-sms

Manually sends the session capture link. Supply an optional phone to replace the stored phone; otherwise the stored phone is used. Response 200 is { "ok": true, "sms_queued": true }. If neither exists, the response is 400 PHONE_REQUIRED; an invalid number is 400 INVALID_PHONE. This route's manual send is not silently disabled by the automatic SMS setting.

POST /v1/licence-precapture/sessions/:id/cancel

Cancels a licence pre-capture session the customer has not used, so its capture link stops working immediately instead of remaining valid for the rest of its 31 days. The rules match POST /v1/verifications/:id/cancel. Response 200:

json
{ "ok": true, "session_id": "ps_...", "status": "cancelled" }

Cancelling a session whose licence is already captured, or which is expired or already cancelled, returns 409 INVALID_STATUS_TRANSITION. An unknown session and a session belonging to another tenant both return 404 SESSION_NOT_FOUND. The route honours Idempotency-Key. A cancelled session is listed with status: "cancelled", any scheduled reminder texts stop, and a customer who opens its link is told the link is no longer valid. No webhook is sent for a cancellation; the 200 response is the confirmation.

Creating a new session for the same reference already replaces the previous link: the older session's link stops working at once and it is listed as expired. Cancel is for ending a link without issuing another.

Pre-capture status and evidence

  • GET /v1/licence-precapture/profiles/:profileId/status returns 200 {ok, profile_id, reference, full_name, status, on_file_licence, evidence_available}.
  • GET /v1/licence-precapture/profiles/:profileId/evidence/pdf
  • GET /v1/licence-precapture/profiles/:profileId/evidence/front
  • GET /v1/licence-precapture/profiles/:profileId/evidence/back
  • GET /v1/licence-precapture/profiles/:profileId/evidence/selfie
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/pdf
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/front
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/back
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/selfie

The evidence endpoints return PDF or JPEG bytes with attachment and integrity headers. They return 404 SESSION_NOT_FOUND for an unknown or foreign session, 404 NO_ON_FILE_LICENCE before capture, and 404 LICENCE_IMAGE_UNAVAILABLE when an optional side is absent. Session-keyed URLs always resolve the licence captured by that session; profile-keyed URLs resolve the current licence.

Licence-capture webhooks

When a session has callback_url set, DoorID sends these additive events using the same per-client HMAC signing and 8-attempt retry ladder as the verification webhooks above (X-DoorID-Signature, X-DoorID-Timestamp, X-DoorID-Signature-V2):

  • licence_capture.opened — fired once, fire-and-forget, when the customer first opens the capture link. Payload: schema_version, event, session_id, client_id, client_reference, customer_name, phone_e164, status: "opened", opened_at, created_at.
  • licence_capture.completed — fired once both licence images are uploaded and the profile's on-file licence is set. Payload adds status: "captured", completed_at, and a signed evidence_url for pulling the capture evidence, in place of opened_at's role. It also carries evidence_expected_by (ten minutes after completed_at) and status_message: "The customer has completed the licence capture. The evidence pack will be ready within 10 minutes." No verdict or outcome event follows a licence capture.
  • licence_capture.consent_declined — fired once, when the customer declines on the consent screen. Same payload as licence_capture.opened, with status: "consent_declined", declined_at, expires_at and a status_message. Not final: the link keeps working and reopening it shows the consent screen again, so licence_capture.completed can still follow.

No callback URL configured means these events are silently skipped, matching the verification-webhook behavior described above.

Address assessment

GET /v1/addresses/assess

Required query parameters are line_1, city, postcode, and country. Optional parameters are line_2 and region. Response: { "success": true, "data": { ...assessment } }.

POST /v1/addresses/assess/batch

Body: { "addresses": [ ... ] }. Up to 50 addresses are accepted. Response: { "success": true, "data": { "results": [...], "processed_count": 0, "failed_count": 0 } }. An empty list is 400 INVALID_INPUT; more than 50 is 400 LIMIT_EXCEEDED.

Assessment is advisory. A client-policy limitation is surfaced as recommended_method: "not_supported" and create can still reject with 422 ADDRESS_TYPE_NOT_SUPPORTED.

Errors, rate limits, and retention

Errors use:

json
{
  "error": {
    "code": "MISSING_FIELD",
    "message": "reference is required",
    "request_id": "req_..."
  },
  "success": false
}

success:false is a compatibility field controlled by the server's legacy envelope setting; clients should read error.code, error.message, and error.request_id.

Documented runtime codes are:

HTTPCodes
400MISSING_FIELD, invalid_phone, INVALID_PHONE, licence_token_required, licence_token_invalid, inline_licence_photo_removed, test_licence_invalid, MISSING_DEALERSHIP_ADDRESS, INVALID_DEALERSHIP_ADDRESS, MISSING_EXPECTED_PLATE, MISSING_CLIENT_ID, DEALER_COLLECTION_NOT_ENABLED, INVALID_DEALER_COLLECTION_REQUEST, INVALID_JOURNEY_TYPE, INVALID_VERIFICATION_ID, INVALID_ASSET_ID, INVALID_IMAGE_ROLE, IDEMPOTENCY_KEY_TOO_LONG, unsupported_schema_version, LICENCE_IMAGE_PLACEHOLDER, PHONE_REQUIRED, INVALID_CALLBACK_URL, INVALID_MODE, USE_LICENCE_PRECAPTURE_ENDPOINT, INVALID_IDENTITY_MODE, INVALID_ELIGIBILITY, INVALID_INPUT, LIMIT_EXCEEDED, MISSING_REFERENCE, MISSING_NAME, MISSING_ADDRESS, MISSING_CONSENT
401MISSING_API_KEY, INVALID_API_KEY_FORMAT, INVALID_API_KEY
402TRIAL_LIMIT_REACHED, CARD_REQUIRED
403CLIENT_ARCHIVED, EVIDENCE_ACCESS_DENIED, ADMIN_AUTH_REQUIRED, DEALERSHIP_CHECK_FORBIDDEN, DEALERSHIP_CLIENT_UNRESOLVED, DEALER_COLLECTION_NOT_ENTITLED
404NOT_FOUND, ASSET_NOT_FOUND, IMAGE_UNAVAILABLE, NO_DECISION_TO_RESEND, PROFILE_NOT_FOUND, SESSION_NOT_FOUND, NO_ON_FILE_LICENCE, LICENCE_IMAGE_UNAVAILABLE
409IDEMPOTENT_REQUEST_IN_PROGRESS, VERIFICATION_NOT_OPEN, ALREADY_ERASED, INVALID_STATUS_TRANSITION, licence_token_already_used
410asset_purged, legacy_endpoint_removed, licence_token_expired
422ADDRESS_STREET_MISSING, ADDRESS_TYPE_NOT_SUPPORTED, IDEMPOTENCY_KEY_REUSED
429RATE_LIMIT_EXCEEDED
500INTERNAL_SERVER_ERROR, PACK_RENDER_FAILED
502ASSET_PURGE_FAILED
503AUTH_SERVICE_UNAVAILABLE, PACK_RENDER_QUARANTINED, PDF_RENDER_TIMEOUT, PDF_RENDER_BUSY

PACK_RENDER_QUARANTINED is the evidence-pack safety circuit breaker described under Evidence: no Retry-After, not to be retried in a loop, not permanent, cleared by an operator replaying the render job — contact support. PDF_RENDER_TIMEOUT and PDF_RENDER_BUSY are ordinary transient render refusals and are safe to retry.

Some historical codes intentionally retain their mixed casing. Do not rename them in a client.

Test keys are limited to 5 requests/second, 30/minute, and 1,000/day. Standard live keys are limited to 10/second, 60/minute, and 5,000/day before client-specific overrides. Every 429 RATE_LIMIT_EXCEEDED includes Retry-After. The concurrent-verification guard also returns 429 with Retry-After: 30; it does not apply to test keys.

Evidence is available for the client's configured identity window (7 days by default, up to 30 days on written instruction). At the end of the identity window, identifying data is de-identified under the account's retention configuration. A purged asset returns 410; do not treat it as a transient HTTP error.

The full retention lifecycle, per the current Data Processing Agreement (src/legal/v1/dpa.html, clause 8):

  • Retention Start Date. Each account has a Retention Start Date shown in the client's portal. Before that date, Applicant data is held securely and is deleted only on the client's explicit instruction.
  • De-identification. From the Retention Start Date, DoorID removes all identifying Applicant data — captured media, extracted document details, name, contact details, date of birth, precise location/sensor data, the Evidence Pack and its links — within 7 days after each verification concludes (or at the end of an instructed extended-availability period).
  • What is retained after de-identification. Only de-identified technical and audit data: the verification identifier, the client's reference, timestamps, journey type, the Result and its reasons, each check's outcome and score, device class, derived non-identifying attributes, the settings that applied, integrity hashes, delivery records, the Evidence Pack fingerprint, any outcome the client reports back, and one-way keyed fingerprints of the applicant's phone number and identity-document number (recognise a repeat submission; cannot be reversed to the original value). The full declared address is kept 90 days for dispute purposes, then reduced to outward postcode and property identifier.
  • Erasure requests. A client may instruct deletion of a specific applicant's data at any time; DoorID acts within 7 days and confirms.
  • Legal hold. Before de-identification, a client may place a hold on a specific verification for a dispute, complaint, or legal/regulatory need. A portal hold lasts 90 days; a written instruction may extend it to 12 months. Already de-identified data cannot be restored by a hold.
  • Termination. On account termination, remaining Applicant data is de-identified or deleted within 30 days, subject to any active legal hold or a legal retention requirement.

There is currently no separate, shorter retention clock for an on-file licence reused across verifications — reuse and its own retention window are tracked as future work, not yet enforced by the running system.

Changelog

  • 2026-09-16 — Dealer Collection outcome timing. dealership_check.outcome is now sent only when the registration read and the face comparison have finished processing; it is no longer sent at the 10-minute mark with a still-processing check reported as unable. It can therefore arrive after outcome_expected_by. If processing has not finished 30 minutes after completed_at, no outcome event is sent: the new dealership_check.processing_failed event is sent instead (once, no verdict, reason_code processing_timeout or processing_error), the same notice is emailed to the client's evidence-pack recipients, and the run is reviewed manually; the reviewer's decision is then sent as the run's one dealership_check.outcome (same fields, status_line "Decided by a DoorID reviewer."), and GET …/outcome returns it. Receivers that switch on event should handle or ignore the new event. GET /v1/dealership-check/sessions/:id/outcome keeps returning outcome_status: "processing" (no verdict) for as long as processing continues, including past outcome_expected_by, and for a run whose processing could not be completed. Existing fields and events are unchanged. The Dealer Collection capture now records at 1080p rather than 4K.

  • 2026-09-16 — Handover check out of beta. Documentation only; no request, response, webhook payload or schema_version changed. The Dealer Collection run is named as the Handover check it is sold as, and the reference now covers every route a client key uses for it: POST /v1/dealership-check/sessions (the original create route, kept as an alias of the unified create), GET /v1/dealership-check/sessions, GET /v1/dealership-check/sessions/:id and POST /v1/dealership-check/sessions/:id/send-sms, with their shapes taken from the code; the full dealership_* field list and the run-status vocabulary; the counting rule (one check is one check, counted at link issue); and the missing error codes. The catch-hook wording no longer calls the no-code Handover switch a "beta flag". The contract test now pins the documented shapes to the routes.

  • 2026-09-15 — Consent decline is the same on all three stages, and no longer final. The customer is asked to confirm, the decline is recorded, and the client receives a notice: verification.consent_declined, licence_capture.consent_declined or dealership_check.consent_declined. The link keeps working, so a customer who changes their mind can reopen it and continue. Change for address verifications: a decline no longer ends the verification with verification.completed / verification.decision carrying consent_declined; those events now only follow if the customer finishes, and an unreturned decline ends as expired. Licence pre-capture and Dealer Collection gain a decline for the first time, and their session status / run_status gain consent_declined.

  • 2026-09-15 — Added POST /v1/licence-precapture/sessions/:id/cancel and POST /v1/dealership-check/sessions/:id/cancel, with the same rules as the verification cancel route. Session lists and Dealer Collection run_status gain the cancelled value, and a session replaced by a newer one for the same customer now reports expired instead of pending or opened (its link had already stopped working).

  • 2026-09-15 — Added the verification.opened webhook for address verifications. It is sent once, the first time the customer opens their link, with status: "opened", opened_at and created_at, matching licence_capture.opened and dealership_check.opened. It carries no result. Receivers that switch on event should ignore events they do not recognise.

  • 2026-09-15 — verification.decision is now always sent after the verification.completed for the same verification has been delivered (or has exhausted its retries). Previously the two could occasionally arrive a fraction of a second out of order. No payload or header changes.

  • 2026-09-14 — Added the verification.capture_completed webhook for address verifications (house, rural, flat and apartment). It is sent once, when the customer finishes their captures, with status: "processing", capture_completed_at, outcome_expected_by (ten minutes later) and status_message. It carries no result; verification.completed and verification.decision are unchanged. Receivers that switch on event should ignore events they do not recognise.

  • 2026-09-14 — Dealer Collection outcome timing. dealership_check.completed gains the additive fields outcome_status (always processing), outcome_expected_by and status_message, and dealership_check.outcome gains outcome_status (always final). The outcome event now waits for the registration read and the face comparison to finish (at most 10 minutes after completion) rather than being sent about a minute after the upload with those checks reported as unable. GET /v1/dealership-check/sessions/:id/outcome adds outcome_status and outcome_expected_by, and during that window returns outcome_status: "processing" with verdict, outcome, manual_review_required, checks and evaluated_at set to null. A face comparison the matching service failed to carry out is now unable rather than a fail at 0% similarity. The Dealer Collection webhook events are documented here for the first time.

  • 2026-09-14 — licence_capture.completed gains the additive fields evidence_expected_by (ten minutes after completed_at) and status_message, so an integrator can confirm the customer has finished and show when the evidence pack will be ready.

  • 2026-09-09 — Added the additive evidence_available_until field (Task #1868) to GET /v1/verifications/:id and every v7 webhook payload (verification.completed, verification.decision, and the terminal decision variant): an ISO 8601 timestamp, or null. It will report the date DoorID's evidence-retention window closes for a verification once an account's Retention Start Date is configured. No account has a Retention Start Date set yet, so the field is null on every response and payload today — existing integrations can ignore it safely. The verification.erased webhook does not carry this field. Also documented the new GET /v1/verifications/export bulk-download endpoint (ZIP of evidence packs + a results.csv, capped at 200 matching verifications per request, tenant-scoped like the single-record evidence route).

  • 2026-09-09 — Decision recorded: the legacy dual error envelope ({ success: false, error: {...} } alongside the current { error: {...} } shape) stays on indefinitely; no removal date is scheduled. The X-Scorecard-Token header is now accepted as an alternative to ?token= on the signal-scorecard export.

  • 2026-09-09 — Verification pass (Task #1861): identity_mode now rejects an unrecognised value with 400 INVALID_IDENTITY_MODE (previously silently ignored — fixed in code, not just documented); corrected the mode section, which had wrongly claimed mode is "not a current public selector" even though it is validated and returns INVALID_MODE / USE_LICENCE_PRECAPTURE_ENDPOINT; corrected the Idempotency-Key-reuse message and doc text to stop claiming the request body is compared or that a different path is rejected; added the missing create/licence-precapture/ address-batch codes to the error table; documented GET /v1 discovery, the no-code catch-hook, and the licence_capture.opened/licence_capture.completed webhooks. Fixed a stale "7-attempt" code comment on the webhook dispatcher (behavior was already 8 attempts). Expanded the retention section from a single Evidence Pack availability line to the DPA's full clause-8 lifecycle (Retention Start Date, what de-identification removes, what is retained afterwards, erasure requests, legal hold, termination) and made explicit that no separate licence-reuse retention clock exists yet. Completed the Node webhook-receiver sample with the timestamp-freshness check its own prose already claimed, and added an equivalent Python receiver sample — neither previously enforced or demonstrated the 5-minute tolerance. All other reference points — evidence negotiation, webhook fields/headers/ retry schedule, the 429 concurrent-cap response, date_of_birth vs dob, customer_link shape and 31-day expiry, the health-check payload, resend-webhook response, access-logging, and licence pre-capture session/ token/resend-link rules — were verified against the running server and found already correct. See reports/p11b-result.md for the full sixteen-point verification record.

  • 2026-09-08 — Corrected the customer-link lifetime to 744 hours (31 days) across verification, licence pre-capture, and Dealer Collection links; documented the Dealer Collection outcome and evidence endpoints, their PDF-only imagery rule, and how a run ends; added PACK_RENDER_QUARANTINED.

  • 2026-09-07 — Reconciled the reference with the mounted public routes; corrected evidence negotiation, detail wrapping, health aliases, error casing, idempotency behavior, link lifetime, and webhook headers/retries.

  • 2026-09-07 — Licence pre-capture accepts live keys; SMS queueing is conditional rather than a delivery guarantee; session-keyed evidence is pullable after capture.

  • 2026-09-07 — Added the additive verification_id detail alias and documented INVALID_IDENTITY_MODE/current identity-mode behavior.

Public contract boundary

This reference covers integrator-facing authenticated API routes and the health aliases only. Browser-internal capture, OTP, sensor, consent, customer-link, telemetry, object-proxy, admin, and portal routes are not third-party APIs and are intentionally not documented here.

The machine-readable specification is available as OpenAPI 3.1.