DoorID API Documentation
Complete reference for the DoorID address verification REST API. Every section follows the same structure: what an endpoint does and when to call it → a ready-to-run curl example → the exact JSON response → a callout explaining what to do next → error cases specific to that endpoint.
Getting Started
- Get your API keys — Sign in to your client portal. Go to API Keys to generate a test key. The raw key is shown once at generation; copy it now. DoorID stores only a prefix + SHA-256 hash — neither DoorID nor the portal can reveal it again. If it is lost, rotate to get a new one.
- Choose your journey mode — Decide whether customers need to verify a property only, provide their driving licence first, or complete both in sequence. See Choosing a Journey Mode.
- Create a session — Call the appropriate creation endpoint and receive a link for your customer.
- Send the link — Forward the link (via SMS, email, or your app) to the customer. They open it on their phone to complete the capture flow.
- Receive webhooks — DoorID fires signed events when the customer finishes capture and when a verdict is reached.
- Fetch the evidence pack — After a terminal decision, download the PDF certificate and JSON bundle.
Base URL
All API endpoints are served from a single shared URL:
https://www.doorid.ai/v1/...
All clients use the same base URL — your API key identifies your account. There are no per-company subdomains.
Field Naming Convention
All request and response fields use snake_case. URL query parameters use snake_case.
Authentication
Getting Your API Keys
Sign in to the portal, go to API Keys, and generate a test key. The key is shown once — never again. Copy it now.
Who can manage keys:
| Portal Role | Can generate / rotate / revoke keys? |
|---|---|
| Owner | Yes |
| Developer | Yes |
| Admin | No (can use keys for requests, cannot manage them) |
| Reviewer | No |
| Billing | No |
| Viewer | No |
All roles can use keys to make API requests. Only Owner and Developer can generate or rotate them.
Bearer Token Format
Every request requires a Bearer token in the Authorization header:
Authorization: Bearer doorid_test_xxxxxxxxxxxx
Health check example — a working request you can run immediately (no auth required):
curl https://www.doorid.ai/health
Response:
{
"status": "ok",
"version": "1.0.0",
"environment": "test",
"testMode": true,
"timestamp": "2026-08-01T10:00:00.000Z",
"providers": {
"vision": "mock",
"imagery": "mock"
}
}
When the database is unavailable, the HTTP status is 503 and "status" is "error".
Note: The health endpoint is at
/health, not/v1/health. It requires no API key.
Test Keys vs Live Keys
Test key (doorid_test_…) | Live key (doorid_live_…) | |
|---|---|---|
| Calls AI providers (Rekognition, OCR) | Never | Yes |
| Consumes credits | Never | Yes |
| Returns deterministic responses | Yes | No |
| Customer links are real | No (test only) | Yes |
| Safe to use freely | Yes | No — guard carefully |
Use test keys for all integration work. Switch to a live key only when going live.
Rate Limits
Per API key, rolling-window limits:
Test keys (doorid_test_…)
| Window | Limit |
|---|---|
| 1 second (burst) | 5 requests |
| 1 minute | 30 requests |
| 24 hours | 1,000 requests |
| Concurrent verifications in non-terminal state | 20 |
Live keys / standard tier (doorid_live_…)
| Window | Limit |
|---|---|
| 1 second (burst) | 10 requests |
| 1 minute | 60 requests |
| 24 hours | 5,000 requests |
| Concurrent verifications in non-terminal state | 500 |
When you exceed a limit, DoorID returns HTTP 429 with a Retry-After header:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests — per-minute limit exceeded. Retry in 12s.",
"request_id": "req_8f2a4c91..."
}
}
Read the Retry-After response header and back off for that many seconds before retrying. The number of seconds is only in the header — not in the JSON body. Do not retry immediately.
Key Rotation (Zero-Downtime)
- Generate a new key in the portal (Owner or Developer role required).
- Update your system config to use the new key.
- Run a test request to confirm the new key works.
- Delete the old key from the portal — it stops working immediately.
Requests in flight at the moment of deletion will fail. Complete any in-flight operations before deleting.
Security Model
Keys are stored as a 3-segment prefix + SHA-256 hash only. DoorID cannot recover or display a key after creation — neither can the portal. If a key is lost, generate a new one and delete the old one. Never share keys across clients or environments.
Choosing a Journey Mode
Three modes are available. Pass mode on the creation request to select the right flow for your use case.
| Mode | What it does | Returns | Use when |
|---|---|---|---|
property_only | Property verification only. No licence required. | customer_link | Identity has already been verified through another channel. |
full_journey | Licence collection + property verification linked. Customer completes both in one session. Pre-captured licence auto-used for face match. | customer_link | You want a single, end-to-end verified record. |
licence_only | Collects the customer's driving licence up front, no property verification yet. | capture_link | Building an identity file before the property stage; property check comes later. |
mode defaults to property_only when omitted — all existing calls that do not send mode continue to work unchanged.
Which endpoint to call
property_onlyorfull_journey→POST /v1/verificationslicence_only→POST /v1/licence-precapture/sessions
Creating a Session
Property-only and full-journey: POST /v1/verifications
Create a new verification session for a customer. Returns a customer_link to send to the customer — they open it on their phone to complete the capture flow.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
first_name | string | yes | Customer's first name |
last_name | string | yes | Customer's last name |
phone | string | yes | Customer's phone number (E.164 preferred, e.g. +447700900123) |
reference | string | yes | Your own reference (e.g. application or loan number). Shown on the dashboard and evidence pack. |
mode | string | no | Journey mode: property_only (default), full_journey. Omitting is identical to property_only. |
dob | string | no | Date of birth (YYYY-MM-DD). Optional metadata. |
address.line_1 | string | yes | First line of address |
address.line_2 | string | no | Second line of address |
address.city | string | yes | City or suburb |
address.region | string | no | County / state / region |
address.postcode | string | no | Postcode or ZIP code |
address.country | string | no | ISO 3166-1 alpha-2 country code. Defaults to GB if omitted. |
customer_id | string | no | Your own identifier for this customer. Echoed back. |
callback_url | string | no | HTTPS URL to receive signed webhook events. Falls back to the client's default if omitted. |
licence_token | string | conditional | Required when client policy is lender_supplied identity mode. From POST /v1/licences/upload. |
eligibility | object | no | Eligibility metadata. See below. |
metadata | object | no | Free-form key/value metadata. Returned in webhook payloads. |
force_manual_review | boolean | no | Force into the manual review queue regardless of pipeline confidence. |
Eligibility object
DoorID does not reject based on eligibility — it tags the verification. These values appear in the audit trail and dashboard for your reporting.
| Field | Type | Description |
|---|---|---|
eligibility.declared_property_type | string | house / flat / apartment / bungalow / other / unknown |
eligibility.declared_urban_rural | string | urban / suburban / rural / unknown |
eligibility.loan_amount_minor_units | integer | Loan amount in smallest currency unit (pence for GBP). E.g. £8,500.00 = 850000 |
eligibility.loan_currency | string | ISO 4217 code (e.g. GBP) |
eligibility.loan_purpose | string | Free-form, e.g. vehicle_finance |
curl example — property_only (default)
curl -X POST https://www.doorid.ai/v1/verifications \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Idempotency-Key: 7c3f2a8b-9e4d-4a12-bc8f-d293e761f9a2" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Sarah",
"last_name": "Johnson",
"phone": "+447700900123",
"reference": "APP-44218",
"address": {
"line_1": "14 Beach Road",
"city": "Bournemouth",
"postcode": "BH1 1AA",
"country": "GB"
},
"callback_url": "https://your-app.example.com/webhooks/doorid",
"customer_id": "customer-44218",
"eligibility": {
"declared_property_type": "house",
"declared_urban_rural": "suburban",
"loan_amount_minor_units": 850000,
"loan_currency": "GBP",
"loan_purpose": "vehicle_finance"
}
}'
Response (201 Created)
{
"verification_id": "did_3f2a4c91e8b7d0f2...",
"customer_link": "https://www.doorid.ai/v/did_3f2a4c91e8b7d0f2...?t=...",
"slug": "did_3f2a4c91e8b7d0f2...",
"status": "pending",
"expires_at": "2026-08-02T12:00:00.000Z",
"eligibility_tags": ["house", "suburban"]
}
Send
customer_linkto your customer. They open it on their phone and complete the capture flow. The link is valid for 72 hours (configurable per client). If they need a fresh link, callPOST /v1/verifications/:id/resend-link.
curl example — full_journey
curl -X POST https://www.doorid.ai/v1/verifications \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Idempotency-Key: 9a1b2c3d-4e5f-6789-abcd-ef0123456789" \
-H "Content-Type: application/json" \
-d '{
"mode": "full_journey",
"first_name": "James",
"last_name": "Patel",
"phone": "+447700900456",
"reference": "APP-55301",
"address": {
"line_1": "Flat 4, 22 Queens Street",
"city": "Manchester",
"postcode": "M1 1AE",
"country": "GB"
},
"callback_url": "https://your-app.example.com/webhooks/doorid"
}'
Response (201 Created)
{
"verification_id": "did_9b3c1d72f4a8e0c1...",
"customer_link": "https://www.doorid.ai/v/did_9b3c1d72f4a8e0c1...?t=...",
"slug": "did_9b3c1d72f4a8e0c1...",
"status": "pending",
"expires_at": "2026-08-02T15:00:00.000Z",
"eligibility_tags": ["flat", "urban"]
}
In
full_journeymode the customer photographs their licence as the first step of the flow. The licence is then linked automatically to the property verification for face match and name check.
Errors specific to this endpoint
| HTTP | Code | Cause |
|---|---|---|
| 400 | MISSING_FIELD | One or more required fields absent. details.missing lists them. |
| 400 | INVALID_MODE | mode is not one of property_only, full_journey, licence_only. |
| 400 | USE_LICENCE_PRECAPTURE_ENDPOINT | mode=licence_only was passed — use POST /v1/licence-precapture/sessions instead. |
| 422 | ADDRESS_TYPE_NOT_SUPPORTED | The address type is not enabled for your client policy. Pre-screen with GET /v1/addresses/assess. |
| 422 | ADDRESS_STREET_MISSING | Address has no street name. Put the full street in line_1. |
| 429 | RATE_LIMIT_EXCEEDED | API key rate limit exceeded. Honour Retry-After. |
| 409 | CONCURRENT_LIMIT_EXCEEDED | You have reached the concurrent-verification cap (20 for test keys, 500 for standard live keys). |
Licence-only: POST /v1/licence-precapture/sessions
Create a standalone licence-capture session. Returns a capture_link to send to the customer — they photograph their driving licence (front, back, and a selfie) without doing property verification.
The captured licence is stored on-file and can be linked to a future property verification by passing its reference when creating the property check.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
reference | string | yes | Your stable identifier for this customer |
first_name | string | yes (or full_name) | Customer's first name |
last_name | string | yes (or full_name) | Customer's last name |
full_name | string | yes (or first + last) | Customer's full name — alternative to first_name + last_name |
phone | string | no | Customer's phone number (E.164). When present, DoorID sends the link by SMS. |
address | object | yes | Customer's declared address (same shape as POST /v1/verifications) |
date_of_birth | string | no | ISO date YYYY-MM-DD |
consent | object | yes | Consent record stored verbatim on the customer profile |
curl example
curl -X POST https://www.doorid.ai/v1/licence-precapture/sessions \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"reference": "customer-44218",
"first_name": "Sarah",
"last_name": "Johnson",
"phone": "+447700900123",
"address": {
"line_1": "14 Beach Road",
"city": "Bournemouth",
"postcode": "BH1 1AA",
"country": "GB"
},
"consent": {
"given_at": "2026-07-30T10:00:00.000Z",
"method": "api_request",
"version": "v1"
}
}'
Response (201 Created)
{
"ok": true,
"customer_profile_id": "cp_a1b2c3d4...",
"precapture_session_id": "ps_e5f6g7h8...",
"capture_link": "https://www.doorid.ai/lpc/plc_...",
"expires_at": "2026-07-31T10:00:00.000Z",
"consent_logged": true,
"sms_queued": true
}
Send
capture_linkto your customer. They photograph their driving licence front, back, and a selfie. The session expires in 24 hours.sms_queued: truemeans DoorID has already sent the link by SMS to the phone number you supplied.
Upload a Licence (lender-supplied mode)
POST /v1/licences/upload
Upload the customer's driving licence image yourself and receive a single-use licence_token to bind to an upcoming verification. Use this when your client policy is configured for lender_supplied identity mode.
curl -X POST https://www.doorid.ai/v1/licences/upload \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/licences/abc123.jpg",
"customer_id": "customer-44218"
}'
Response (201 Created)
{
"licence_token": "lct_<64 hex chars>",
"expires_at": "2026-07-31T10:00:00.000Z",
"single_use": true,
"image_summary": {
"format": "jpg",
"bytes": 412980,
"sha256": "<64 hex chars>",
"received_at": "2026-08-01T10:00:00.000Z"
},
"ttl_ms": 86400000,
"max_bytes": 10485760
}
Pass
licence_tokenin the body ofPOST /v1/verifications. The token expires in 24 hours and is consumed on first use — upload a fresh image for each verification.
Resending the Link
POST /v1/verifications/:id/resend-link
Re-surface the customer verification link for a pending or in-progress verification. When a phone number is on file, DoorID also re-sends the branded SMS.
Use this when a customer says they never received the link, or when the original link was lost before forwarding.
When to call it: only while the verification is still open (pending or in_progress). Returns 409 once capture has started and a terminal decision is reached.
Rate limit: Standard API key rate limits apply (see Rate Limits). There is no separate per-verification resend-specific limit.
curl -X POST https://www.doorid.ai/v1/verifications/did_3f2a.../resend-link \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"
Response (200 OK)
{
"success": true,
"data": {
"verification_link": "https://www.doorid.ai/v/did_3f2a...?t=...",
"sms_sent": true,
"had_phone": true
}
}
sms_sent: truemeans a fresh SMS was delivered to the customer's phone.sms_sent: falsewithhad_phone: falsemeans no phone number was supplied at creation — forwardverification_linkmanually.sms_sent: falsewithhad_phone: truemeans the SMS system was unavailable; use the link directly.
Required role in the portal: Owner, Admin, or Developer. All API key holders can call this endpoint.
Errors specific to this endpoint
| HTTP | Code | Cause |
|---|---|---|
| 404 | NOT_FOUND | Verification does not exist or belongs to a different client. |
| 409 | VERIFICATION_NOT_OPEN | Verification is in a terminal state — no point resending. |
| 429 | RATE_LIMIT_EXCEEDED | Too many resend attempts. Retry-After tells you when to try again. |
Retrieving Results
GET /v1/verifications/:id
Retrieve a single verification — its current status, decision (when reached), per-stage results, eligibility tags, and the evidence pack URL.
curl https://www.doorid.ai/v1/verifications/did_3f2a... \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"
Pending (no decision yet)
{
"verification_id": "did_3f2a...",
"status": "pending",
"created_at": "2026-07-30T10:00:00.000Z",
"expires_at": "2026-08-02T10:00:00.000Z",
"customer_id": "customer-44218",
"reference": "APP-44218",
"last_decision": null,
"webhook_delivery": null
}
Completed — pass
{
"verification_id": "did_3f2a...",
"status": "completed",
"created_at": "2026-07-30T10:00:00.000Z",
"expires_at": "2026-08-02T10:00:00.000Z",
"customer_id": "customer-44218",
"reference": "APP-44218",
"last_decision": {
"decision": "pass",
"confidence": "high",
"decided_at": "2026-07-30T10:08:30.000Z",
"summary": {
"identity": "verified",
"address_presence": "verified",
"device_and_capture": "consistent"
},
"risk_flags": [],
"evidence_pack_url": "https://www.doorid.ai/v1/evidence/did_3f2a..."
},
"webhook_delivery": {
"status": "delivered",
"last_attempt_at": "2026-07-30T10:08:35.000Z"
}
}
Completed — refer (review required)
{
"verification_id": "did_3f2a...",
"status": "completed",
"last_decision": {
"decision": "refer",
"confidence": "medium",
"decided_at": "2026-07-30T10:09:00.000Z",
"summary": {
"identity": "verified",
"address_presence": "inconclusive",
"device_and_capture": "consistent"
},
"risk_flags": ["address_presence_inconclusive", "manual_review_required"],
"evidence_pack_url": "https://www.doorid.ai/v1/evidence/did_3f2a..."
},
"webhook_delivery": {
"status": "delivered",
"last_attempt_at": "2026-07-30T10:09:05.000Z"
}
}
Completed — fail
{
"verification_id": "did_3f2a...",
"status": "completed",
"last_decision": {
"decision": "fail",
"confidence": "low",
"decided_at": "2026-07-30T10:10:00.000Z",
"summary": {
"identity": "failed",
"address_presence": "failed",
"device_and_capture": "consistent"
},
"risk_flags": ["identity_mismatch", "address_presence_inconclusive"],
"evidence_pack_url": "https://www.doorid.ai/v1/evidence/did_3f2a..."
},
"webhook_delivery": {
"status": "delivered",
"last_attempt_at": "2026-07-30T10:10:05.000Z"
}
}
Expired
{
"verification_id": "did_3f2a...",
"status": "expired",
"created_at": "2026-07-27T10:00:00.000Z",
"expires_at": "2026-07-30T10:00:00.000Z",
"last_decision": null
}
Cancelled
{
"verification_id": "did_3f2a...",
"status": "cancelled",
"last_decision": null
}
Field Reference
| Field | Meaning |
|---|---|
decision | pass / refer / fail / consent_declined / expired / cancelled |
confidence | Coarse band: high / medium / low. Numeric scores are never exposed. |
summary.identity | Whether the customer's identity matched their licence and biometric evidence. |
summary.address_presence | Whether the customer was present at the declared address during capture. |
summary.device_and_capture | Whether the capture session was internally consistent (single device, continuous). |
risk_flags | Closed enum array. See the table below. Ignore unknown values gracefully. |
evidence_pack_url | Signed URL to the PDF + JSON evidence bundle. Valid 7 days from generation. |
webhook_delivery.status | delivered / pending / failed — the last webhook delivery outcome. |
risk_flags values
| Flag | What it means | What to do |
|---|---|---|
identity_mismatch | Identity evidence does not match. | Escalate for manual review or decline. |
address_presence_inconclusive | Address presence could not be confirmed. | Request alternative verification or refer to underwriter. |
capture_inconsistency | Capture session was internally inconsistent. | Flag for review; may indicate fraud attempt. |
manual_review_required | DoorID could not auto-decide. | Wait for reviewer verdict or contact DoorID support. |
consent_declined | Customer declined consent. | No data captured; take alternative action. |
cross_verification_match | High-confidence match to a prior verification in the fraud pool. | Escalate to your fraud team. Details visible to DoorID admin only. |
Polling Alternative
If you cannot accept inbound webhooks, poll GET /v1/verifications/:id periodically. The response shape is identical to the webhook payload (under last_decision).
Recommended cadence: every 30 seconds while the customer's link is active, every 5 minutes after the link is first opened, until status is a terminal value.
Webhooks
DoorID posts signed JSON to your callback_url at two points in each verification's lifecycle:
| Event | When | Notes |
|---|---|---|
verification.completed | Customer finishes capture. No verdict yet. | Always fired. |
verification.decision | A verdict is reached (automatic or manual review). | Always fired; decision payload may be suppressed by client config. |
Which events fire per journey mode
| Mode | verification.completed | verification.decision |
|---|---|---|
property_only | After property capture | After pipeline decision |
full_journey | After both licence + property capture | After pipeline decision |
licence_only | licence_capture.completed (separate event) | N/A — no property decision |
verification.completed Payload
{
"schema_version": 7,
"event_type": "verification.completed",
"event_id": "evt_3a4b5c6d...",
"verification_id": "did_3f2a...",
"customer_id": "customer-44218",
"completed_at": "2026-07-30T10:05:00.000Z",
"eligibility_tags": ["house", "suburban"],
"metadata": { "internal_app_id": "app-99812" }
}
verification.decision Payload
{
"schema_version": 7,
"event_type": "verification.decision",
"event_id": "evt_71c2d3e4...",
"verification_id": "did_3f2a...",
"customer_id": "customer-44218",
"completed_at": "2026-07-30T10:05:00.000Z",
"decided_at": "2026-07-30T10:08:30.000Z",
"decision": "pass",
"confidence": "high",
"summary": {
"identity": "verified",
"address_presence": "verified",
"device_and_capture": "consistent"
},
"risk_flags": [],
"evidence_pack_url": "https://www.doorid.ai/v1/evidence/did_3f2a...",
"eligibility_tags": ["house", "suburban"],
"metadata": { "internal_app_id": "app-99812" }
}
Signature Verification
Every request carries two signatures so you can adopt replay protection without breaking your existing receiver.
Legacy signature (backward-compatible):
X-DoorID-Signature: sha256=<hmac-hex>
Timestamped V2 signature (replay-resistant — recommended):
X-DoorID-Timestamp: 1722333600
X-DoorID-Signature-V2: t=1722333600,v1=<hmac-hex>
The V2 HMAC is computed over "${timestamp}.${raw_body}". Reject requests where t is more than 5 minutes old.
Node.js — verify both formats
const crypto = require('crypto');
function verifyDoorIDSignature(rawBody, headers, secret) {
// --- V2 (timestamped, replay-resistant) ---
const ts = headers['x-doorid-timestamp'];
const sigV2Header = headers['x-doorid-signature-v2'] || '';
const v2Match = sigV2Header.match(/t=(\d+),v1=([0-9a-f]+)/);
if (ts && v2Match) {
const age = Math.abs(Date.now() / 1000 - parseInt(ts, 10));
if (age > 300) return { ok: false, reason: 'expired' };
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
const expBuf = Buffer.from(expected, 'hex');
const recBuf = Buffer.from(v2Match[2], 'hex');
if (expBuf.length === recBuf.length && crypto.timingSafeEqual(expBuf, recBuf)) {
return { ok: true, version: 'v2' };
}
return { ok: false, reason: 'bad_signature' };
}
// --- Legacy (V1) ---
const legacy = headers['x-doorid-signature'] || '';
const [scheme, received] = legacy.split('=');
if (scheme !== 'sha256' || !received) return { ok: false, reason: 'no_signature' };
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
if (a.length !== b.length) return { ok: false, reason: 'bad_signature' };
return crypto.timingSafeEqual(a, b)
? { ok: true, version: 'v1' }
: { ok: false, reason: 'bad_signature' };
}
app.post('/webhooks/doorid', express.raw({ type: 'application/json' }), (req, res) => {
const result = verifyDoorIDSignature(req.body, req.headers, process.env.DOORID_WEBHOOK_SECRET);
if (!result.ok) return res.status(401).json({ error: result.reason });
const eventId = req.headers['x-doorid-event-id'];
if (await alreadyProcessed(eventId)) return res.json({ received: true, deduplicated: true });
const payload = JSON.parse(req.body);
await processVerificationEvent(payload);
await markProcessed(eventId);
res.json({ received: true });
});
Python — verify V2
import hmac, hashlib, time
def verify_doorid_v2(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get('x-doorid-timestamp', '')
sig_header = headers.get('x-doorid-signature-v2', '')
if not ts or 'v1=' not in sig_header:
return False
if abs(time.time() - int(ts)) > 300:
return False # expired
v1_part = sig_header.split('v1=', 1)[1]
payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1_part)
Handling Duplicates
Deduplicate on verification_id + event_type — or use event_id (globally unique per delivery attempt) for finer-grained tracking. Resends carry the original event_id.
const key = `${payload.verification_id}:${payload.event_type}`;
if (await db.events.findByKey(key)) {
return res.json({ received: true, deduplicated: true });
}
await db.events.insert({ key, payload });
Retry Schedule
If your endpoint does not return a 2xx response, DoorID retries with exponential back-off:
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | (initial delivery) | 0 |
| 2 | 10 seconds | 10s |
| 3 | 1 minute | ~1m |
| 4 | 5 minutes | ~6m |
| 5 | 20 minutes | ~26m |
| 6 | 1 hour | ~1.5h |
| 7 | 2 hours | ~3.5h |
After all 7 attempts fail the verification is marked webhook_undelivered. Resend manually via POST /v1/verifications/:id/resend-webhook.
Evidence Pack
Every completed verification produces an evidence pack in two forms.
PDF Certificate
GET /v1/verifications/:id/evidence/pack.pdf
A human-readable, image-led certificate for a reviewer. Contains:
- Decision badge — overall outcome (
pass/refer/fail) - Subject & declared address, verification ID, and timestamp
- Identity — licence-front and selfie side-by-side, plus the face-match similarity score (the only numeric score on the certificate)
- Property — branch-dependent imagery with a Google Street View reference alongside the customer's capture
- Reviewer notes (if manually reviewed)
curl https://www.doorid.ai/v1/verifications/did_3f2a.../evidence/pack.pdf \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
--output evidence.pdf
JSON Bundle
GET /v1/verifications/:id/evidence/pack.json
Machine-readable bundle for system integration. Contains:
- Captured image and video assets (as URLs)
- Device GPS readings (timestamped)
- Face-comparison score
- Full decision audit trail and embedded webhook payload
- Consent record (timestamp, IP, device fingerprint hash, consent version)
- Per-stage timing log
- Manual review notes (if applicable)
curl https://www.doorid.ai/v1/verifications/did_3f2a.../evidence/pack.json \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"
Access and Expiry
- The evidence pack URL is signed and valid for 7 days from generation (configurable per client).
- To refresh an expired URL, call
GET /v1/verifications/:id/evidence— returns a fresh signed URL. - After Tier 1 asset retention (7 days from terminal decision), biometric assets are purged and the pack returns HTTP 410.
- Access is logged (timestamp + requester IP). Audit logs are in your dashboard.
Data Retention
| Tier | Data | Default retention |
|---|---|---|
| 1 | Raw biometric assets (selfie, video, licence image) | 7 days from terminal decision |
| 2 | Derived non-reversible fingerprints | 7 years |
| 3 | Decision data, audit trail, metadata | 7 years |
Errors Reference
All error responses share this shape:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests — per-minute limit exceeded. Retry in 12s.",
"request_id": "req_8f2a4c91..."
}
}
request_id is logged on DoorID's side — include it when contacting support.
Legacy envelope flag: The production server currently includes
"success": falsealongside every error response. Always key your error-handling logic offerror.code, not the presence ofsuccess:json{ "error": { "code": "NOT_FOUND", "message": "...", "request_id": "..." }, "success": false }
HTTP Error Codes
| HTTP | Code | Cause | What to do |
|---|---|---|---|
| 400 | MISSING_FIELD | Required field absent. details.missing lists them. | Add the missing fields. |
| 400 | INVALID_MODE | mode value not recognised. | Use property_only, full_journey, or licence_only. |
| 400 | USE_LICENCE_PRECAPTURE_ENDPOINT | mode=licence_only sent to wrong endpoint. | Call POST /v1/licence-precapture/sessions. |
| 401 | UNAUTHORIZED | API key missing, invalid, or expired. | Check the Authorization: Bearer header. |
| 403 | FORBIDDEN | Key does not have permission, or licence_token belongs to a different client. | Check key scope; never share tokens across clients. |
| 404 | NOT_FOUND | Verification does not exist or belongs to a different client. | Check the verification ID and key scope. |
| 409 | INVALID_STATUS_TRANSITION | Operation not valid in current state (e.g. cancelling a completed verification). | Fetch current status before retrying. |
| 409 | VERIFICATION_NOT_OPEN | Resend-link called on a terminal verification. | No action needed — link is no longer usable. |
| 410 | GONE | Evidence pack assets purged after Tier 1 retention. | Asset is permanently unavailable. |
| 422 | ADDRESS_TYPE_NOT_SUPPORTED | Address type not enabled for your client policy. Response includes address_type. | Pre-screen with GET /v1/addresses/assess. |
| 422 | ADDRESS_NO_MATCH | Address could not be geocoded to any known location. Response includes candidates array (may be empty). | Verify address and resubmit; use candidates if provided. |
| 422 | ADDRESS_AMBIGUOUS | Multiple possible matches. Response includes candidates array. | Use the candidates to pick the correct address and resubmit. |
| 429 | RATE_LIMIT_EXCEEDED | Rate limit exceeded. | Read Retry-After header and back off. |
| 500 | INTERNAL_ERROR | Something went wrong on DoorID's side. | Retry. If persistent, contact support with the request_id. |
422 Address ambiguous — example response
{
"error": {
"code": "ADDRESS_AMBIGUOUS",
"message": "Multiple address candidates found. Please confirm the correct address.",
"request_id": "req_9a1b2c...",
"details": {
"candidates": [
{ "line_1": "14 Beach Road", "city": "Bournemouth", "postcode": "BH1 1AA" },
{ "line_1": "14 Beach Road", "city": "Poole", "postcode": "BH15 1AA" }
]
}
}
}
Worked Example
A complete property_only integration in five curl steps — readable in under 5 minutes.
Step 1 — Create the verification
curl -X POST https://www.doorid.ai/v1/verifications \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Sarah",
"last_name": "Johnson",
"phone": "+447700900123",
"reference": "APP-44218",
"address": {
"line_1": "14 Beach Road",
"city": "Bournemouth",
"postcode": "BH1 1AA"
},
"callback_url": "https://your-app.example.com/webhooks/doorid",
"customer_id": "customer-44218"
}'
Response:
{
"verification_id": "did_3f2a4c91...",
"customer_link": "https://www.doorid.ai/v/did_3f2a4c91...?t=abc123...",
"expires_at": "2026-08-02T10:00:00.000Z"
}
Step 2 — Send the link to the customer
SMS, email, or in-app — your choice. The customer opens customer_link on their phone and completes the capture flow (typically 3–5 minutes).
Step 3 — Receive the webhook
DoorID POSTs to your callback_url when a verdict is reached. Verify the signature immediately:
app.post('/webhooks/doorid', express.raw({ type: 'application/json' }), async (req, res) => {
const result = verifyDoorIDSignature(req.body, req.headers, process.env.DOORID_WEBHOOK_SECRET);
if (!result.ok) return res.status(401).json({ error: 'bad signature' });
const payload = JSON.parse(req.body);
if (payload.event_type === 'verification.decision') {
console.log('Decision:', payload.decision); // 'pass', 'refer', or 'fail'
console.log('Confidence:', payload.confidence); // 'high', 'medium', or 'low'
console.log('Evidence:', payload.evidence_pack_url);
// Store payload.verification_id → payload.decision in your DB
}
res.json({ received: true });
});
Step 4 — Verify the signature (full check)
function verifyDoorIDSignature(rawBody, headers, secret) {
const ts = headers['x-doorid-timestamp'];
const sigV2Header = headers['x-doorid-signature-v2'] || '';
const v2Match = sigV2Header.match(/t=(\d+),v1=([0-9a-f]+)/);
if (ts && v2Match) {
if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) return { ok: false, reason: 'expired' };
const expected = require('crypto').createHmac('sha256', secret)
.update(`${ts}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v2Match[2], 'hex');
return (a.length === b.length && require('crypto').timingSafeEqual(a, b))
? { ok: true } : { ok: false, reason: 'bad_signature' };
}
return { ok: false, reason: 'no_v2_signature' };
}
Step 5 — Fetch the evidence pack
curl https://www.doorid.ai/v1/verifications/did_3f2a4c91.../evidence/pack.pdf \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
--output evidence-APP-44218.pdf
The PDF is the signed certificate you keep on file. The JSON bundle at pack.json has the full machine-readable record including GPS timestamps, consent log, and the embedded decision payload.
Additional Endpoints
GET /v1/verifications — List Verifications
Page through all verifications belonging to the calling API key's account. Results are tenant-scoped — you will never see verifications from another client account.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | no | Number of results to return. Maximum 100, default 20. |
offset | integer | no | Number of results to skip (for pagination). Default 0. |
status | string | no | Filter by status: pending, in_progress, completed, failed, cancelled, expired. |
search | string | no | Free-text search over the customer reference and address fields. |
curl "https://www.doorid.ai/v1/verifications?limit=20&status=completed" \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"
Response (200 OK)
{
"success": true,
"data": {
"verifications": [
{
"verification_id": "did_3f2a...",
"status": "completed",
"reference": "APP-44218",
"customer_id": "customer-44218",
"created_at": "2026-07-30T10:00:00.000Z",
"expires_at": "2026-08-02T10:00:00.000Z"
}
],
"total": 142,
"limit": 20,
"offset": 0
}
}
Each item in
verificationsis a summary shape — no decision detail, no raw capture assets, no per-stage results. CallGET /v1/verifications/:idfor the full record includinglast_decision.
POST /v1/verifications/:id/cancel — Cancel a Verification
Cancel a pending or in_progress verification. The customer link stops resolving immediately. Supports Idempotency-Key.
curl -X POST https://www.doorid.ai/v1/verifications/did_3f2a.../cancel \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Idempotency-Key: 7c3f2a8b-9e4d-4a12-bc8f-d293e761f9a2"
Response (200 OK)
{
"success": true,
"data": {
"verification_id": "did_3f2a...",
"status": "cancelled",
"reference": "APP-44218",
"cancelled_at": "2026-08-01T10:00:00.000Z"
}
}
DoorID fires a terminal
verification.completedwebhook withdecision: "cancelled"after cancellation.
Errors specific to this endpoint
| HTTP | Code | Cause |
|---|---|---|
| 404 | NOT_FOUND | Verification does not exist. |
| 409 | INVALID_STATUS_TRANSITION | Verification is already in a terminal state (completed, failed, expired, cancelled). |
POST /v1/verifications/:id/resend-webhook — Resend a Webhook
Re-dispatch the most recent verification.decision webhook payload to the configured callback_url. Use when your receiver was down during initial delivery. Supports Idempotency-Key.
curl -X POST https://www.doorid.ai/v1/verifications/did_3f2a.../resend-webhook \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx" \
-H "Idempotency-Key: 9a1b2c3d-4e5f-6789-abcd-ef0123456789"
Response (200 OK)
{
"success": true,
"data": {
"verification_id": "did_3f2a...",
"event": "verification.decision",
"delivery_id": "dlv_8f2a4c91...",
"status": "delivered",
"attempt_no": 1
}
}
The re-dispatched payload is identical to the original — not recomputed. This preserves the decision state as it was at the time of the original webhook emission.
Errors specific to this endpoint
| HTTP | Code | Cause |
|---|---|---|
| 404 | NOT_FOUND | Verification does not exist. |
| 404 | NO_DECISION_TO_RESEND | No verification.decision has been emitted yet for this verification. |
Pre-screen an Address
GET /v1/addresses/assess — check whether DoorID can verify an address before creating a verification.
Query parameters
| Parameter | Required | Description |
|---|---|---|
line_1 | yes | First line of address |
city | yes | City |
postcode | yes | Postcode |
country | yes | ISO 3166-1 alpha-2 country code (e.g. GB). Omitting returns HTTP 400 MISSING_FIELD. |
line_2 | no | Second line of address |
region | no | County / region |
curl "https://www.doorid.ai/v1/addresses/assess?line_1=14+Beach+Road&city=Bournemouth&postcode=BH1+1AA&country=GB" \
-H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"
Returns suitability_score, address_type, street_view_available, limitations, and recommended_method.
Batch Pre-screen
POST /v1/addresses/assess/batch — assess up to 50 addresses in one call.
Customer Erasure (UK GDPR Article 17)
POST /v1/verifications/:id/erase
⚠️ Admin-only endpoint. This route is protected by DoorID admin authentication and is not accessible to client API keys. Client API key holders cannot call this endpoint regardless of their portal role.
To request erasure of a customer record, use the DoorID admin portal or contact DoorID support with the verification ID and the customer's deletion request. DoorID will process the erasure within 30 days (operational target: 7 days).
# This example will return 403 FORBIDDEN when called with a Bearer API key.
# Erasure is initiated via the DoorID admin portal or DoorID support.
curl -X POST https://www.doorid.ai/v1/verifications/did_3f2a.../erase \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer erasure request under UK GDPR Art. 17" }'
