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

  1. 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.
  2. 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.
  3. Create a session — Call the appropriate creation endpoint and receive a link for your customer.
  4. 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.
  5. Receive webhooks — DoorID fires signed events when the customer finishes capture and when a verdict is reached.
  6. 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 RoleCan generate / rotate / revoke keys?
OwnerYes
DeveloperYes
AdminNo (can use keys for requests, cannot manage them)
ReviewerNo
BillingNo
ViewerNo

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

bash
curl https://www.doorid.ai/health

Response:

json
{
  "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)NeverYes
Consumes creditsNeverYes
Returns deterministic responsesYesNo
Customer links are realNo (test only)Yes
Safe to use freelyYesNo — 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_…)

WindowLimit
1 second (burst)5 requests
1 minute30 requests
24 hours1,000 requests
Concurrent verifications in non-terminal state20

Live keys / standard tier (doorid_live_…)

WindowLimit
1 second (burst)10 requests
1 minute60 requests
24 hours5,000 requests
Concurrent verifications in non-terminal state500

When you exceed a limit, DoorID returns HTTP 429 with a Retry-After header:

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

  1. Generate a new key in the portal (Owner or Developer role required).
  2. Update your system config to use the new key.
  3. Run a test request to confirm the new key works.
  4. 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.

ModeWhat it doesReturnsUse when
property_onlyProperty verification only. No licence required.customer_linkIdentity has already been verified through another channel.
full_journeyLicence collection + property verification linked. Customer completes both in one session. Pre-captured licence auto-used for face match.customer_linkYou want a single, end-to-end verified record.
licence_onlyCollects the customer's driving licence up front, no property verification yet.capture_linkBuilding 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_only or full_journeyPOST /v1/verifications
  • licence_onlyPOST /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

FieldTypeRequiredDescription
first_namestringyesCustomer's first name
last_namestringyesCustomer's last name
phonestringyesCustomer's phone number (E.164 preferred, e.g. +447700900123)
referencestringyesYour own reference (e.g. application or loan number). Shown on the dashboard and evidence pack.
modestringnoJourney mode: property_only (default), full_journey. Omitting is identical to property_only.
dobstringnoDate of birth (YYYY-MM-DD). Optional metadata.
address.line_1stringyesFirst line of address
address.line_2stringnoSecond line of address
address.citystringyesCity or suburb
address.regionstringnoCounty / state / region
address.postcodestringnoPostcode or ZIP code
address.countrystringnoISO 3166-1 alpha-2 country code. Defaults to GB if omitted.
customer_idstringnoYour own identifier for this customer. Echoed back.
callback_urlstringnoHTTPS URL to receive signed webhook events. Falls back to the client's default if omitted.
licence_tokenstringconditionalRequired when client policy is lender_supplied identity mode. From POST /v1/licences/upload.
eligibilityobjectnoEligibility metadata. See below.
metadataobjectnoFree-form key/value metadata. Returned in webhook payloads.
force_manual_reviewbooleannoForce 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.

FieldTypeDescription
eligibility.declared_property_typestringhouse / flat / apartment / bungalow / other / unknown
eligibility.declared_urban_ruralstringurban / suburban / rural / unknown
eligibility.loan_amount_minor_unitsintegerLoan amount in smallest currency unit (pence for GBP). E.g. £8,500.00 = 850000
eligibility.loan_currencystringISO 4217 code (e.g. GBP)
eligibility.loan_purposestringFree-form, e.g. vehicle_finance

curl example — property_only (default)

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

json
{
  "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_link to 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, call POST /v1/verifications/:id/resend-link.

curl example — full_journey

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

json
{
  "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_journey mode 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

HTTPCodeCause
400MISSING_FIELDOne or more required fields absent. details.missing lists them.
400INVALID_MODEmode is not one of property_only, full_journey, licence_only.
400USE_LICENCE_PRECAPTURE_ENDPOINTmode=licence_only was passed — use POST /v1/licence-precapture/sessions instead.
422ADDRESS_TYPE_NOT_SUPPORTEDThe address type is not enabled for your client policy. Pre-screen with GET /v1/addresses/assess.
422ADDRESS_STREET_MISSINGAddress has no street name. Put the full street in line_1.
429RATE_LIMIT_EXCEEDEDAPI key rate limit exceeded. Honour Retry-After.
409CONCURRENT_LIMIT_EXCEEDEDYou 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

FieldTypeRequiredDescription
referencestringyesYour stable identifier for this customer
first_namestringyes (or full_name)Customer's first name
last_namestringyes (or full_name)Customer's last name
full_namestringyes (or first + last)Customer's full name — alternative to first_name + last_name
phonestringnoCustomer's phone number (E.164). When present, DoorID sends the link by SMS.
addressobjectyesCustomer's declared address (same shape as POST /v1/verifications)
date_of_birthstringnoISO date YYYY-MM-DD
consentobjectyesConsent record stored verbatim on the customer profile

curl example

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

json
{
  "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_link to your customer. They photograph their driving licence front, back, and a selfie. The session expires in 24 hours. sms_queued: true means 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.

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

json
{
  "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_token in the body of POST /v1/verifications. The token expires in 24 hours and is consumed on first use — upload a fresh image for each verification.


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.

bash
curl -X POST https://www.doorid.ai/v1/verifications/did_3f2a.../resend-link \
  -H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"

Response (200 OK)

json
{
  "success": true,
  "data": {
    "verification_link": "https://www.doorid.ai/v/did_3f2a...?t=...",
    "sms_sent": true,
    "had_phone": true
  }
}

sms_sent: true means a fresh SMS was delivered to the customer's phone. sms_sent: false with had_phone: false means no phone number was supplied at creation — forward verification_link manually. sms_sent: false with had_phone: true means 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

HTTPCodeCause
404NOT_FOUNDVerification does not exist or belongs to a different client.
409VERIFICATION_NOT_OPENVerification is in a terminal state — no point resending.
429RATE_LIMIT_EXCEEDEDToo 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.

bash
curl https://www.doorid.ai/v1/verifications/did_3f2a... \
  -H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"

Pending (no decision yet)

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

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

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

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

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

json
{
  "verification_id": "did_3f2a...",
  "status": "cancelled",
  "last_decision": null
}

Field Reference

FieldMeaning
decisionpass / refer / fail / consent_declined / expired / cancelled
confidenceCoarse band: high / medium / low. Numeric scores are never exposed.
summary.identityWhether the customer's identity matched their licence and biometric evidence.
summary.address_presenceWhether the customer was present at the declared address during capture.
summary.device_and_captureWhether the capture session was internally consistent (single device, continuous).
risk_flagsClosed enum array. See the table below. Ignore unknown values gracefully.
evidence_pack_urlSigned URL to the PDF + JSON evidence bundle. Valid 7 days from generation.
webhook_delivery.statusdelivered / pending / failed — the last webhook delivery outcome.

risk_flags values

FlagWhat it meansWhat to do
identity_mismatchIdentity evidence does not match.Escalate for manual review or decline.
address_presence_inconclusiveAddress presence could not be confirmed.Request alternative verification or refer to underwriter.
capture_inconsistencyCapture session was internally inconsistent.Flag for review; may indicate fraud attempt.
manual_review_requiredDoorID could not auto-decide.Wait for reviewer verdict or contact DoorID support.
consent_declinedCustomer declined consent.No data captured; take alternative action.
cross_verification_matchHigh-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:

EventWhenNotes
verification.completedCustomer finishes capture. No verdict yet.Always fired.
verification.decisionA verdict is reached (automatic or manual review).Always fired; decision payload may be suppressed by client config.

Which events fire per journey mode

Modeverification.completedverification.decision
property_onlyAfter property captureAfter pipeline decision
full_journeyAfter both licence + property captureAfter pipeline decision
licence_onlylicence_capture.completed (separate event)N/A — no property decision

verification.completed Payload

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

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

javascript
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

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

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

AttemptDelayCumulative
1(initial delivery)0
210 seconds10s
31 minute~1m
45 minutes~6m
520 minutes~26m
61 hour~1.5h
72 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)
bash
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)
bash
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

TierDataDefault retention
1Raw biometric assets (selfie, video, licence image)7 days from terminal decision
2Derived non-reversible fingerprints7 years
3Decision data, audit trail, metadata7 years

Errors Reference

All error responses share this shape:

json
{
  "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": false alongside every error response. Always key your error-handling logic off error.code, not the presence of success:

json
{
  "error": { "code": "NOT_FOUND", "message": "...", "request_id": "..." },
  "success": false
}

HTTP Error Codes

HTTPCodeCauseWhat to do
400MISSING_FIELDRequired field absent. details.missing lists them.Add the missing fields.
400INVALID_MODEmode value not recognised.Use property_only, full_journey, or licence_only.
400USE_LICENCE_PRECAPTURE_ENDPOINTmode=licence_only sent to wrong endpoint.Call POST /v1/licence-precapture/sessions.
401UNAUTHORIZEDAPI key missing, invalid, or expired.Check the Authorization: Bearer header.
403FORBIDDENKey does not have permission, or licence_token belongs to a different client.Check key scope; never share tokens across clients.
404NOT_FOUNDVerification does not exist or belongs to a different client.Check the verification ID and key scope.
409INVALID_STATUS_TRANSITIONOperation not valid in current state (e.g. cancelling a completed verification).Fetch current status before retrying.
409VERIFICATION_NOT_OPENResend-link called on a terminal verification.No action needed — link is no longer usable.
410GONEEvidence pack assets purged after Tier 1 retention.Asset is permanently unavailable.
422ADDRESS_TYPE_NOT_SUPPORTEDAddress type not enabled for your client policy. Response includes address_type.Pre-screen with GET /v1/addresses/assess.
422ADDRESS_NO_MATCHAddress could not be geocoded to any known location. Response includes candidates array (may be empty).Verify address and resubmit; use candidates if provided.
422ADDRESS_AMBIGUOUSMultiple possible matches. Response includes candidates array.Use the candidates to pick the correct address and resubmit.
429RATE_LIMIT_EXCEEDEDRate limit exceeded.Read Retry-After header and back off.
500INTERNAL_ERRORSomething went wrong on DoorID's side.Retry. If persistent, contact support with the request_id.

422 Address ambiguous — example response

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

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

json
{
  "verification_id": "did_3f2a4c91...",
  "customer_link": "https://www.doorid.ai/v/did_3f2a4c91...?t=abc123...",
  "expires_at": "2026-08-02T10:00:00.000Z"
}

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:

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

javascript
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

bash
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

ParameterTypeRequiredDescription
limitintegernoNumber of results to return. Maximum 100, default 20.
offsetintegernoNumber of results to skip (for pagination). Default 0.
statusstringnoFilter by status: pending, in_progress, completed, failed, cancelled, expired.
searchstringnoFree-text search over the customer reference and address fields.
bash
curl "https://www.doorid.ai/v1/verifications?limit=20&status=completed" \
  -H "Authorization: Bearer doorid_test_xxxxxxxxxxxx"

Response (200 OK)

json
{
  "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 verifications is a summary shape — no decision detail, no raw capture assets, no per-stage results. Call GET /v1/verifications/:id for the full record including last_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.

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

json
{
  "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.completed webhook with decision: "cancelled" after cancellation.

Errors specific to this endpoint

HTTPCodeCause
404NOT_FOUNDVerification does not exist.
409INVALID_STATUS_TRANSITIONVerification 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.

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

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

HTTPCodeCause
404NOT_FOUNDVerification does not exist.
404NO_DECISION_TO_RESENDNo 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

ParameterRequiredDescription
line_1yesFirst line of address
cityyesCity
postcodeyesPostcode
countryyesISO 3166-1 alpha-2 country code (e.g. GB). Omitting returns HTTP 400 MISSING_FIELD.
line_2noSecond line of address
regionnoCounty / region
bash
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).

bash
# 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" }'