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

ProductWhat it checksHow to start itWhat you get back
Address checkThe 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-checksPass, Refer or Fail with reasons, a webhook for each step, and a PDF report.
Collection checkAt 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-checksA verdict (approved, referred or failed) from the plate, face and location checks, a webhook for each step, and a PDF report.
Delivery checkAt 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-checksA verdict (approved, referred or failed) from the plate, face and location checks, a webhook for each step, and a PDF report.
Licence checkThe 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-checksThe 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> (or X-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-Key header (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_url when you start a Collection check or an Address check. It must be https and 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:

bash
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)

bash
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:

json
{
  "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:

json
{
  "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)

bash
curl "$DOORID_BASE_URL/v1/address-checks/$CHECK_ID" \
  -H "Authorization: Bearer $DOORID_API_KEY"

Response 200 (trimmed):

json
{
  "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)

bash
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)

bash
curl -X POST "$DOORID_BASE_URL/v1/address-checks/$CHECK_ID/resend" \
  -H "Authorization: Bearer $DOORID_API_KEY"

Response 200:

json
{
  "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)

bash
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:

json
{
  "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)

bash
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.

json
{
  "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)

bash
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)

bash
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)

bash
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:

json
{
  "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)

bash
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)

bash
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)

bash
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)

bash
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:

json
{
  "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)

bash
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):

json
{
  "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)

bash
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)

bash
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.

ProductStartStatus / resultReportResend
Address checkPOST /v1/verificationsGET /v1/verifications/:idGET /v1/verifications/:id/evidence?format=pdfPOST /v1/verifications/:id/resend-link
Collection checkPOST /v1/dealership-check/sessionsGET /v1/dealership-check/sessions/:idGET /v1/dealership-check/sessions/:id/evidence/pdfPOST /v1/dealership-check/sessions/:id/send-sms
Licence checkPOST /v1/licence-precapture/sessionsGET /v1/licence-precapture/sessionsGET /v1/licence-precapture/sessions/:id/evidence/pdfPOST /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:

ProductWebhook events, in the order they can arrive
Address checkverification.opened, verification.consent_declined, verification.capture_completed, verification.completed, verification.decision (only when Share decision events is on), verification.erased
Collection checkdealership_check.opened, dealership_check.consent_declined, dealership_check.completed, dealership_check.outcome, dealership_check.processing_failed
Delivery checkdealership_check.opened, dealership_check.consent_declined, dealership_check.completed, dealership_check.outcome, dealership_check.processing_failed
Licence checklicence_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:

json
{
  "success": true,
  "data": {
    "name": "DoorID API",
    "version": "v1",
    "endpoints": {
      "verifications": "/v1/verifications",
      "address_assessment": "/v1/addresses/assess",
      "batch_assessment": "/v1/addresses/assess/batch",
      "webhook_deliveries": "/v1/webhooks/deliveries",
      "licence_upload": "/v1/licences/upload",
      "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:

http
Authorization: Bearer doorid_test_xxxxxxxxxxxx

or:

http
X-API-Key: doorid_test_xxxxxxxxxxxx

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

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 /health
  • GET /healthz
  • GET /v1/health

They return:

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

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

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

json
{
  "ready": true,
  "checks": {
    "database":   { "state": "ok" },
    "migrations": { "state": "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}):

json
{
  "verification_id": "did_3f2a4c91e8b7d0f2...",
  "customer_link": "https://www.doorid.ai/v/Ab3dEf4gHi5jK6mN7pQ8rS9tU0w",
  "slug": "Ab3dEf4gHi5jK6mN7pQ8rS9tU0w",
  "status": "pending",
  "expires_at": "2026-10-07T12:00:00.000Z",
  "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:

json
{
  "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):

FieldRequiredNotes
dealership_addressyesPlain object: the address of the collection site. 400 MISSING_DEALERSHIP_ADDRESS when missing or empty.
expected_registration_plateyesThe plate the customer should be collecting. 400 MISSING_EXPECTED_PLATE when missing.
expected_make, expected_colournoShown in the report; never used to fail a run.
dealership_namenoShown to the customer and in the report.
prior_verification_referencenoThe reference of an earlier Address check with this client whose licence photo should be used for the face comparison, when it differs from reference.
licence_tokennoA token from POST /v1/licences/upload, stored as the customer's on-file licence for the face comparison. Same three failures as elsewhere: 400 licence_token_invalid, 409 licence_token_already_used, 410 licence_token_expired.
consentnoStored verbatim on the customer profile when supplied; never fabricated. The response reports consent_logged.
new_runnotrue starts another run for the same customer and plate; see Ending a Collection check run.
callback_urlnoPer-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.
dobnoThe 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:

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

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

data.evidence_available_until is an 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:

sh
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 (only completed/failed verifications ever do; reuses the same self-healing render path as GET /:id/evidence).
  • results.csv — one row per matching verification, including ones with no pack (still processing, cancelled, expired, declined), with columns verification_id,reference,client_id,status,decision,created_at,updated_at,evidence_included,exclusion_reason so the CSV always reconciles against what's bundled in the ZIP. A pack whose 7-day evidence window has passed is listed with exclusion_reason evidence_window_closed and 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:

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

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

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:

json
{
  "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:

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

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

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

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 with 400 INVALID_CALLBACK_URL (the portal webhook settings page answers 400 VALIDATION with a webhook_url detail, as it already does for a non-https URL). On POST /v1/verifications without dealership_* fields, this is the only callback_url check: a value that is not a parseable http(s) URL is stored as before and simply never delivers. A plain http callback_url is still accepted for now and logged as deprecated; it will be refused with 400 INVALID_CALLBACK_URL once 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 from https to http is not followed. As before, 307 and 308 repeat the POST with the same body and headers, while 301, 302 and 303 turn 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:

json
{
  "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:

json
{
  "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:

json
{
  "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.

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

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

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

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

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

Python receiver

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

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

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

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

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

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

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

Retry schedule

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

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

ProductMessageWhen
Licence checkText with the capture linkOn 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 checkText with the capture linkOn 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 checkText with the capture linkOn 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 checkText with the customer linkOn 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 fourReminder texts24, 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.

ProductTextFirst textReminder
Address checkLicence lineDoorID, 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 checkNo licence lineDoorID, 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 checkLicence lineDoorID, 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 checkNo licence lineDoorID, 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 checkLicence lineDoorID, 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 checkNo licence lineDoorID, 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 checkAlwaysDoorID, 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_check is 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:

json
{
  "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:

ProductReport PDFWhen the window closes
Licence check (pre-capture)GET /v1/licence-precapture/sessions/:id/evidence/pdfevidence_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/pdfevidence_available_until on (GET /v1/dealership-check/sessions/:id, in session) and on GET /v1/dealership-check/sessions/:id/outcome
Address checkGET /v1/verifications/:id/evidence?format=pdfdata.evidence_available_until on GET /v1/verifications/:id
bash
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.

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

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

json
{
  "status": "generating",
  "code": "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:

SituationResponse
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 deleted404 NOT_FOUND, standard envelope
The evidence window has closed410 EVIDENCE_WINDOW_CLOSED, standard envelope, details.available_until
A rendered PDF is held200 application/pdf
Not finished yet409 NOT_READY (resolver shape)
Retention has removed the captures and no rendered PDF is held410 PURGED (resolver shape)
Finished, PDF not rendered yet202 GENERATING with Retry-After: 30, then 200
A finished check with no stored result to render404 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:

HTTPcodeMeaning
409NOT_READYThe 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.
410PURGEDThe 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.
404NO_EVIDENCEA 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-After header, 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:

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

POST /v1/dealership-check/sessions

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

Required: reference (400 MISSING_REFERENCE); full_name, or both first_name and last_name (400 MISSING_NAME); address as a plain object (400 MISSING_ADDRESS); consent as a plain object, stored verbatim (400 MISSING_CONSENT). Optional: phone (E.164 or a UK number; 400 INVALID_PHONE when unparseable; with no phone, no text is sent and no reminders are scheduled), date_of_birth, expected_registration_plate, expected_colour, expected_make, dealership_address (plain object; 400 INVALID_DEALERSHIP_ADDRESS otherwise), dealership_name, prior_verification_reference, callback_url (400 INVALID_CALLBACK_URL), new_run, licence_token (same three failures as above), 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:

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:

json
{
  "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:

json
{
  "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:

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

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

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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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: true create for the same customer and expected registration plate replaces the active run. An ordinary repeat create without that flag reuses the existing active session instead of starting a second one. The replaced run's link stops working at once, and the run reports run_status: "expired".
  • Cancelled — you cancel it with the endpoint below.

The verification cancel route does not apply to a 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:

json
{ "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:

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

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

Automatic reuse of an on-file licence

When your account's identity mode expects a 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:

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

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

Only a licence with a retrievable front image inside your account's licence freshness window 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:

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

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_supplied account with nothing on file, where the API returns 400 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 with 403 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:

json
{
  "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:

json
{ "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/status returns 200 {ok, profile_id, reference, full_name, status, on_file_licence, evidence_available, evidence_available_until}. evidence_available_until is when the current licence's evidence window closes, or null before capture.
  • GET /v1/licence-precapture/profiles/:profileId/evidence/pdf
  • GET /v1/licence-precapture/profiles/:profileId/evidence/front
  • GET /v1/licence-precapture/profiles/:profileId/evidence/back
  • GET /v1/licence-precapture/profiles/:profileId/evidence/selfie
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/pdf
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/front
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/back
  • GET /v1/licence-precapture/sessions/:sessionId/evidence/selfie

The evidence 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 adds status: "captured", completed_at, and a signed evidence_url for pulling the capture evidence, in place of opened_at's role. It also carries evidence_expected_by (ten minutes after completed_at) and status_message: "The customer has completed the licence capture. The evidence pack will be ready within 10 minutes." No verdict or outcome event follows a licence capture.
  • licence_capture.consent_declined — fired once, when the customer declines on the consent screen. Same payload as licence_capture.opened, with status: "consent_declined", declined_at, expires_at and a status_message. Not final: the link keeps working and reopening it shows the consent screen again, so licence_capture.completed can still follow.

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

Address assessment

GET /v1/addresses/assess

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

POST /v1/addresses/assess/batch

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

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

Errors, rate limits, and retention

Errors use:

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

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

Documented runtime codes are (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):

HTTPCodes
400MISSING_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
401MISSING_API_KEY, INVALID_API_KEY_FORMAT, INVALID_API_KEY
402TRIAL_LIMIT_REACHED, CARD_REQUIRED, CARD_PAYMENT_FAILED (LICENCE_CAP_REACHED is retired and no longer returned)
403CLIENT_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
404NOT_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)
409IDEMPOTENT_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)
410EVIDENCE_WINDOW_CLOSED, asset_purged, legacy_endpoint_removed, licence_token_expired, LICENCE_TOKEN_EXPIRED, PURGED (evidence pack, resolver shape)
415CAPTURE_MEDIA_UNSUPPORTED
422ADDRESS_STREET_MISSING, ADDRESS_TYPE_NOT_SUPPORTED, IDEMPOTENCY_KEY_REUSED
429RATE_LIMIT_EXCEEDED
500INTERNAL_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
503AUTH_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 as CARD_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:

HTTPCodeWhenReads (GET, HEAD)
403CLIENT_ARCHIVEDThe account is closed.refused
403CLIENT_SUSPENDEDDoorID has paused the account. Contact DoorID.refused
402TRIAL_LIMIT_REACHEDA 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
402CARD_REQUIREDA self-serve Pay as you go account has no valid card on file.allowed
402CARD_PAYMENT_FAILEDA 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_link from 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 your callback_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.