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:
- Keep an API key server-side and call
POST /v1/verifications. - Send the returned
customer_linkto the customer. - Receive a signed
verification.completedwebhook, or pollGET /v1/verifications/:iduntilstatusis terminal. - Fetch the evidence pack from
GET /v1/verifications/:id/evidence. Request PDF with?format=pdf; if the response is202, wait forRetry-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.
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:
{
"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:
Authorization: Bearer doorid_test_xxxxxxxxxxxx
or:
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 /healthGET /healthzGET /v1/health
They return:
{
"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:
{
"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}):
{
"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:
{
"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):
| Field | Required | Notes |
|---|---|---|
dealership_address | yes | Plain object: the address of the collection site. 400 MISSING_DEALERSHIP_ADDRESS when missing or empty. |
expected_registration_plate | yes | The plate the customer should be collecting. 400 MISSING_EXPECTED_PLATE when missing. |
expected_make, expected_colour | no | Shown in the report; never used to fail a run. |
dealership_name | no | Shown to the customer and in the report. |
prior_verification_reference | no | The 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_token | no | A 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. |
consent | no | Stored verbatim on the customer profile when supplied; never fabricated. The response reports consent_logged. |
new_run | no | true starts another run for the same customer and plate; see Ending a Dealer Collection run. |
callback_url | no | Per-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. |
dob | no | The 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:
{
"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:
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 (onlycompleted/failedverifications ever do; reuses the same self-healing render path asGET /:id/evidence).results.csv— one row per matching verification, including ones with no pack (still processing, cancelled, expired, declined), with columnsverification_id,reference,client_id,status,decision,created_at,updated_at,evidence_included,exclusion_reasonso 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:
{
"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:
{
"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:
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:
{
"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:
{
"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:
{
"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.
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
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.
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:
{
"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-Afterheader, 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:
| Value | Meaning |
|---|---|
completed | All three captures are in. Checked first, so a finished run never reads as expired. |
cancelled | You cancelled the run before it finished. |
expired | The link reached its 31-day window, or a newer run for the same customer and plate replaced it. |
consent_declined | The customer declined on the consent screen and has not agreed since. Not final: the link still works. |
opened | The customer has opened the link but not finished. |
pending | Issued, 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:
{
"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:
{
"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:
{
"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:
{ "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:
{
"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:
{
"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:
{
"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:
{
"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: truecreate 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 reportsrun_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:
{ "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:
{
"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:
- Supplied on the request — a
licence_token(or an accepted lender upload) always wins, exactly as before. - Captured by DoorID — a current licence front the customer captured through DoorID's own licence pre-capture flow.
- 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:
| Reason | Meaning |
|---|---|
no_customer_match | No customer with that reference on your account (also returned for a customer held by another account). |
nothing_on_file | The customer exists, but no licence is on file. |
outside_freshness_window | A licence exists but was captured outside the freshness window. |
image_unavailable | A 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:
{
"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:
{ "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/statusreturns200 {ok, profile_id, reference, full_name, status, on_file_licence, evidence_available}.GET /v1/licence-precapture/profiles/:profileId/evidence/pdfGET /v1/licence-precapture/profiles/:profileId/evidence/frontGET /v1/licence-precapture/profiles/:profileId/evidence/backGET /v1/licence-precapture/profiles/:profileId/evidence/selfieGET /v1/licence-precapture/sessions/:sessionId/evidence/pdfGET /v1/licence-precapture/sessions/:sessionId/evidence/frontGET /v1/licence-precapture/sessions/:sessionId/evidence/backGET /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 addsstatus: "captured",completed_at, and a signedevidence_urlfor pulling the capture evidence, in place ofopened_at's role. It also carriesevidence_expected_by(ten minutes aftercompleted_at) andstatus_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 aslicence_capture.opened, withstatus: "consent_declined",declined_at,expires_atand astatus_message. Not final: the link keeps working and reopening it shows the consent screen again, solicence_capture.completedcan 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:
{
"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:
| HTTP | Codes |
|---|---|
| 400 | MISSING_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 |
| 401 | MISSING_API_KEY, INVALID_API_KEY_FORMAT, INVALID_API_KEY |
| 402 | TRIAL_LIMIT_REACHED, CARD_REQUIRED |
| 403 | CLIENT_ARCHIVED, EVIDENCE_ACCESS_DENIED, ADMIN_AUTH_REQUIRED, DEALERSHIP_CHECK_FORBIDDEN, DEALERSHIP_CLIENT_UNRESOLVED, DEALER_COLLECTION_NOT_ENTITLED |
| 404 | NOT_FOUND, ASSET_NOT_FOUND, IMAGE_UNAVAILABLE, NO_DECISION_TO_RESEND, PROFILE_NOT_FOUND, SESSION_NOT_FOUND, NO_ON_FILE_LICENCE, LICENCE_IMAGE_UNAVAILABLE |
| 409 | IDEMPOTENT_REQUEST_IN_PROGRESS, VERIFICATION_NOT_OPEN, ALREADY_ERASED, INVALID_STATUS_TRANSITION, licence_token_already_used |
| 410 | asset_purged, legacy_endpoint_removed, licence_token_expired |
| 422 | ADDRESS_STREET_MISSING, ADDRESS_TYPE_NOT_SUPPORTED, IDEMPOTENCY_KEY_REUSED |
| 429 | RATE_LIMIT_EXCEEDED |
| 500 | INTERNAL_SERVER_ERROR, PACK_RENDER_FAILED |
| 502 | ASSET_PURGE_FAILED |
| 503 | AUTH_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.outcomeis 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 asunable. It can therefore arrive afteroutcome_expected_by. If processing has not finished 30 minutes aftercompleted_at, no outcome event is sent: the newdealership_check.processing_failedevent is sent instead (once, no verdict,reason_codeprocessing_timeoutorprocessing_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 onedealership_check.outcome(same fields,status_line"Decided by a DoorID reviewer."), andGET …/outcomereturns it. Receivers that switch oneventshould handle or ignore the new event.GET /v1/dealership-check/sessions/:id/outcomekeeps returningoutcome_status: "processing"(no verdict) for as long as processing continues, including pastoutcome_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_versionchanged. 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/:idandPOST /v1/dealership-check/sessions/:id/send-sms, with their shapes taken from the code; the fulldealership_*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_declinedordealership_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 withverification.completed/verification.decisioncarryingconsent_declined; those events now only follow if the customer finishes, and an unreturned decline ends asexpired. Licence pre-capture and Dealer Collection gain a decline for the first time, and their sessionstatus/run_statusgainconsent_declined. -
2026-09-15 — Added
POST /v1/licence-precapture/sessions/:id/cancelandPOST /v1/dealership-check/sessions/:id/cancel, with the same rules as the verification cancel route. Session lists and Dealer Collectionrun_statusgain thecancelledvalue, and a session replaced by a newer one for the same customer now reportsexpiredinstead ofpendingoropened(its link had already stopped working). -
2026-09-15 — Added the
verification.openedwebhook for address verifications. It is sent once, the first time the customer opens their link, withstatus: "opened",opened_atandcreated_at, matchinglicence_capture.openedanddealership_check.opened. It carries no result. Receivers that switch oneventshould ignore events they do not recognise. -
2026-09-15 —
verification.decisionis now always sent after theverification.completedfor 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_completedwebhook for address verifications (house, rural, flat and apartment). It is sent once, when the customer finishes their captures, withstatus: "processing",capture_completed_at,outcome_expected_by(ten minutes later) andstatus_message. It carries no result;verification.completedandverification.decisionare unchanged. Receivers that switch oneventshould ignore events they do not recognise. -
2026-09-14 — Dealer Collection outcome timing.
dealership_check.completedgains the additive fieldsoutcome_status(alwaysprocessing),outcome_expected_byandstatus_message, anddealership_check.outcomegainsoutcome_status(alwaysfinal). 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 asunable.GET /v1/dealership-check/sessions/:id/outcomeaddsoutcome_statusandoutcome_expected_by, and during that window returnsoutcome_status: "processing"withverdict,outcome,manual_review_required,checksandevaluated_atset tonull. A face comparison the matching service failed to carry out is nowunablerather than afailat 0% similarity. The Dealer Collection webhook events are documented here for the first time. -
2026-09-14 —
licence_capture.completedgains the additive fieldsevidence_expected_by(ten minutes aftercompleted_at) andstatus_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_untilfield (Task #1868) toGET /v1/verifications/:idand every v7 webhook payload (verification.completed,verification.decision, and the terminal decision variant): an ISO 8601 timestamp, ornull. 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 isnullon every response and payload today — existing integrations can ignore it safely. Theverification.erasedwebhook does not carry this field. Also documented the newGET /v1/verifications/exportbulk-download endpoint (ZIP of evidence packs + aresults.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. TheX-Scorecard-Tokenheader is now accepted as an alternative to?token=on the signal-scorecard export. -
2026-09-09 — Verification pass (Task #1861):
identity_modenow rejects an unrecognised value with400 INVALID_IDENTITY_MODE(previously silently ignored — fixed in code, not just documented); corrected themodesection, which had wrongly claimedmodeis "not a current public selector" even though it is validated and returnsINVALID_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; documentedGET /v1discovery, the no-code catch-hook, and thelicence_capture.opened/licence_capture.completedwebhooks. 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_birthvsdob,customer_linkshape 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. Seereports/p11b-result.mdfor 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_iddetail alias and documentedINVALID_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.
