API
API reference
IDotta v1 — identity verification service
Overview
IDotta lets tenant applications verify their end users remotely. Every verification runs on a session; documents and liveness data attach to that session.
Base URL
https://api.idotta.comAll endpoints live under this root.
Version
v1Path prefix: /v1
Format
JSON + multipartImage uploads are multipart.
Authentication
Two separate credentials are used.
Tenant → API
X-Api-Key: idv_live_<key>
Used on every call from the tenant backend. It must never reach a browser.
SDK → API
Authorization: Bearer <jwt>
The short-lived JWT from the POST /v1/sessions response. Scoped to that session and valid for 15 minutes (the session record lives 60 min).
Verification flow
Two flows are supported: the photo path (standard) and the NFC path.
Photo path
- 1Create sessionBackend → POST /sessions
- 2Show the SDKwith sdk_url + JWT
- 3MRZ / document infoSDK → access-keys
- 4Upload photosSDK → documents/images
- 5LivenessSDK → liveness/start+submit
- 6ResultWebhook or result
NFC path (mobile app)
- 1Create sessionBackend → POST /sessions
- 2Scan the MRZMobile → access-keys (mrz)
- 3Read the chipMobile → documents/nfc
- 4Passive authAPI: SOD + CSCA chain
- 5LivenessSDK → liveness/start+submit
- 6ResultWebhook or result
Modules and rules
The modules field in the POST /v1/sessions body decides which verification steps run. The SDKs read it from GET /v1/sessions/{id}/config and drive the flow accordingly.
Full flow
["document","liveness"]Document → liveness → approval (default)
Document only
["document"]Approved as soon as the document passes
Liveness only
["liveness"]Document steps are skipped
Reusable KYC
With reuse_if_verified: true, if the same applicant_external_id was approved within the last 180 days, has no AML alert and is in the verified state, the session returns approved without asking for a document or liveness. It removes all friction for a returning user.
Tenant rule engine
Under config.rules a tenant can only tighten the verification thresholds — never drop below the platform floor. It raises the bar for automatic approval on higher-risk use cases.
config.rules = {
"document_score_pass": 85, // default 70; lower values clamp to 70
"document_score_review": 55, // default 45
"liveness_min_confidence": 75 // default 60
}WebSDK integration
The WebSDK is embedded in the tenant page as an iframe. Session creation and API key handling stay entirely on the backend — nothing is exposed to the browser.
1. Create a session on your backend
POST /v1/sessions
X-Api-Key: idv_live_...
Content-Type: application/json
{ "applicant_external_id": "usr_abc123", "metadata": {} }
// Response:
{
"session_id": "550e8400-...",
"access_token": "eyJ...",
"sdk_url": "https://verify.idotta.com/session/550e8400-...?token=eyJ...",
"expires_at": "2026-09-20T01:00:00Z"
}2. Embed the iframe
<iframe
src="https://verify.idotta.com/session/{session_id}?token={access_token}"
allow="camera; microphone"
style="width:480px; height:640px; border:none;"
/>3. Listen for postMessage events
window.addEventListener("message", (e) => {
const ev = e.data;
switch (ev.type) {
case "sdk.ready": // SDK loaded, camera ready
break;
case "step.changed": // ev.step: "welcome" | "document_front"
break; // | "document_back" | "liveness" | "processing" | "result"
case "session.completed": // finished (approved or manual_review)
break; // ev.sessionId → GET /v1/sessions/{id}
case "session.failed": // rejected or expired (ev.reason)
break;
}
});Security notes
- The
access_tokentravels in the URL; HTTPS is mandatory. - The tenant API key (
X-Api-Key) must never be sent to the frontend — server side only. - Check
e.originwhen listening forpostMessage.
Mobile SDKs
Two SDKs are offered for native mobile apps. Both use the same API contract: the session is created on the tenant backend and only the session_id + access_token are handed to the app. The tenant API key never reaches the device.
Flutter — idotta_sdk
photo + NFC + livenessDocument capture, NFC chip reading (BAC + Secure Messaging) and active liveness.
React Native
photo + NFC + liveness@idotta/react-native-sdk. Document capture, NFC chip reading (BAC + Secure Messaging) and active liveness.
Flutter — usage
import 'package:idotta_sdk/idotta_sdk.dart'; // session_id + token come from your backend (X-Api-Key stays on the server) final result = await IdottaSdk.startVerification( context, baseUrl: 'https://api.idotta.com', sessionId: sessionId, token: accessToken, );
React Native — usage
import { IdottaVerification } from '@idotta/react-native-sdk';
<IdottaVerification
baseUrl="https://api.idotta.com"
sessionId={sessionId} // from your backend
token={accessToken} // short-lived JWT
onComplete={(result) => { /* 'approved' | 'rejected' | 'review' | 'expired' */ }}
onCancel={() => { /* the user left */ }}
/>The SDK result is for your user interface only. Always confirm the approve/reject decision from the server webhooks.
Headless (no SDK) API
Using an SDK is not required. The whole flow can run server to server over the REST API. The session is created with X-Api-Key; the following steps are called with the session-scoped short-lived Bearer JWT.
Your responsibility as the integrator
- You capture the images yourself: the document photos and, for liveness, a frame at each challenge plus a selfie.
- The challenge order comes from the server (
/liveness/start) and the server verifies the frames independently. - You receive the result over a webhook (recommended) or by polling
GET /result.
End-to-end example (document path)
# 1) Create the session — backend, X-Api-Key
curl -X POST https://api.idotta.com/v1/sessions \
-H "X-Api-Key: idv_live_..." -H "Content-Type: application/json" \
-d '{"applicant_external_id":"usr_abc","modules":["document"]}'
# 2) Document info — Bearer JWT
curl -X POST https://api.idotta.com/v1/sessions/$SID/access-keys \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"method":"manual","document_type":"passport"}'
# 3) Upload the photos — multipart
curl -X POST https://api.idotta.com/v1/sessions/$SID/documents/images \
-H "Authorization: Bearer $TOKEN" -F "front=@front.jpg" -F "back=@back.jpg"
# 4) Read the result (webhook recommended)
curl https://api.idotta.com/v1/sessions/$SID/result -H "Authorization: Bearer $TOKEN"Liveness step (headless)
# start → the challenge order comes from the server
curl -X POST https://api.idotta.com/v1/sessions/$SID/liveness/start \
-H "Authorization: Bearer $TOKEN"
# → { "challenge_id":"...", "challenges":["head_left","blink","head_right"] }
# submit → one frame per challenge + a neutral selfie (ORDER MATTERS)
curl -X POST https://api.idotta.com/v1/sessions/$SID/liveness/submit \
-H "Authorization: Bearer $TOKEN" -F "challenge_id=..." \
-F "frames=@head_left.jpg" -F "frames=@blink.jpg" -F "frames=@head_right.jpg" \
-F "selfie=@selfie.jpg"Notes
- The JWT is valid for 15 min — drive the flow right after you obtain it.
- The
framesorder must match thechallengesorder exactly. - The NFC path needs DG1/DG2/SOD; chip reading only works on mobile — NFC cannot be driven from a backend alone.
API endpoints
/v1/sessionsX-Api-KeyCreates a new verification session. Called from the tenant backend.
// Request
{
"applicant_external_id": "usr_abc123", // optional
"modules": ["document", "liveness"], // optional — both by default
"reuse_if_verified": false, // optional
"metadata": {}
}
// Response 201
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJ...", // JWT, 15 min
"sdk_url": "https://verify.idotta.com/session/...",
"status": "not_started", // verification status
"status_label": "Not Started",
"step": "created", // internal step, informational
"expires_at": "2026-09-20T01:00:00Z" // session lives 60 min
}/v1/sessions/{id}X-Api-KeyReturns the session status, its event history and metadata.
/v1/sessions/{id}/resultBearer JWTReturns the verification result and the steps that were passed.
// Response 200
{
"session_id": "550e8400-...",
"status": "approved",
"verification_path": "photo",
"checks": [
{ "name": "document_capture", "status": "passed" },
{ "name": "liveness", "status": "passed" }
],
"approved_at": "2026-09-20T00:45:00Z"
}/v1/sessions/{id}/access-keysBearer JWTSubmits MRZ or manual document details. Moves the session to document_pending.
// MRZ method
{
"method": "mrz",
"document_type": "id_card",
"document_country": "UTO",
"mrz_line1": "I<UTOD231458907<<<<<<<<<<<<<<<",
"mrz_line2": "7408122F1204159UTO<<<<<<<<<<<6",
"mrz_line3": "ERIKSSON<<ANNA<MARIA<<<<<<<<<<"
}
// Manual method (photo path)
{
"method": "manual",
"document_type": "passport", // id_card | passport | driving_license | residence_permit
"document_country": "KEN" // ISO 3166 alpha-3 of the issuing state
}document_type picks the document check profile (a passport or licence has no back side); always send it. Send document_country too: a calibrated profile is used where we have one for that country and type, otherwise the country-independent ICAO profile.
/v1/sessions/{id}/documents/imagesBearer JWTUploads the document front (required) and back (optional). multipart/form-data.
// multipart/form-data
front: <image/jpeg> // required
back: <image/jpeg> // optional (MRZ)
// Response 202
{ "status": "document_processing" }A worker starts the document analysis in the background: blur, ELA, face detection, OCR, MRZ validation, colour signature, contour detection.
/v1/sessions/{id}/documents/nfcBearer JWTAccepts NFC chip data from a mobile app. Passive authentication (SOD + CSCA chain) runs immediately.
{
"dg1_base64": "<base64>", // identity fields (MRZ equivalent)
"dg2_base64": "<base64>", // face image
"sod_base64": "<base64>", // signed document object
"chip_auth": "bac" // "bac" | "pace"
}
// Response 202
{ "status": "nfc_processing" }
// Error 422
{ "reason": "sod_signature_invalid" }
{ "reason": "sod_hash_mismatch", "dg1_ok": false, "dg2_ok": true }/v1/sessions/{id}/liveness/startBearer JWTStarts the active liveness challenge. Generates 3-4 random challenges per session.
// Response 200
{ "challenge_id": "abc123", "challenges": ["blink", "head_right", "head_left"] }The challenge order is decided by the server, which is what blocks replay attacks.
/v1/sessions/{id}/liveness/submitBearer JWTUploads the liveness data: one frame per challenge plus a final selfie. multipart/form-data.
// multipart/form-data
challenge_id: "abc123"
frames: [blob, blob, blob] // a frame captured at each challenge
selfie: <image/jpeg> // neutral face after the challenges
burst: [blob, ...] // optional — optical flow analysis
// Response 202
{ "status": "liveness_processing" }The worker's decision is composite: (1) face analysis per frame against its challenge type, (2) our in-house passive anti-spoofing model plus LBP/FFT/skin, (3) selfie ↔ document face match (in-house model), (4) frame consistency, (5) optical flow when a burst is present.
Verification statuses
Every session response and webhook sent to the tenant carries three fields: status (the key — your code checks this), status_label (a fixed, human-readable display name) and step (the internal step, informational). A rejection with retries left is in_progress; declined is final.
| STATUS | DESCRIPTION |
|---|---|
| not_started | Not Started — session opened, the user has not opened the link |
| in_progress | In Progress — the user is going through the steps; a rejected step can be retried |
| resubmitted | Resubmitted — an analyst asked for some steps to be redone, the user has not finished yet |
| in_review | In Review — awaiting a human decision; does not expire |
| approved | Approved — verification complete, approved |
| kyc_expired | Kyc Expired — the approval is older than 365 days, request re-verification |
| declined | Declined — final decline: an analyst declined, retries ran out, or time ran out with retries left |
| expired | Expired — time ran out before the user started (default: 60 min) |
| abandoned | Abandoned — the user started but time ran out before they finished |
The decision object (on approved, declined, in_review and kyc_expired; null otherwise) carries the check results and the reason codes. decided_by: auto (engine) · manual (analyst) · reuse (reuse). Reason text and parameters are not sent (they may carry personal data); do not show the codes to the end user as they are.
{
"decided_by": "auto",
"document_score": 62.5,
"checks": [
{ "name": "mrz_checksum", "status": "passed", "reason_code": null },
{ "name": "expiry", "status": "failed", "reason_code": "expiry.expired" },
{ "name": "liveness", "status": "passed", "reason_code": null }
],
"warnings": ["expiry.expired"]
}SDK endpoints (/result, document and liveness responses) return the internal step: created → document_pending → document_processing | nfc_processing → liveness_pending → liveness_processing → approved | review | rejected | expired. In tenant responses this value is step. Every step transition is written to the session_events table (audit log).
Webhooks
Every session event POSTs to the tenant's registered webhook_url. Failed deliveries are retried at increasing intervals.
Signature verification
// Headers
X-Signature: sha256=<hex_digest>
X-Event-Id: <uuid>
X-Event-Type: session.approved
// Verifying in Python
import hmac, hashlib
expected = hmac.new(WEBHOOK_SECRET.encode(), request.body, hashlib.sha256).hexdigest()
assert hmac.compare_digest(expected, x_signature.removeprefix("sha256="))| EVENT | FIRES WHEN |
|---|---|
| document.verified | The document analysis passes |
| document.rejected | The document analysis fails — status: in_progress in the body = retryable, declined = final |
| document.review_required | The document goes to manual review |
| liveness.failed | Liveness fails (same status rule) |
| session.approved | Final approval: automatic, manual or reuse (reused: true) |
| session.rejected | Final decline (status: declined): an analyst decision, an automatic decline with no retries left, or time running out with retries left |
| session.manual_review | The session goes to manual review |
| session.expired | Time ran out: status: expired (never started) or abandoned (started, did not finish); a session in review does not expire |
| applicant.aml_alert | Ongoing monitoring: a NEW list match on an approved applicant |
| session.resubmitted | An analyst asked for step(s) to be redone (resubmit_info); send the user a new link with POST /v1/sessions/{id}/sdk-link |
| session.kyc_expired | An approved verification reached 365 days (status: kyc_expired); scanned once a day |
Example payload
{
"event_type": "session.approved",
"event_id": "a1b2c3d4-...",
"session_id": "550e8400-...",
"status": "approved",
"status_label": "Approved",
"step": "approved",
"environment": "production",
"decision": { "decided_by": "auto", "document_score": 91.0, "checks": [ … ], "warnings": [] }
}