This guide walks you through the complete flow for integrating Didit identity verification via the REST API: get credentials, create a session, present it to the user, and receive results.
Step 1: Get Your Credentials
You have two ways to obtain an api_key:
Option A: Programmatic registration (no browser)
Best for CI/CD and AI agents. Two API calls — POST /programmatic/register/ then POST /programmatic/verify-email/ — return the api_key inline.
→ See Programmatic Registration for the full flow, password rules, JWT lifetime, and lockout policy.
Option B: From the Didit Console
- Go to the Didit Console.
- Create or select your organization.
- Navigate to Settings → API & Webhooks.
- Copy:
- API Key — used as the
x-api-key header on every verification call.
- Webhook Secret Key — used to verify webhook HMAC signatures.
Environment variables
# Required
DIDIT_API_KEY=your_api_key_here
DIDIT_WEBHOOK_SECRET=your_webhook_secret_here
# Your workflow ID (from the Console)
DIDIT_WORKFLOW_ID=your_workflow_id_here
Step 2: Create a Workflow
Before creating sessions, you need a verification workflow. Workflows define what verification steps users go through.
Create in Console
- Go to Didit Console → Workflows
- Click Create Workflow
- Choose a base template:
| Template | Best For |
|---|
| KYC | Full identity verification with ID document |
| Adaptive Age Verification | Age verification with optional ID backup |
| Biometric Authentication | Re-verifying returning users |
| Address Verification | Proof of address verification |
- Add optional features:
| Feature | Description |
|---|
| NFC Verification | Read passport/ID chip data |
| Liveness Detection | Prevent spoofing with selfie video |
| Face Matching | Match selfie to document photo |
| Phone Verification | Verify phone number via SMS |
| Email Verification | Verify email address |
| AML Screening | Check against sanctions/PEP lists |
| Database Validation | Verify against government databases |
| Device & IP Analysis | Detect VPNs, proxies, geolocation |
- Copy your Workflow ID
Step 3: Create a Verification Session
Call the API to create a session for your user.
Request
curl -X POST https://verification.didit.me/v3/session/ \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"workflow_id": "your-workflow-id",
"callback": "https://yourapp.com/verification-complete",
"vendor_data": "user-123"
}'
Request Parameters
| Parameter | Required | Description |
|---|
workflow_id | Yes | Your workflow UUID from the Console. |
callback | No | URL the user is sent to after verification. Didit appends verificationSessionId and status as query parameters. Supports custom schemes (e.g. myapp://). Falls back to the workflow’s configured callback URL when omitted. |
callback_method | No | Which device handles the redirect: initiator, completer, or both. Default: initiator. |
vendor_data | No | Your internal user identifier — echoed in webhooks for correlation. |
metadata | No | Arbitrary JSON object stored with the session and echoed back in webhooks. |
language | No | UI language hint (ISO 639-1, e.g. en, es). Browser language is auto-detected when omitted. |
contact_details | No | User’s email/phone for prefill and email notifications. |
expected_details | No | Expected user details for cross-validation against verified data. |
portrait_image | Conditional | Base64-encoded face image (max 2MB; JPEG, PNG, WebP, or TIFF). Required for Biometric Authentication / Face-Match-first workflows. |
sandbox_scenario | No | Sandbox-only scenario slug (e.g. approve, decline_aml_hit) that auto-populates the session with magic inputs. |
Full Example with All Options
curl -X POST https://verification.didit.me/v3/session/ \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"workflow_id": "11111111-2222-3333-4444-555555555555",
"callback": "https://yourapp.com/verification-complete",
"vendor_data": "user-123",
"metadata": {
"plan": "premium",
"signup_source": "mobile-app"
},
"contact_details": {
"email": "alex.sample@example.com",
"email_lang": "en",
"send_notification_emails": true,
"phone": "+15550101000"
},
"expected_details": {
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1990-01-01",
"expected_document_types": ["P", "ID"]
}
}'
expected_details.expected_document_types restricts the ID verification step
to specific document types — the document selection screen only shows the
requested types. Allowed values: P (passport), ID (national ID), DL
(driver’s licence), RP (residence permit), HIC (health insurance card),
TC (tax card), SSC (social security card). Values are case-insensitive
and deduplicated; unknown values return 400. See the
Create Session reference for the full schema.
Response
201 Created:
{
"session_id": "11111111-2222-3333-4444-555555555555",
"session_number": 43762,
"session_token": "3FaJ9wLqX2Mz",
"url": "https://verify.didit.me/session/3FaJ9wLqX2Mz",
"vendor_data": "user-123",
"metadata": {
"plan": "premium",
"signup_source": "mobile-app"
},
"status": "Not Started",
"workflow_id": "11111111-2222-3333-4444-555555555555",
"workflow_version": 3,
"callback": "https://yourapp.com/verification-complete"
}
Key Response Fields
| Field | Description |
|---|
session_id | Unique UUID for this session. Use it for GET /v3/session/{sessionId}/decision/ and webhooks. |
session_number | Human-readable sequence number shown in the Didit console. |
session_token | 12-character URL-safe token that authorizes the end user to open the hosted flow, and initialises the native SDKs (the web SDK takes url instead). Treat it as a secret. |
url | Hosted verification URL to redirect the user to, or embed in an iframe. (Field name is url, not verification_url.) When the request includes language, the URL contains a language path segment — e.g. https://verify.didit.me/en/session/3FaJ9wLqX2Mz. |
status | Current status — "Not Started" for a newly created session. |
workflow_id | Stable workflow identifier the session runs on. |
workflow_version | Published workflow version the session was pinned to at creation. |
Idempotency. When vendor_data is provided and an unfinished session
(Not Started, In Progress, Resubmitted, or Awaiting User) with the
same vendor_data already exists on the workflow’s latest published version,
that existing session is returned (still 201) instead of creating a
duplicate — with its callback and metadata updated to the new values, and
status reflecting the existing session.
Auth errors are 403, never 401. A missing, malformed, or expired
x-api-key returns 403 with
{"detail": "You do not have permission to perform this action."} — the
same body as a valid key without permission. Insufficient credits return
400. See Create Session API Reference for
complete documentation.
Step 4: Present Verification to User
Choose how to present the verification flow to your user:
Option A: Native SDK (recommended for mobile)
Use the session_token with the native SDKs for camera, NFC, and biometric integration.
→ iOS SDK · Android SDK · React Native · Flutter
Option B: InContext iframe (recommended for web)
Embed the verification in your page using the url returned in Step 3.
<iframe
src="{url}"
style="width: 100%; height: 700px; border: none;"
allow="camera; microphone; fullscreen; autoplay; encrypted-media"
></iframe>
→ InContext iframe
Option C: Redirect
Redirect the user to the verification URL.
window.location.href = session.url;
→ Web redirect
Option D: Mobile WebView
For mobile apps without a native SDK.
// React Native
<WebView source={{ uri: session.url }} />
→ WebView in iOS/Android
Step 5: Receive Results
Via Webhook (Recommended)
Webhooks notify your server in real-time when verification status changes.
Setup:
- Create a webhook destination in the Console (API & Webhooks) or via
POST /v3/webhook/destinations/ with webhook_version: "v3" and a subscribed_events array (e.g. ["status.updated"])
- Store the
secret_shared_key returned on creation — it is the HMAC secret for all signature variants
- Implement a webhook endpoint on your server
Example webhook payload:
Inside decision, every per-feature result is delivered as a plural array (id_verifications[], nfc_verifications[], liveness_checks[], face_matches[], …) — one entry per node in the workflow. This is the V3 contract and is sacred: never code against a singular nfc / id_verification / liveness key.
{
"event_id": "9c0c8b8a-1111-4222-9333-444444444444",
"webhook_type": "status.updated",
"timestamp": 1774970000,
"created_at": 1774969994,
"application_id": "11111111-2222-3333-4444-555555555555",
"environment": "live",
"session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"status": "Approved",
"workflow_id": "22222222-3333-4444-5555-666666666666",
"workflow_version": 4,
"vendor_data": "user-123",
"metadata": { "plan": "premium" },
"decision": {
"session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"status": "Approved",
"id_verifications": [
{ "node_id": "id_verification_1", "status": "Approved", "document_type": "Passport", "warnings": [] }
],
"liveness_checks": [
{ "node_id": "liveness_1", "status": "Approved", "method": "PASSIVE", "score": 95.4, "warnings": [] }
],
"face_matches": [
{ "node_id": "face_match_1", "status": "Approved", "score": 96.1, "warnings": [] }
],
"reviews": []
}
}
The decision object is only present when status is Approved, Declined, In Review, or Abandoned (a Resubmitted event carries resubmit_info instead). Didit reuses the same event_id across retries and fan-out destinations — key your idempotency on it. See Data models for every plural array and field.
Verify the signature (X-Signature-V2, recommended):
X-Signature-V2 is HMAC-SHA256 over a canonical re-encoding of the JSON body: whole-valued floats shortened to ints, keys sorted recursively, compact separators, Unicode preserved.
const crypto = require("crypto");
// Match Didit's float normalisation: whole-valued floats serialise as ints.
function shortenFloats(data) {
if (Array.isArray(data)) return data.map(shortenFloats);
if (data !== null && typeof data === "object") {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [key, shortenFloats(value)])
);
}
return data;
}
// Sort object keys recursively before re-stringifying.
function sortKeys(obj) {
if (Array.isArray(obj)) return obj.map(sortKeys);
if (obj !== null && typeof obj === "object") {
return Object.keys(obj).sort().reduce((acc, key) => {
acc[key] = sortKeys(obj[key]);
return acc;
}, {});
}
return obj;
}
function verifyWebhook(rawBody, signatureV2, timestamp, secret) {
// 1. Reject anything older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
// 2. Recompute HMAC-SHA256 against the canonical JSON Didit signs
const canonical = JSON.stringify(sortKeys(shortenFloats(JSON.parse(rawBody))));
const expected = crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex");
// 3. Constant-time compare
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureV2, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
→ Full webhooks documentation, including the raw-bytes X-Signature, the envelope-only X-Signature-Simple, and how to pick. Didit retries failed deliveries (5xx, 404, timeout) up to 2 times (~1 min, then ~4 min) and times out after 5 seconds — return 2xx fast.
Via API (optional)
Fetch the same decision payload at any time:
curl https://verification.didit.me/v3/session/{session_id}/decision/ \
-H "x-api-key: YOUR_API_KEY"
The response shape (plural arrays) is identical to the decision object inside the webhook payload, and the endpoint can be called at any point in the session lifecycle. Use webhooks for real-time updates and reserve API calls for backfill, audit, and rebuilds — the /decision/ endpoint is rate-limited to 600 GET requests/min per API key (see Rate limiting). Error semantics: an unknown session_id returns 404 with {"detail": "Not found."}; authentication failures surface as 403 — this endpoint never returns 401. Media URLs in the response are short-lived presigned links — fetch them promptly rather than persisting them.
→ Retrieve session API reference
Complete Code Examples
Node.js / Express
const express = require("express");
const crypto = require("crypto");
const app = express();
// Create verification session
app.post("/api/verify", async (req, res) => {
const { userId } = req.body;
const response = await fetch("https://verification.didit.me/v3/session/", {
method: "POST",
headers: {
"x-api-key": process.env.DIDIT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
workflow_id: process.env.DIDIT_WORKFLOW_ID,
callback: `${process.env.APP_URL}/verification-complete`,
vendor_data: userId,
}),
});
const session = await response.json();
// Store session_id associated with user
await db.users.update({
where: { id: userId },
data: { verificationSessionId: session.session_id },
});
res.json({
verificationUrl: session.url, // hosted verification URL
sessionToken: session.session_token, // for native/web SDKs
});
});
// Handle webhook — uses verifyWebhook / shortenFloats / sortKeys from Step 5
app.post(
"/api/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-Signature-V2");
const timestamp = req.get("X-Timestamp");
if (
!verifyWebhook(
req.body.toString("utf8"),
signature,
timestamp,
process.env.DIDIT_WEBHOOK_SECRET,
)
) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body);
const { event_id, session_id, status, vendor_data } = payload;
// Idempotency: the same event_id is reused on retries and fan-out.
// Skip events you have already processed (e.g. a processed_events table).
// Update user's verification status (status values are case-sensitive strings)
if (status === "Approved") {
db.users.update({
where: { id: vendor_data },
data: { isVerified: true, verifiedAt: new Date() },
});
}
// Return 2xx quickly (Didit's delivery timeout is 5s); do heavy work asynchronously
res.json({ received: true });
},
);
app.listen(3000);
Python / FastAPI
from fastapi import FastAPI, Request, HTTPException
import httpx
import hashlib
import hmac
import json
import os
import time
app = FastAPI()
@app.post("/api/verify")
async def create_verification(user_id: str):
async with httpx.AsyncClient() as client:
response = await client.post(
"https://verification.didit.me/v3/session/",
headers={
"x-api-key": os.environ["DIDIT_API_KEY"],
"Content-Type": "application/json"
},
json={
"workflow_id": os.environ["DIDIT_WORKFLOW_ID"],
"callback": f"{os.environ['APP_URL']}/verification-complete",
"vendor_data": user_id
}
)
session = response.json()
return {
"verification_url": session["url"], # hosted verification URL
"session_token": session["session_token"], # for native/web SDKs
}
def shorten_floats(data):
"""Match Didit: whole-valued floats serialise as ints."""
if isinstance(data, dict):
return {k: shorten_floats(v) for k, v in data.items()}
if isinstance(data, list):
return [shorten_floats(x) for x in data]
if isinstance(data, float) and data.is_integer():
return int(data)
return data
@app.post("/api/webhook")
async def handle_webhook(request: Request):
payload = await request.json()
signature = request.headers.get("X-Signature-V2")
timestamp = request.headers.get("X-Timestamp")
# Verify timestamp freshness
if not signature or not timestamp or abs(int(time.time()) - int(timestamp)) > 300:
raise HTTPException(status_code=401, detail="Request too old")
# Recompute Didit's V2 canonical JSON: shortened floats, sorted keys,
# compact separators, Unicode preserved (ensure_ascii=False)
canonical = json.dumps(
shorten_floats(payload),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
expected = hmac.new(
os.environ["DIDIT_WEBHOOK_SECRET"].encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
# Idempotency: dedupe on payload["event_id"] (reused on retries)
if payload["status"] == "Approved":
# Update user in database
pass
return {"received": True}
Session statuses
These are the 10 possible values of status — exact, case-sensitive strings (note Kyc Expired uses a single capital K):
| Status | Description |
|---|
Not Started | Session created but user hasn’t opened the link yet. |
In Progress | User is currently completing verification. |
In Review | Verification flagged for human review. |
Approved | All checks passed. |
Declined | One or more checks failed. |
Resubmitted | Reviewer requested specific steps to be redone. |
Awaiting User | KYB parent session waiting for child KYC parties to finish. |
Abandoned | User started but didn’t finish in the allotted time. |
Expired | Session timed out before the user opened the link. |
Kyc Expired | Previously approved session passed its KYC expiration policy. |
See Verification statuses for the full lifecycle, transitions, and Mermaid diagram.
Next steps
- Choose your integration method:
- Configure webhooks: Webhooks
- Plan capacity: Rate limiting
- Handle every status: Verification statuses
- Customize your workflow: Workflows
- Verify the API is reachable: Healthcheck
Need help?