API keys are created in the portal: log in → Developers → API keys.Login
DoorID API Documentation
Production-safe reference for the DoorID identity-and-presence API, built for any industry. It covers the Address check, the Collection check, the Delivery check and the Licence check.
This page is the same source rendered at /docs and returned by the
Download docs (.md) button. The four products come first. Everything
else (old route names, every field, errors, limits and signatures) is in the
Advanced reference at the bottom of the page.
The four products
| Product | What it checks | How to start it | What you get back |
|---|---|---|---|
| Address check | The customer goes to the address on their own phone and shows they can get in. If no current driving licence is on file for them, they photograph the front and the back. | POST /v1/address-checks | Pass, Refer or Fail with reasons, a webhook for each step, and a PDF report. |
| Collection check | At collection, the customer, the vehicle and the place are recorded together. If no current driving licence is on file for them, they photograph the front and the back first. | POST /v1/collection-checks | A verdict (approved, referred or failed) from the plate, face and location checks, a webhook for each step, and a PDF report. |
| Delivery check | At delivery, the customer, the vehicle and the delivery address are recorded together. If no current driving licence is on file for them, they photograph the front and the back first. | POST /v1/delivery-checks | A verdict (approved, referred or failed) from the plate, face and location checks, a webhook for each step, and a PDF report. |
| Licence check | The customer photographs the front and back of their driving licence and takes a live photo of their face. There is no address visit. | POST /v1/licence-checks | The captured licence for your team to review (a Licence check has no Pass, Refer or Fail), a webhook for each step, and a PDF report. |
Every product works the same way. Your server starts the check with an API key. DoorID returns a link. The customer opens the link on their own phone and follows the steps. You get webhooks as they go, can read the result at any time, and can download the report once they have finished.
These are the same four products as the client portal's Send a check page, with the same rules: the same required fields, the same consent handling, the same reuse of a driving licence already on file, and every account can start all four. A check started here and one sent from the portal are the same journey and produce the same record.
Each product has four routes: start, status / result, report and resend. The routes integrations already use keep working unchanged; they are listed once, under Older routes, still supported.
Each link works for 744 hours (31 days) from creation. DoorID texts the link to the customer when your account's text setting for that product is on; the link is also in the response, so you can send it yourself.
Before you start
- Base URL.
https://www.doorid.ai. Every route starts with/v1. - API key. Create keys in the portal (Developers, then API keys). Send
the key from your server as
Authorization: Bearer <key>(orX-API-Key: <key>). Never put a key in browser code or in a customer link. - Test keys. Build with a test key (such as
doorid_test_xxxxxxxxxxxx). A test check texts and emails nobody, uses built-in test providers, is never billed, and its report is a fixed example. See Test keys are a sandbox in the Advanced reference. - Idempotency. Send an
Idempotency-Keyheader (a fresh UUID) on every start, so a retried request returns the first answer instead of starting a second check. - Field names. Requests and responses use
snake_case. - Webhooks. Set your webhook URL in the portal, or send
callback_urlwhen you start a Collection check or an Address check. It must behttpsand publicly reachable. Every delivery is signed; see Webhooks below. - How Address check results are decided. A new account decides automatically: DoorID's automated checks give Pass, Refer or Fail, with the reasons. Where the account is set to review borderline results, a borderline result is Refer for your team to look at. An account can instead be set to review every result; then every result is Refer until it is reviewed. DoorID sets this on your account; contact us to change it.
The examples below read the key and the base URL from two shell variables:
export DOORID_API_KEY='doorid_test_xxxxxxxxxxxx'
export DOORID_BASE_URL='https://www.doorid.ai'
Address check
The customer records short live videos at the address on their own phone, ending inside. DoorID checks that it is them, that they are there, and that it is now.
Start an Address check (POST /v1/address-checks)
curl -sS -X POST "$DOORID_BASE_URL/v1/address-checks" \
-H "Authorization: Bearer $DOORID_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Alex",
"last_name": "Example",
"phone": "+447700900123",
"reference": "APP-44218",
"address": {
"line_1": "14 Example Road",
"city": "Bournemouth",
"postcode": "BH1 1AA",
"country": "GB"
},
"callback_url": "https://your-app.example.com/webhooks/doorid"
}'
Response 201. Like every start, it is a bare 201 response object, not
wrapped in success / data:
{
"check_id": "did_3f2a4c91e8b7d0f2...",
"reference": "APP-44218",
"status": "pending",
"customer_link": "https://www.doorid.ai/v/Ab3dEf4gHi5jK6mN7pQ8rS9tU0w",
"slug": "Ab3dEf4gHi5jK6mN7pQ8rS9tU0w",
"expires_at": "2026-11-12T12:00:00.000Z"
}
Required: first_name, last_name, phone, reference, address.line_1
and address.city (400 MISSING_FIELD, with the missing fields in
details.missing). Optional: address.line_2, address.region,
address.postcode, address.country (defaults to GB), callback_url and
journey_type (leave it out to let DoorID choose from the address). An
unusable phone number is 400 invalid_phone. check_id is the check's id in
every other Address check route. Send customer_link to the customer.
The driving licence follows one rule. When no current licence is on file for
the customer, they photograph the front and the back before the rest of the
check, unless your account's identity check is off; a current licence on file
for this reference is reused. When DoorID holds a current licence that
matches this customer only by phone number, it asks before using it: nothing
is created, and the answer is 200:
{
"requires_confirmation": true,
"on_file_licence_candidate": { "id": "oflic_...", "captured_at": "2026-10-01T09:30:00.000Z" }
}
Send the same request again with "reuse_licence_id": "<that id>" to use it,
or "decline_licence_reuse": true to go without it. To supply the licence
yourself, upload it first (POST /v1/licences/upload) and send the
licence_token it returns; an unknown token is 400 LICENCE_TOKEN_INVALID,
a used one 409 LICENCE_TOKEN_ALREADY_USED and an expired one
410 LICENCE_TOKEN_EXPIRED.
Get the Address check result (GET /v1/address-checks/:id)
curl "$DOORID_BASE_URL/v1/address-checks/$CHECK_ID" \
-H "Authorization: Bearer $DOORID_API_KEY"
Response 200 (trimmed):
{
"success": true,
"data": {
"verification_id": "did_3f2a4c91e8b7d0f2...",
"reference": "APP-44218",
"status": "completed",
"status_plain": "Result: Pass",
"result_plain": "Pass",
"last_decision": { "decision": "pass", "reasons": [] },
"evidence_available_until": "2026-10-19T12:20:00.000Z"
}
}
status is where the customer is in the journey. result_plain is the
result: Pass, Refer, Fail, or null while there is none. last_decision
is the full decision payload (the same body as the verification.decision
webhook) once there is one. verification_id is the same id as check_id.
Download the Address check report (GET /v1/address-checks/:id/report)
curl "$DOORID_BASE_URL/v1/address-checks/$CHECK_ID/report" \
-H "Authorization: Bearer $DOORID_API_KEY" --output address-check-report.pdf
Returns the report as 200 application/pdf. Before the check has finished
the answer is 409 NOT_READY. While the PDF is being made it is 202 with
Retry-After: 30: wait and ask again. Reports can be downloaded for 7 days
after the check finishes (evidence_available_until), then the answer is
410 EVIDENCE_WINDOW_CLOSED. Keep your own copy.
Resend the Address check link (POST /v1/address-checks/:id/resend)
curl -X POST "$DOORID_BASE_URL/v1/address-checks/$CHECK_ID/resend" \
-H "Authorization: Bearer $DOORID_API_KEY"
Response 200:
{
"success": true,
"data": {
"verification_link": "https://www.doorid.ai/v/...",
"sms_sent": true,
"had_phone": true
}
}
Texts the link to the customer again while the check is open. At most 2 a
minute and 6 an hour per check. 409 VERIFICATION_NOT_OPEN once the check is
no longer open; 409 SMS_OPTED_OUT when the customer's number has opted out
of DoorID's texts (send verification_link yourself).
Address check webhooks
In the order they can arrive: verification.opened (the customer opened the
link), verification.consent_declined (not final: the link keeps working),
verification.capture_completed (the customer has finished; the result
follows within 10 minutes), verification.completed, verification.decision
(the result, sent only when Share decision events is on in the portal),
and verification.erased. Payloads carry schema_version: 7 and
verification_id. Each payload is shown in full in Webhook reference in
the Advanced reference.
Collection check
At handover the customer records short live videos of the place of collection, the vehicle and themselves. DoorID checks the registration plate, the face and the location.
Start a Collection check (POST /v1/collection-checks)
curl -sS -X POST "$DOORID_BASE_URL/v1/collection-checks" \
-H "Authorization: Bearer $DOORID_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Alex",
"last_name": "Example",
"phone": "+447700900123",
"reference": "APP-44218",
"collection_address": { "line_1": "1 Forecourt Way", "city": "Bournemouth", "postcode": "BH2 5AA", "country": "GB" },
"collection_name": "Example Motors Ltd",
"registration_plate": "AB12 CDE"
}'
Response 201:
{
"check_id": "dchk_...",
"customer_profile_id": "cprof_...",
"customer_link": "https://www.doorid.ai/dc/...",
"expires_at": "2026-11-12T12:00:00.000Z",
"status": "pending",
"sms_queued": true,
"reused_existing": false,
"prior_verification_linked": false
}
Required: first_name, last_name, reference, a valid phone,
collection_address (where the vehicle is collected; the location check uses
it) and registration_plate (the plate the customer should be collecting).
Any of them missing or unusable is 400 INVALID_DEALER_COLLECTION_REQUEST.
Optional: collection_name, vehicle_make, vehicle_colour, address (the
customer's own address), prior_verification_reference, callback_url
(400 INVALID_CALLBACK_URL), new_run and collection_place
(home_delivery runs the check where the vehicle is delivered to the
customer, at the collection_address you send; omit it for a forecourt
collection; any other value is 400 INVALID_COLLECTION_PLACE). There is no
consent field: the customer gives their consent on their own phone. The names the older route
uses (dealership_address, dealership_name, expected_registration_plate,
expected_make, expected_colour) are accepted too; one field sent under
both names with different values is 400 CONFLICTING_FIELDS. check_id is
the check's id in every other Collection check route. Starting the same customer
and plate again returns the active run (reused_existing: true) instead of a
second link.
Get the Collection check result (GET /v1/collection-checks/:id)
curl "$DOORID_BASE_URL/v1/collection-checks/$CHECK_ID" \
-H "Authorization: Bearer $DOORID_API_KEY"
Response 200 (trimmed): the run's stored record, with its status.
{
"ok": true,
"session": {
"session_id": "dchk_...",
"reference": "APP-44218",
"status": "completed",
"expected_registration_plate": "AB12 CDE",
"captures_completed_at": "2026-10-12T12:04:00.000Z",
"evidence_available_until": "2026-10-19T12:04:00.000Z"
}
}
status is pending, opened, consent_declined, completed, cancelled
or expired. The verdict (approved, referred or failed) and the three
checks behind it are on the outcome route,
GET /v1/dealership-check/sessions/:id/outcome, which has no new name: see
Collection check (API name: dealership_check) results and evidence in the
Advanced reference. A Collection check has no Pass, Refer or Fail field; its
words are run_status and verdict.
Download the Collection check report (GET /v1/collection-checks/:id/report)
curl "$DOORID_BASE_URL/v1/collection-checks/$CHECK_ID/report" \
-H "Authorization: Bearer $DOORID_API_KEY" --output collect-check-report.pdf
Returns the report as 200 application/pdf, made on demand. A busy or slow
render answers 503 PDF_RENDER_BUSY or 503 PDF_RENDER_TIMEOUT: both are
safe to retry. Reports can be downloaded for 7 days after the customer
finished, then the answer is 410 EVIDENCE_WINDOW_CLOSED.
Resend the Collection check link (POST /v1/collection-checks/:id/resend)
curl -X POST "$DOORID_BASE_URL/v1/collection-checks/$CHECK_ID/resend" \
-H "Authorization: Bearer $DOORID_API_KEY"
Response 200 is { "ok": true, "sms_queued": true, "noop": false }.
Texts the link to the number on the run, or send { "phone": "+447700900123" }
to replace it. No number at all is 400 PHONE_REQUIRED. noop: true means
texting is switched off for the whole service and nothing was sent.
Collection check webhooks
dealership_check.opened, dealership_check.consent_declined (not final),
dealership_check.completed (the customer has finished; no verdict yet),
then dealership_check.outcome with the verdict, the three checks and a
signed evidence_pack_url, normally within 10 minutes. If processing cannot
finish, dealership_check.processing_failed is sent instead of the outcome
and DoorID reviews the run. Payloads carry schema_version: 1 and
session_id. Each payload is shown in full under Collection check
(API name: dealership_check) webhooks in the Advanced reference.
Delivery check
When the vehicle is delivered to the customer, at their home or another address that is not the dealership's, the customer records short live videos of the place, the vehicle and themselves. DoorID checks the registration plate, the face and the location against the delivery address.
Start a Delivery check (POST /v1/delivery-checks)
curl -sS -X POST "$DOORID_BASE_URL/v1/delivery-checks" \
-H "Authorization: Bearer $DOORID_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Alex",
"last_name": "Example",
"phone": "+447700900125",
"reference": "APP-44219",
"delivery_address": { "line_1": "14 Example Road", "city": "Bournemouth", "postcode": "BH1 1AA", "country": "GB" },
"registration_plate": "AB12 CDE",
"vehicle_make": "Example",
"vehicle_colour": "Blue"
}'
Response 201:
{
"check_id": "dchk_...",
"customer_profile_id": "cprof_...",
"customer_link": "https://www.doorid.ai/dc/...",
"expires_at": "2026-11-12T12:00:00.000Z",
"status": "pending",
"sms_queued": true,
"reused_existing": false,
"prior_verification_linked": false
}
Required: first_name, last_name, reference, a valid phone,
delivery_address (where the vehicle is delivered; the location check uses
it) and registration_plate. Any of them missing or unusable is
400 INVALID_DELIVERY_CHECK_REQUEST. Optional: address (the
customer's own address), vehicle_make, vehicle_colour,
prior_verification_reference, callback_url (400 INVALID_CALLBACK_URL)
and new_run, the same as the Collection check and the admin New Test
Delivery run. There is no consent field: the customer gives their consent on
their own phone. Starting the same customer and plate again returns the active
run (reused_existing: true) instead of a second link.
Get the Delivery check result (GET /v1/delivery-checks/:id)
curl "$DOORID_BASE_URL/v1/delivery-checks/$CHECK_ID" \
-H "Authorization: Bearer $DOORID_API_KEY"
The same response as the Collection check's result route: the run's stored
record, with its status. The verdict and the three checks behind it are on
GET /v1/dealership-check/sessions/:id/outcome.
Download the Delivery check report (GET /v1/delivery-checks/:id/report)
curl "$DOORID_BASE_URL/v1/delivery-checks/$CHECK_ID/report" \
-H "Authorization: Bearer $DOORID_API_KEY" --output delivery-check-report.pdf
Returns the report as 200 application/pdf, as for the Collection check.
Resend the Delivery check link (POST /v1/delivery-checks/:id/resend)
curl -X POST "$DOORID_BASE_URL/v1/delivery-checks/$CHECK_ID/resend" \
-H "Authorization: Bearer $DOORID_API_KEY"
Response 200 is { "ok": true, "sms_queued": true, "noop": false }, as for
the Collection check.
Delivery check webhooks
The same events as the Collection check, with the delivery address in
dealership_address: dealership_check.opened,
dealership_check.consent_declined, dealership_check.completed, then
dealership_check.outcome (or dealership_check.processing_failed).
Licence check
The customer photographs the front and back of their driving licence and
takes a live photo of their face, for your team to review. DoorID keeps the
licence on file, so a later Address check or Collection check for the same
reference reuses it.
Start a Licence check (POST /v1/licence-checks)
curl -sS -X POST "$DOORID_BASE_URL/v1/licence-checks" \
-H "Authorization: Bearer $DOORID_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Alex",
"last_name": "Example",
"phone": "+447700900123",
"reference": "APP-44218",
"address": { "line_1": "14 Example Road", "city": "Bournemouth", "postcode": "BH1 1AA", "country": "GB" }
}'
Response 201:
{
"check_id": "pcs_...",
"customer_profile_id": "cprof_...",
"customer_link": "https://www.doorid.ai/lpc/plc_...",
"expires_at": "2026-11-12T12:00:00.000Z",
"sms_queued": true
}
Required: first_name and last_name (400 MISSING_NAME), phone
(400 MISSING_PHONE; 400 INVALID_PHONE when it cannot be read) and
reference (400 MISSING_REFERENCE). address is optional. There is no
consent field: the customer gives their consent on their own phone before
the capture starts, and DoorID records it. Webhooks for a Licence check go to
the webhook URL set in the portal. check_id is the check's id in every other
Licence check route. sms_queued is true when your account's first text for
Licence checks is on; it does not prove delivery. Starting a new Licence
check for the same reference replaces the older link.
Get Licence check results (GET /v1/licence-precapture/sessions)
curl "$DOORID_BASE_URL/v1/licence-precapture/sessions" \
-H "Authorization: Bearer $DOORID_API_KEY"
There is no single-check read for a Licence check: this list is the status
route. It holds your 50 most recent Licence checks, newest first, and an
item's session_id is the check_id. Each item (trimmed):
{
"session_id": "pcs_...",
"reference": "APP-44218",
"status": "captured",
"captured_at": "2026-10-12T12:04:00.000Z",
"evidence_pdf_url": "/v1/licence-precapture/sessions/pcs_.../evidence/pdf",
"evidence_available_until": "2026-10-19T12:04:00.000Z"
}
status is pending (not opened yet), opened, consent_declined,
captured (the licence was taken), cancelled or expired. A Licence check
has no Pass, Refer or Fail: your team reviews the licence itself. For one
customer's current licence, use
GET /v1/licence-precapture/profiles/:profileId/status. The
licence_capture.* webhooks (below) tell you when each check changes, so you
do not need to poll.
Download the Licence check report (GET /v1/licence-checks/:id/report)
curl "$DOORID_BASE_URL/v1/licence-checks/$CHECK_ID/report" \
-H "Authorization: Bearer $DOORID_API_KEY" --output licence-check-report.pdf
Returns the report as 200 application/pdf. Before the licence is captured
the answer is 404 NO_ON_FILE_LICENCE. Reports can be downloaded for 7 days
after the licence was captured, then the answer is
410 EVIDENCE_WINDOW_CLOSED.
Resend the Licence check link (POST /v1/licence-checks/:id/resend)
curl -X POST "$DOORID_BASE_URL/v1/licence-checks/$CHECK_ID/resend" \
-H "Authorization: Bearer $DOORID_API_KEY"
Response 200 is { "ok": true, "sms_queued": true }. Texts the link to
the number on the check, or send { "phone": "+447700900123" } to replace it.
No number at all is 400 PHONE_REQUIRED. This manual send works even when
your account's automatic first text is off.
Licence check webhooks
licence_capture.opened, licence_capture.consent_declined (not final) and
licence_capture.completed (the licence was captured; it carries a signed
evidence_url for the report). Payloads carry schema_version: 1 and
session_id. There is no verdict event. The fields are listed under
Licence-capture webhooks in the Advanced reference.
Older routes, still supported
Integrations built on these routes keep working exactly as they do today:
same fields, same rules, same responses and the same webhooks. The status,
report and resend routes above answer exactly as these do. The start routes
keep their own published rules, which differ from the product starts above
in places (for example a Licence check start there needs a consent object,
and a Collection check start there accepts full_name). Every field is in the
Advanced reference.
| Product | Start | Status / result | Report | Resend |
|---|---|---|---|---|
| Address check | POST /v1/verifications | GET /v1/verifications/:id | GET /v1/verifications/:id/evidence?format=pdf | POST /v1/verifications/:id/resend-link |
| Collection check | POST /v1/dealership-check/sessions | GET /v1/dealership-check/sessions/:id | GET /v1/dealership-check/sessions/:id/evidence/pdf | POST /v1/dealership-check/sessions/:id/send-sms |
| Licence check | POST /v1/licence-precapture/sessions | GET /v1/licence-precapture/sessions | GET /v1/licence-precapture/sessions/:id/evidence/pdf | POST /v1/licence-precapture/sessions/:id/send-sms |
Webhooks
DoorID posts JSON to your webhook URL (or the check's callback_url). Each
product sends its own events:
| Product | Webhook events, in the order they can arrive |
|---|---|
| Address check | verification.opened, verification.consent_declined, verification.capture_completed, verification.completed, verification.decision (only when Share decision events is on), verification.erased |
| Collection check | dealership_check.opened, dealership_check.consent_declined, dealership_check.completed, dealership_check.outcome, dealership_check.processing_failed |
| Delivery check | dealership_check.opened, dealership_check.consent_declined, dealership_check.completed, dealership_check.outcome, dealership_check.processing_failed |
| Licence check | licence_capture.opened, licence_capture.consent_declined, licence_capture.completed |
Every delivery is signed. Check X-DoorID-Signature-V2: t=<timestamp>,v1=<hmac>,
where <hmac> is HMAC-SHA256 with your webhook secret over
<timestamp>.<raw body>, and X-DoorID-Timestamp equals t. Reject a
signature older than five minutes. Read the raw body before parsing it.
X-DoorID-Event names the event. Answer with any 2xx once you have stored the
delivery; a failed delivery is tried again, 8 attempts in all over about
3 hours. The
receiver code, the retry schedule and the URL rules are in the Advanced
reference. The portal's Send test webhook button sends a signed sample
(webhook.test) so you can check your receiver first.
Advanced reference
Old route names, every field, errors, limits, retention and signatures. Select to open.
Everything below is public reference: every field and route, the error codes, limits, retention and the signature details. You do not need any of it to start the four products above.
Discovery
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",
"licence_precapture": "/v1/licence-precapture/sessions",
"handover_check": "/v1/dealership-check/sessions",
"health": "/health"
},
"products": [
{
"id": "address_check",
"name": "Address check",
"what_it_does": "The customer goes to the address on their own phone and shows they can get in. You get Pass, Refer or Fail, and a report.",
"how_it_links": "POST /v1/verifications with no mode. mode property_only is accepted and means the same. Success is 201 with verification_id and customer_link. The same customer_link is what you send them.",
"limitation": "A customer with no current driving licence on file photographs the front and the back. A current licence on file is reused and they are not asked. Turning the identity check off for one check leaves the photo out. mode licence_only is not this product: it returns 400 USE_LICENCE_PRECAPTURE_ENDPOINT and creates nothing.",
"create_path": "/v1/verifications",
"success_status": 201
},
{
"id": "handover_check",
"name": "Collection check",
"what_it_does": "At collection, the customer, the vehicle and the place are recorded together. You get a match on the customer, the plate and the location, plus a report.",
"how_it_links": "POST /v1/verifications with dealership_address and expected_registration_plate, or POST /v1/dealership-check/sessions. Both create the same run. Success is 201. The body uses flow dealership_collection, not the address-check body. On the wire the fields stay dealership_*.",
"limitation": "A collection or a home delivery asks for the front and the back when no current driving licence is on file. A current licence on file is reused and they are not asked. With no licence to compare, the face check cannot be done and the result is Refer, not Fail. It counts as one check. A missing dealership address is 400 MISSING_DEALERSHIP_ADDRESS. A missing plate is 400 MISSING_EXPECTED_PLATE.",
"create_path": "/v1/verifications",
"success_status": 201
},
{
"id": "delivery_check",
"name": "Delivery check",
"what_it_does": "At delivery, the customer, the vehicle and the delivery address are recorded together. You get a match on the customer, the plate and the location, plus a report.",
"how_it_links": "POST /v1/delivery-checks with delivery_address and registration_plate. Success is 201 with check_id and customer_link. Read, report and resend are GET /v1/delivery-checks/:id, GET /v1/delivery-checks/:id/report and POST /v1/delivery-checks/:id/resend.",
"limitation": "A customer with no current driving licence on file photographs the front and the back. A current licence on file is reused and they are not asked. It counts as one check. Its webhooks are the dealership_check events.",
"create_path": "/v1/delivery-checks",
"success_status": 201
},
{
"id": "driving_licence",
"name": "Licence check",
"what_it_does": "The customer photographs the front and back of their driving licence and takes a live photo of their face. There is no address visit.",
"how_it_links": "POST /v1/licence-precapture/sessions. Do not send this as mode licence_only on POST /v1/verifications.",
"limitation": "mode licence_only on POST /v1/verifications returns 400 USE_LICENCE_PRECAPTURE_ENDPOINT and creates nothing. Any other unknown mode returns 400 INVALID_MODE.",
"create_path": "/v1/licence-precapture/sessions",
"success_status": 201
}
],
"documentation": "https://docs.doorid.ai"
}
}
data.products is also returned, in the order Licence check, Collection
check, Address check. Each item has id, name, what_it_does,
how_it_links, limitation, create_path and success_status (201).
The endpoint paths that were already in this map are unchanged. DoorID has
products a client can start: the Licence check, the Collection check and the
Address check, in that order. The list, their names and their create paths come from one
journey registry, the same one the client portal reads, so a product is the
same journey whether you start it here or in the portal, licence step
included (see "Checks sent from the client portal" under Licence
check). create_path is each product's create route. Until 12 October
2026 this response named the Collection check Collect check; its id
(handover_check) is unchanged. Resend and cancel stay
documented below and are not repeated in the map. (Erasure is done by DoorID
on request, not through an integration endpoint.)
- Address check. Proof your applicant was at the address. They record short live videos at the address on their own phone, ending inside, and we check it's them, there, now, then send you the result with an evidence pack.
- Collection check. The right person, the right car, at the dealership. At handover they record short live videos of the dealership, the vehicle and themselves, and we check the plate, the face and the location, so you know before you release funds.
- Delivery check. The right person, the right car, at the delivery address. At delivery they record short live videos of the address, the vehicle and themselves, and we check the plate, the face and the location against the delivery address.
- Licence check. Their driving licence and face, captured live. They photograph the front and back of their licence and take a live photo of their face for you to review, and we keep the licence on file for their Address or Collection check.
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.
An account holds at most 10 API keys, live and test together, unless DoorID
has raised its limit. Creating one more key in the portal is refused with
409 API_KEY_LIMIT_REACHED; revoke a key you no longer use, or ask DoorID to
raise the limit. Lowering a limit never revokes a key.
Every request is also checked against the account's standing, after the key
itself is accepted. A key whose account is paused or closed is refused on
every /v1 route. A key whose account has used its free customers with no
card on file, or is past the grace period after a failed card payment, is
refused on everything except reads (GET and HEAD), so results and reports
already produced stay available (see Account standing under Errors). The
answer is re-read at most every 60 seconds, so adding a card starts a stopped
key working again within a minute; a pause takes effect at once.
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 check 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": "ok" },
"storage": { "state": "ok" },
"workers": { "state": "skipped", "reason": "not the worker role (web)" }
}
}
Each check is ok, failed or skipped, and reason is free text: do
not parse it. 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.
Address check: full field reference
POST /v1/verifications
The route for starting an Address check (see Address check above), with every
field. It also starts a Collection check 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 is optional and is best left out: an omitted mode is the Address
check. The customer photographs the front and the back of their driving
licence when this account holds no current licence for that reference.
A current licence already on file is reused and the customer is not asked
again. skip_id_check: true is the switch that leaves the photo out.
identity_mode: "skip" does not, on its own, skip the photo.
Legacy aliases, accepted for compatibility and not products: property_only
behaves exactly like an omitted mode. The route also still accepts
full_journey, which
maps to identity_mode: "customer_capture" unless an explicit
identity_mode is also given (which always wins); it is no longer offered as
a product. 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).
skip does not, on its own, leave the licence photo out. A live check asks
the customer to photograph the front and the back of their driving licence
whenever your account holds no current licence for that
reference, whatever the identity_mode. That licence is then used for the
face comparison. A current licence already on file is reused, and the
customer is not asked.
skip_id_check: true is the switch that leaves the photo out. With it, and
no current licence on file for the same reference under your account, there
is no licence face to compare with. On the automated decisioning modes the
result then cannot Pass: it is Refer with licence_not_on_file, or Fail when
the evidence score is below the pass level or a fail rule applies (see the
decision payload under Webhook reference). New accounts decide
automatically. On an account set to manual review of every result, every
result is Refer until it is reviewed.
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",
"eligibility_tags": []
}
eligibility_tags echoes the advisory tags derived from eligibility (an
empty array when none were sent). 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.
Collection check (API name: dealership_check) on the same create route
This is the Collection 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": "dchk_...",
"customer_profile_id": "cprof_...",
"customer_link": "https://www.doorid.ai/dc/...",
"capture_link": "https://www.doorid.ai/dc/...",
"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 Collection check run).
dealership_address and expected_registration_plate are required for this
flow. The events and evidence for the Collection check 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 check 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 Collection check run. |
callback_url | no | Per-run webhook URL, overriding the account's configured URL for this run's events. Must be https and publicly routable — see Webhook URL requirements. 400 INVALID_CALLBACK_URL when not an absolute http(s) URL, or when it points at a private, loopback, link-local or reserved address or an internal hostname. |
dob | no | The customer's date of birth, stored on the profile. |
When this customer has no current driving licence on file for reference
or prior_verification_reference, a forecourt collection or a home
delivery asks them to photograph the front and the back before the other
photos. That photo is saved for this customer, the same way as a licence
photographed on a home or apartment check, and the face comparison uses
it. A current licence already on file means the customer is not asked, and
the comparison uses that licence. With no licence to compare against, and
no photo taken, the face check reports unable, which makes the run
referred rather than failing it.
Counting. A Collection 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 Collection check results and evidence below. Both routes
call the same creation code; an integration only ever needs one of them.
Optional collection_place: "home_delivery" starts that run at the customer's
address instead of the forecourt. Omit it for a forecourt collection. Any
other value is 400 INVALID_COLLECTION_PLACE. The delivery address is the
dealership_address you already send. A home delivery asks for the front
and the back of a driving licence when no current licence is on file, the
same as a forecourt collection. A current licence on file is reused. With
no licence to compare, the face check is unable and the run is referred.
The Collection check capture link uses the same 744-hour (31-day) lifetime as
a verification link. dealership_check_session_id is the identifier used by
the Collection check result and evidence endpoints below; there is no
verification record behind a Collection check 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 Collection check 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 the Collection check 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 Collection 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 ISO 8601 timestamp, or null. It is
when this verification's evidence window closes: 7 days after the check
finished (see Evidence window under Evidence). After that time the evidence
routes answer 410 EVIDENCE_WINDOW_CLOSED. It is null while the check has
not finished, and for an erased verification. The result, reasons and the
rest of this response stay readable after the window closes; only the
evidence (PDF and images) stops being served.
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_API_KEY" \
"$DOORID_BASE_URL/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. A pack whose 7-day evidence window has passed is listed withexclusion_reasonevidence_window_closedand is not bundled.
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.
Refusals: 404 NOT_FOUND for an unknown or another tenant's id, 409 VERIFICATION_NOT_OPEN once the check is no longer open, 409 SMS_OPTED_OUT
when the customer's number has opted out of DoorID's texts (send the
verification_link yourself instead), and 429 RATE_LIMIT_EXCEEDED with
Retry-After over the limit above.
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": "wdel_...",
"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.
Webhook reference
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.
Each product's events are listed under Webhooks near the top of this page.
Webhook URL requirements
Every webhook destination — a per-request callback_url on any create
endpoint (Licence check, Collection check, Address check, the
catch-hook URL, portal sends) and the account webhook URL set in the portal —
must use https and be publicly routable.
- When you set it. A URL whose host is a private, loopback, link-local,
carrier-grade-NAT, multicast, documentation or otherwise reserved IP address
(including cloud metadata addresses such as
169.254.169.254), or an internal hostname (localhost,*.local,*.internal), is refused with400 INVALID_CALLBACK_URL(the portal webhook settings page answers400 VALIDATIONwith awebhook_urldetail, as it already does for a non-https URL). OnPOST /v1/verificationswithoutdealership_*fields, this is the onlycallback_urlcheck: a value that is not a parseable http(s) URL is stored as before and simply never delivers. A plainhttpcallback_urlis still accepted for now and logged as deprecated; it will be refused with400 INVALID_CALLBACK_URLonce https enforcement is switched on. The portal webhook settings page already requires https. - When DoorID delivers. The host is resolved once, every address it
resolves to must be public, and the connection is made to that checked
address only, so a DNS answer that changes to an internal address is never
reached. A destination that fails this check is not contacted; the attempt
counts as a failed delivery. A URL stored before these rules existed keeps
receiving deliveries, including over plain
http, unless its host resolves to a non-public address. - Redirects. DoorID follows at most 3 redirects (
301,302,303,307,308). Every redirect target must meet the same rules (https, publicly routable, checked and pinned the same way). A redirect fromhttpstohttpis not followed. As before,307and308repeat the POST with the same body and headers, while301,302and303turn the POST into a GET without a body. A redirect that is not followed, or a fourth redirect, is a failed attempt retried on the normal schedule. Configuring the final URL avoids all of this. - Send test webhook in the portal reports only delivered (with the 2xx status), the endpoint answered with HTTP status N, not reachable, or rejected URL. It does not report timings or the kind of network error.
Every payload root carries schema_version (7), event and
verification_id. verification.completed adds customer_id, reference,
status, completed_at, customer_informed, eligibility_tags, metadata,
verification_type, status_plain and result_plain.
verification.decision adds customer_id, reference, decision,
created_at, decided_at, risk_flags, customer_informed,
eligibility_tags, metadata and verification_type. Decision-bearing
payloads also use confidence_band, summary, and reasons. summary has
the four branches identity, device_and_capture, address, and
property. The verification.opened, verification.consent_declined and
verification.capture_completed notices use the fields in their examples
below. verification.erased carries customer_id, metadata and
erased_at.
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 setting. A new account decides automatically, on automated or
automated with borderline review: the payload carries 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. On
automated with borderline review, a borderline result is Refer and is also
reviewed. A Refer or Fail from the evidence score alone, with no other
concern, carries evidence_below_pass_threshold. On an account set to
manual review of every result, it is always Refer until a review is
recorded; when no check raised a concern, reasons is
["manual_review_all_results"].
On those automated modes, an address check with no driving licence on file
cannot Pass: reasons includes licence_not_on_file and risk_flags
includes manual_review_required. The missing licence on its own makes the
result Refer, not Fail, and it is not an identity mismatch: the customer's
face was not compared. When the evidence score is also below the pass level,
or a fail rule applies, the result is Fail, with licence_not_on_file among
the reasons. A licence photo that is on file but whose face was not
compared is also Refer on its own, with licence_face_not_compared and the
same manual_review_required flag, and Fail only under the same conditions.
It is not an identity mismatch. A licence that was supplied but could not be
read is a different case and does not use either reason.
Every payload also carries the additive evidence_available_until field: an
ISO 8601 timestamp, or null. On webhook payloads it is null today; read
the evidence window's close time from GET /v1/verifications/:id (above),
or work it out as 7 days after the check finished. 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": "did_...",
"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.
If the customer takes every required capture but never taps Finish (for
example, they close the page on the last review screen), DoorID finishes the
check for them once there has been no capture activity for 30 minutes, and
processes it exactly as if they had tapped Finish at that moment.
verification.capture_completed is then sent at that point, so its
capture_completed_at is the time DoorID finished the check, and
verification.completed / verification.decision follow as normal. This only
happens when the last capture was taken in the past 7 days: a check whose last
capture is older than that is left open and expires as usual, as is a check
with any required capture missing.
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": "did_...",
"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": "did_...",
"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. A decline does not end a
verification; only older verifications carry a terminal consent_declined
status.
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 — the same order DoorID's own verifier uses. 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.
Texts and emails DoorID sends
Besides webhooks, DoorID sends these messages. None of them changes the check, its result or its webhooks. The wording of the customer's texts is DoorID's; it names your business and the check, carries the customer link, and is not configurable through the API.
To the customer
| Product | Message | When |
|---|---|---|
| Licence check | Text with the capture link | On create, when a phone is supplied and the account's first-text setting is on. POST /v1/licence-precapture/sessions/:id/send-sms sends it on request. |
| Delivery check | Text with the capture link | On create, when a phone is supplied and the account's first-text setting is on. POST /v1/dealership-check/sessions/:id/send-sms sends it on request. |
| Collection check | Text with the capture link | On create, when a phone is supplied and the account's first-text setting is on. POST /v1/dealership-check/sessions/:id/send-sms sends it on request. |
| Address check | Text with the customer link | On create, when a phone is supplied and the account's first-text setting is on. POST /v1/verifications/:id/resend-link sends it again. |
| All four | Reminder texts | 24, 48 and 72 hours after the check was created, while the customer has not finished, when the account's reminder setting for that product is on. |
What the texts say. {Client} is the name your customer knows you by: your
trading name (the portal's "Name your customer sees"), or your company name
when no trading name is set. {link} is the short link described below. The
same text is used for a resend. A Collection check and a Delivery check are
told apart by where the vehicle is handed over: a Delivery check (at the
customer's address) never mentions a dealership.
| Product | Text | First text | Reminder |
|---|---|---|---|
| Address check | Licence line | DoorID, for {Client}, needs you to confirm you're at your address. Have your driving licence with you: {link} | A reminder from DoorID for {Client}: your address check isn't finished. Have your driving licence with you: {link} |
| Address check | No licence line | DoorID, for {Client}, needs you to confirm you're at your address. Start here: {link} (doorid.ai) | A reminder from DoorID for {Client}: your address check isn't finished. Continue here: {link} (doorid.ai) |
| Collection check | Licence line | DoorID, for {Client}: your vehicle collection at the dealership. Have your driving licence with you: {link} | A reminder from DoorID for {Client}: your collection check isn't finished. Have your driving licence with you: {link} |
| Collection check | No licence line | DoorID, for {Client}: your vehicle collection at the dealership. Start here: {link} (doorid.ai) | A reminder from DoorID for {Client}: your collection check isn't finished. Continue here: {link} (doorid.ai) |
| Delivery check | Licence line | DoorID, for {Client}: your vehicle delivery at your address. Have your driving licence with you: {link} | A reminder from DoorID for {Client}: your delivery check isn't finished. Have your driving licence with you: {link} |
| Delivery check | No licence line | DoorID, for {Client}: your vehicle delivery at your address. Start here: {link} (doorid.ai) | A reminder from DoorID for {Client}: your delivery check isn't finished. Continue here: {link} (doorid.ai) |
| Licence check | Always | DoorID, for {Client}, needs a photo of your driving licence. Start here: {link} | A reminder from DoorID for {Client}: we need a photo of your driving licence: {link} |
Every text fits in one standard text message (160 characters, with the short
link). To keep it there, a long {Client} name is shortened at a word
break, or left out when there is no room for it. With no name known, the
texts read "DoorID needs you to confirm...", "DoorID: your vehicle
collection...", "A reminder from DoorID: ..." and so on. The texts have no
greeting.
The link in every customer text (first texts, resends and reminders, for all
four products) is a short link on the same host as the customer link, for
example https://verify.doorid.ai/c/7k2m9q4xw8bd3hfp: /c/ followed by 16
random characters. Opening it redirects (302) to exactly the customer link the
API returned for that check (customer_link / capture_link), so the customer
lands on the same page as before. It works for as long as that link does and
then shows a "not available" page. A new customer link for the same check gets
a new short link; one already sent keeps opening the link it was made for. The
short link appears only in texts: every API response, webhook and portal screen
still carries the full customer link, which keeps working as before.
When the check will ask the customer to photograph a driving licence, the first text (including a resend) and every reminder also tell the customer to have their driving licence with them (the "Licence line" rows above). The check asks for the licence:
- Licence check: always.
- Address check: when no current driving licence is on file for the customer
and the ID check is not turned off for that check (
skip_id_checkis not set). This is the same rule that decides whether the customer is asked for the photo. - Collection check and Delivery check: when no current driving licence is on file for the customer.
Otherwise the texts do not mention the licence.
The rules for reminder texts, the same for all four products:
- They stop when the check is no longer waiting for the customer: finished, cancelled, expired or declined.
- They stop when the same account has since sent the same person another
check that is still open or finished, or when the person has finished
another check from the same account since this one was sent. "The same
person" means the same
reference, or the same phone number when the two checks do not carry different references. - At most one reminder per phone number per account in any 12 hours. A reminder that falls inside that 12 hours is skipped and the next one in the series still runs.
- These rules never look across accounts: checks sent by another DoorID client never stop or skip your reminders.
- Reminders are only sent between 08:00 and 21:00 UK time; one that falls outside is sent at the next 08:00.
- No reminder is scheduled for after the link expires.
Every customer link (Licence check, Collection check and Address check) works for 744 hours (31 days) from creation, then expires. The reminders do not extend it.
DoorID sends the customer an email only when someone on your account sends the link by email from the client portal (the fallback when a text cannot arrive). The API does not send customer emails.
To your business
- Evidence pack email. When an Address check reaches its result, its evidence pack PDF is emailed to the account's report email recipients (set in the portal), once per recipient. A Licence check emails the same recipients a signed, time-limited link to its report, and a Collection or Delivery check emails them a link to the run's evidence pack. With no recipients set, nothing is sent.
- Portal notifications (only where DoorID has switched them on for the service). Each portal user chooses, in the portal, whether to get an email (with the report) and a text when a check finishes, an email when a text to a customer could not be delivered, and a 09:00 daily summary of checks nobody has opened after 24 hours. Texts follow the same 08:00 to 21:00 rule. Test checks send none of these.
Evidence
Evidence window
Evidence is served for 7 days after the check finishes — the evidence
window. It covers every evidence route for every product: the Address check
evidence pack (JSON and PDF), its stored assets and identity
images, the bulk export, the Collection check evidence pack (JSON, PDF and
regenerate) and its images, the Licence check evidence (PDF, front,
back and face image — the pdf, front, back and selfie routes, by
profile and by session), and the signed evidence links carried in webhooks
and emails.
The 7 days is how long you can download the evidence. It is not how long DoorID keeps the data: retention is set out under Errors, rate limits, and retention.
The signed evidence links carried in webhooks and emails (the Address check
/v1/public/evidence-pack/..., the Licence check /v1/public/licence-evidence/...
and the Collection and Delivery check /v1/public/dealership-evidence/..., the
evidence_pack_url on dealership_check.outcome) are absolute https URLs and
answer 200 application/pdf with Content-Disposition: inline and a filename,
so the pack opens in the browser or mail app and can be saved from there.
After the window, each of those routes answers 410 in the standard error envelope:
{
"error": {
"code": "EVIDENCE_WINDOW_CLOSED",
"message": "evidence retention window ended",
"request_id": "req_...",
"details": { "available_until": "2026-10-11T14:22:05.000Z" }
}
}
It is not transient: do not retry it. Download the evidence (or keep the PDF DoorID emails) as soon as the check finishes and file it with your own records — DoorID is not a store of record. The result, reasons and status stay readable through the status endpoints after the window closes.
"Finished" means: for an Address check, when it first reached a final
status (completed, failed, expired, cancelled, consent_declined);
for a Collection check run, when the customer finished the captures; for a
Licence check, when the licence was captured. A check that has not
finished has no window yet. A foreign or unknown id is still 404, never
410. GET /v1/verifications/:id reports the close time as
evidence_available_until.
The window controls access only. Removing the stored copies is a separate
retention process (see Errors, rate limits, and retention); a 410 EVIDENCE_WINDOW_CLOSED does not mean the data has been deleted.
Downloading the report, per product
Every product has one route that returns its report as a PDF inside the
window and 410 EVIDENCE_WINDOW_CLOSED after it, and one status or detail
response that says when the window closes:
| Product | Report PDF | When the window closes |
|---|---|---|
| Licence check (pre-capture) | GET /v1/licence-precapture/sessions/:id/evidence/pdf | evidence_available_until on each item of GET /v1/licence-precapture/sessions, and on GET /v1/licence-precapture/profiles/:profileId/status for the profile's current licence |
Collection check (dealership_check) | GET /v1/dealership-check/sessions/:id/evidence/pdf | evidence_available_until on (GET /v1/dealership-check/sessions/:id, in session) and on GET /v1/dealership-check/sessions/:id/outcome |
| Address check | GET /v1/verifications/:id/evidence?format=pdf | data.evidence_available_until on GET /v1/verifications/:id |
curl "$DOORID_BASE_URL/v1/dealership-check/sessions/$SESSION_ID/evidence/pdf" \
-H "Authorization: Bearer $DOORID_API_KEY" --output collect-report.pdf
curl "$DOORID_BASE_URL/v1/licence-precapture/sessions/$SESSION_ID/evidence/pdf" \
-H "Authorization: Bearer $DOORID_API_KEY" --output licence-report.pdf
evidence_available_until is an ISO 8601 timestamp, or null while the check
has not finished. It is the same time after which the PDF route answers 410.
The client portal shows the same date under each check's "Download report
(PDF)" button.
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": "GENERATING",
"retry_after": 30,
"message": "The evidence pack PDF is being generated. Retry after the indicated delay."
}
Read the Retry-After header (30 seconds) and retry the same request. The
PDF is normally rendered when the check finishes, so most requests get 200
straight away; 202 is returned when a finished check's PDF is not rendered
yet, and the render is queued by that first request. JSON is synchronously
self-healed when a cached pack is missing.
In order, a PDF request is answered:
| Situation | Response |
|---|---|
Malformed id (not did_ + 32 hex) | 400, body {"error": "Invalid verification ID"} (no code) |
| Unknown id, another client's id, or a check that has been deleted | 404 NOT_FOUND, standard envelope |
| The evidence window has closed | 410 EVIDENCE_WINDOW_CLOSED, standard envelope, details.available_until |
| A rendered PDF is held | 200 application/pdf |
| Not finished yet | 409 NOT_READY (resolver shape) |
| Retention has removed the captures and no rendered PDF is held | 410 PURGED (resolver shape) |
| Finished, PDF not rendered yet | 202 GENERATING with Retry-After: 30, then 200 |
| A finished check with no stored result to render | 404 NO_EVIDENCE (resolver shape) |
The window check comes first, so after the window every request is 410 EVIDENCE_WINDOW_CLOSED, whatever else is true. Inside the window a PDF that
was rendered before retention removed the captures is still served.
Three refusals on this route come from the evidence-pack resolver and use its
own shape, {"error": "<message>", "code": "..."} with code at the body
root, not the standard error.code envelope:
| HTTP | code | Meaning |
|---|---|---|
| 409 | NOT_READY | The verification is not completed or failed yet. The body also carries status (the current verification status). Poll the detail route or wait for the webhook. |
| 410 | PURGED | The evidence was removed under the retention policy, or on an erasure request (an erased check never returns its PDF or pack again). Do not retry. |
| 404 | NO_EVIDENCE | A terminal verification has no stored evidence to render. |
An unknown or another tenant's verification id is still 404 NOT_FOUND in the
standard envelope.
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, 410 EVIDENCE_WINDOW_CLOSED once the evidence
window has passed, and 410 asset_purged with details.purged_at after
retention removes it.
The bytes are unchanged. The response headers are fixed by type: JPEG, PNG and
WebP images are served Content-Disposition: inline; video (video/webm,
video/mp4, video/quicktime) and any other type are served
Content-Disposition: attachment, and a stored type outside that list is
served as application/octet-stream. Every asset response also carries
X-Content-Type-Options: nosniff and
Content-Security-Policy: default-src 'none'; sandbox. The same headers apply
to the Collection check per-image routes below.
GET /v1/verifications/:id/evidence/image/:role
The labelled identity-image roles are front_licence, back_licence, and
selfie (the still of the customer's face taken at the door). 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, 410 EVIDENCE_WINDOW_CLOSED once the evidence window has passed, 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. DoorID restarts the render 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 Collection check 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.
Collection check (API name: dealership_check) results and evidence
A Collection check (the product's name on doorid.ai and in the
portal) is one run. 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 Collection check run is not an Address check. 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),
collection_place (home_delivery for a delivery at the customer's address;
omit it for a forecourt collection; 400 INVALID_COLLECTION_PLACE otherwise).
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": "cprof_...",
"dealership_check_session_id": "dchk_...",
"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 Collection check
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": "dchk_...",
"customer_profile_id": "cprof_...",
"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": "dchk_...",
"client_id": "cli_...",
"customer_profile_id": "cprof_...",
"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/dchk_.../captures/building" },
{ "role": "car_front", "label": "Car front", "present": true, "mime_type": "video/mp4", "media_kind": "video", "url": "/v1/dealership-check/sessions/dchk_.../captures/car_front" },
{ "role": "selfie", "label": "Face", "present": true, "mime_type": "video/mp4", "media_kind": "video", "url": "/v1/dealership-check/sessions/dchk_.../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/dchk_.../plate-frame",
"evidence_available_until": "2026-09-23T11:04:00.000Z"
}
}
evidence_available_until is when this run's evidence window closes (7 days
after captures_completed_at), after which the evidence routes answer
410 EVIDENCE_WINDOW_CLOSED; null until the customer has finished.
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
Collection check 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.
Collection check (API name: dealership_check) webhooks
When a callback URL is configured, a Collection check 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": "dchk_...",
"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.
When a live run's one outcome is decided (automatic or the reviewer's), the
client's evidence-pack recipients (the portal's "Report emails" list) are also
emailed a signed, time-limited link to the run's evidence pack — once per run
per address, whether or not a webhook is configured. The email names the
product (a Delivery check when collection_place is home_delivery, otherwise
a Collection check) and shows your reference, or the run's session_id when
the reference is empty or a placeholder such as "none". The subject never
carries the customer's name.
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": "dchk_...",
"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": "dchk_...",
"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": "dchk_...",
"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,
"evidence_available_until": "2026-09-21T12:03:00.000Z"
}
Every outcome response, processing or final, also carries
evidence_available_until: when the run's evidence window closes, or null
for a run the customer has not finished.
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
The evidence window applies (7 days from when the customer finished the
captures): this route, GET .../evidence, POST .../evidence/regenerate,
GET .../plate-frame, GET .../captures/:role and GET .../face-match/:role
answer 410 EVIDENCE_WINDOW_CLOSED after it. GET .../outcome and the JSON
per-check reads stay readable.
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 comparison of the customer's face, taken from the video they recorded at
collection, with the licence photo. 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 Collection check (API name: dealership_check) 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 Collection check run: a Collection check 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": "dchk_...", "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 check (API name: licence pre-capture)
POST /v1/licences/upload
Uploads a client-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 client-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 check.
- 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 is reused. The window is 60 days (60 x 24 hours) from the
licence capture, and never longer for any account; an older licence is
reported as
outside_freshness_window and the applicant captures it again. 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. |
Separately, when an address check runs and on-file licence reuse is on for
your account (the default), DoorID uses an active licence already on file for
the same reference under your account for the face comparison, whatever the
identity mode (including skip and customer_capture), unless
the check's licence was already settled when it was created (a
licence_token you sent, or a licence reused as above).
Checks sent from the client portal
This applies only to checks a client sends by hand from the portal's Send a
check page (/portal/create). Nothing above changes for the API: a
lender_supplied account still gets 400 licence_token_required on
POST /v1/verifications when nothing usable is on file, and the ID-check
entitlement still applies to API creates.
The portal's four products are independent, and every account can send each of them on its own:
- Address check follows the same licence rule as an API-created check:
when no current licence is on file for that applicant, they photograph the
front and the back during the check; a current licence on file is reused
and they are not asked again; with the identity check off for the account
they are not asked. The portal has no licence setting of its own, and it
never refuses the send for a missing licence, whatever the account's
identity setting or ID-check entitlement (on a
lender_suppliedaccount with nothing on file, where the API returns400 licence_token_required, the portal sends the check and the applicant is asked for the licence). - Licence check is sent on its own. The portal form asks for a phone
number (the API does not require one), fills in the consent record itself
(the API requires
consent), and is not refused with403 PRODUCT_NOT_ENABLED. - Delivery check uses the delivery address entered on the form (where the vehicle is delivered) for the location check. The form requires a phone number, the registration plate and that address. The licence step is the Collection check's rule: a current licence on file is reused; otherwise the customer photographs the front and the back at the start of the journey.
- Collection check uses the dealership address entered on the form (where the car is collected) for the location check. The form requires a phone number, the registration plate and that address, and does not ask for the customer's own address. The licence step is the same rule as a forecourt Collect run created through the API: a current licence on file is reused; otherwise the customer photographs the front and the back at the start of the Collect journey. A home delivery sent from the portal follows the same rule.
A licence photographed inside an Address or Collect journey is part of that check's charge, never a separate licence charge.
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, date_of_birth and callback_url (see
Webhook URL requirements). The response is 201:
{
"ok": true,
"customer_profile_id": "cprof_...",
"precapture_session_id": "pcs_...",
"capture_link": "https://www.doorid.ai/lpc/plc_...",
"expires_at": "...",
"consent_logged": true,
"sms_queued": false,
"callback_url": null
}
callback_url echoes the session's own callback URL, or null when none was
supplied.
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 Licence check link is valid for 744 hours (31 days), the same window as a verification link and a Collection check capture link. This is not the client-supplied licence upload token above, which is a different 24-hour clock.
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. See Free checks and the card requirement
under Errors. There is no monthly limit on driving-licence checks:
402 LICENCE_CAP_REACHED is retired and is no longer returned.
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. Each item also carries evidence_available_until: when the
session's evidence window closes (7 days after the licence was captured),
or null before capture.
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. The Collect
check 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 check 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": "pcs_...", "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, evidence_available_until}.evidence_available_untilis when the current licence's evidence window closes, ornullbefore capture.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 window applies: 7 days from when the licence was captured
(for the session routes, when that session captured it), after which these
routes answer 410 EVIDENCE_WINDOW_CLOSED.
A standalone licence check has no DoorID result. No Licence check
response or webhook carries a Pass / Refer / Fail, decision or verdict field:
status is the session's lifecycle (captured means the licence was taken),
and the client reviews the licence itself. The client portal shows a captured
licence check as "Licence captured - for your review", and its report as
"Download licence report (PDF)".
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 (every one is returned by a /v1 route today,
and a CI test fails if a public route returns a code missing from this
table):
| HTTP | Codes |
|---|---|
| 400 | MISSING_FIELD, invalid_phone, INVALID_PHONE, licence_token_required, licence_token_invalid, licence_token_not_accepted, inline_licence_photo_removed, test_licence_invalid, MISSING_DEALERSHIP_ADDRESS, INVALID_DEALERSHIP_ADDRESS, MISSING_EXPECTED_PLATE, 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, INVALID_LICENCE_IMAGE, 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_PHONE, MISSING_ADDRESS, MISSING_CONSENT, CONFLICTING_FIELDS, LICENCE_TOKEN_INVALID, INVALID_DATE_RANGE, TOO_MANY_RESULTS |
| 401 | MISSING_API_KEY, INVALID_API_KEY_FORMAT, INVALID_API_KEY |
| 402 | TRIAL_LIMIT_REACHED, CARD_REQUIRED, CARD_PAYMENT_FAILED (LICENCE_CAP_REACHED is retired and no longer returned) |
| 403 | CLIENT_ARCHIVED, CLIENT_SUSPENDED (the account is paused; nothing is created), PRODUCT_NOT_ENABLED (a licence step, or a Licence check, was asked for on an account without the ID check), EVIDENCE_ACCESS_DENIED, ADMIN_AUTH_REQUIRED (a route for DoorID staff only), 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, CAPTURE_NOT_FOUND, PLATE_FRAME_UNAVAILABLE, FACE_IMAGE_NOT_FOUND, FACE_IMAGE_UNAVAILABLE, catch_hook_not_found, NO_EVIDENCE (evidence pack, resolver shape) |
| 409 | IDEMPOTENT_REQUEST_IN_PROGRESS, VERIFICATION_NOT_OPEN, SMS_OPTED_OUT, INVALID_STATUS_TRANSITION, licence_token_already_used, LICENCE_TOKEN_ALREADY_USED, NOT_READY (evidence pack, resolver shape) |
| 410 | EVIDENCE_WINDOW_CLOSED, asset_purged, legacy_endpoint_removed, licence_token_expired, LICENCE_TOKEN_EXPIRED, PURGED (evidence pack, resolver shape) |
| 415 | CAPTURE_MEDIA_UNSUPPORTED |
| 422 | ADDRESS_STREET_MISSING, ADDRESS_TYPE_NOT_SUPPORTED, IDEMPOTENCY_KEY_REUSED |
| 429 | RATE_LIMIT_EXCEEDED |
| 500 | INTERNAL_SERVER_ERROR, INTERNAL_ERROR, PACK_RENDER_FAILED, PDF_RENDER_FAILED, EXPORT_FAILED, LICENCE_IMAGE_FAILED, CAPTURE_READ_FAILED, FACE_COMPARISON_ERROR, PLATE_FRAME_ERROR, test_licence_unavailable, slug_missing |
| 503 | AUTH_SERVICE_UNAVAILABLE, PACK_RENDER_QUARANTINED, PDF_RENDER_TIMEOUT, PDF_RENDER_BUSY |
A 5xx is DoorID's fault, not the request's: retry with backoff, except
PACK_RENDER_QUARANTINED (below). Two responses do not use the envelope: the
evidence-pack resolver's {"error", "code"} shape (marked above) and the
evidence route's 400 {"error": "Invalid verification ID"}.
Free checks and the card requirement
The same two refusals apply to every create, whatever the product (Licence check, Collection check, Address check) and whether it is sent through this API or from the portal:
402 TRIAL_LIMIT_REACHED— a trial account has used its free checks. The message says where in the portal to continue.402 CARD_REQUIRED— the account is on the self-serve Pay as you go plan, has used its free checks, and has no valid card on file. It is never returned to an account on an agreed plan, and never to a plan charged per check (invoiced for every check it sends). It is returned whether or not a card can be added at that moment: after the free checks a card is required.402 CARD_PAYMENT_FAILED— the account's last automatic card payment did not go through, the invoice is still unpaid, and 14 days have passed since the first failed attempt (Terms 5.2). Same reach asCARD_REQUIRED. The account is emailed and shown a portal notice when the payment fails, naming the day it will stop.
Neither refusal creates anything, and a retry after the account has added a card (Usage & billing in the portal) succeeds.
Account standing
The account's standing is checked on every /v1
request made with an API key, after the key itself is accepted. A refused
request returns the documented error envelope with one of:
| HTTP | Code | When | Reads (GET, HEAD) |
|---|---|---|---|
| 403 | CLIENT_ARCHIVED | The account is closed. | refused |
| 403 | CLIENT_SUSPENDED | DoorID has paused the account. Contact DoorID. | refused |
| 402 | TRIAL_LIMIT_REACHED | A self-serve account has used its free customers and has no card on file. Add a card and press Keep going in the portal. | allowed |
| 402 | CARD_REQUIRED | A self-serve Pay as you go account has no valid card on file. | allowed |
| 402 | CARD_PAYMENT_FAILED | A failed card payment is still unpaid 14 days after the first failed attempt. | allowed |
For the three 402 codes, results and reports already produced stay
available, and checks already sent keep running; every other request (any
POST, PUT, PATCH or DELETE) is refused. The same conditions stop
sending from the portal. Webhooks for checks already sent are still
delivered. The standing is re-read at most every 60
seconds per server, so a fix (a card added, an invoice paid) takes effect
within a minute; a pause or a closure takes effect at once. Accounts on an
agreed plan are reached only by CLIENT_ARCHIVED and CLIENT_SUSPENDED.
Before the card refusals start, the account is warned by email and by a portal notice: when its free customers are nearly used, 30 and 7 days before a card on file expires, and when a card payment fails (naming the day new checks stop).
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 when DoorID restarts the render — 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, per
key, on every route.
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.
Test keys are a sandbox
A test key cannot make anything real happen. This applies to everything the key starts: the create call, the customer link it returns (also when that link is opened on a real phone), the analysis, the reminders and the evidence report.
- No text message or email is sent to anyone. Open the
customer_linkfrom the create response yourself. - No outside service is used. Face match, document reading, imagery, maps, geocoding, address lookup and building data come from built-in test providers. Any other outside call is refused.
- The evidence PDF for a test check is always the fixed example report, which shows a made-up applicant. It is never built from what was captured.
- A test check is never billed. It never counts towards free checks. It never reads or matches your live customers' data, such as licences on file or cross-check matches.
- Every response to a test-key request has the header
X-DoorID-Test-Mode: true. Response bodies and webhook payloads have the same shape as for a live key, and webhooks are still sent to yourcallback_url. - A test result comes from the test providers. Do not use it for anything except testing your integration. You cannot yet ask for a particular result.
The portal's "Send a test to my own phone" button is not a test-key check. It runs the real journey and the real providers, and it texts your own verified phone.
Evidence is served for 7 days after each check finishes (the evidence
window, under Evidence); after it every evidence route returns 410 EVIDENCE_WINDOW_CLOSED ("evidence retention window ended"). The window
controls access only: identifying data is de-identified separately, under the
account's retention configuration below. A purged asset returns 410 asset_purged. Neither 410 is a transient HTTP error.
The full retention lifecycle, per the current Data Processing Agreement (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.
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. The Postman collection
has one folder per product (Licence check, Collection check, Address check), each
with the start, result, report and resend requests and an example signed
webhook body, and the rest of each product's requests in a More requests
folder inside it. Set the collection variables base_url and api_key (a
test key) and run the folders in order.