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

All endpoints live under this root.

Version

v1

Path prefix: /v1

Format

JSON + multipart

Image uploads are multipart.

Authentication

Two separate credentials are used.

X-Api-Key: idv_live_<key>

Used on every call from the tenant backend. It must never reach a browser.

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.

  1. 1Create sessionBackend → POST /sessions
  2. 2Show the SDKwith sdk_url + JWT
  3. 3MRZ / document infoSDK → access-keys
  4. 4Upload photosSDK → documents/images
  5. 5LivenessSDK → liveness/start+submit
  6. 6ResultWebhook or result
  1. 1Create sessionBackend → POST /sessions
  2. 2Scan the MRZMobile → access-keys (mrz)
  3. 3Read the chipMobile → documents/nfc
  4. 4Passive authAPI: SOD + CSCA chain
  5. 5LivenessSDK → liveness/start+submit
  6. 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

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.

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.

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"
}
<iframe
  src="https://verify.idotta.com/session/{session_id}?token={access_token}"
  allow="camera; microphone"
  style="width:480px; height:640px; border:none;"
/>
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;
  }
});
  • The access_token travels 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.origin when listening for postMessage.

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 + liveness

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

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,
);
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.

  • 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.
# 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"
# 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"
  • The JWT is valid for 15 min — drive the flow right after you obtain it.
  • The frames order must match the challenges order exactly.
  • The NFC path needs DG1/DG2/SOD; chip reading only works on mobile — NFC cannot be driven from a backend alone.

API endpoints

POST/v1/sessionsX-Api-Key

Creates 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
}
GET/v1/sessions/{id}X-Api-Key

Returns the session status, its event history and metadata.

GET/v1/sessions/{id}/resultBearer JWT

Returns 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"
}
POST/v1/sessions/{id}/access-keysBearer JWT

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

POST/v1/sessions/{id}/documents/imagesBearer JWT

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

POST/v1/sessions/{id}/documents/nfcBearer JWT

Accepts 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 }
POST/v1/sessions/{id}/liveness/startBearer JWT

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

POST/v1/sessions/{id}/liveness/submitBearer JWT

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

STATUSDESCRIPTION
not_startedNot Started — session opened, the user has not opened the link
in_progressIn Progress — the user is going through the steps; a rejected step can be retried
resubmittedResubmitted — an analyst asked for some steps to be redone, the user has not finished yet
in_reviewIn Review — awaiting a human decision; does not expire
approvedApproved — verification complete, approved
kyc_expiredKyc Expired — the approval is older than 365 days, request re-verification
declinedDeclined — final decline: an analyst declined, retries ran out, or time ran out with retries left
expiredExpired — time ran out before the user started (default: 60 min)
abandonedAbandoned — 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.

// 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="))
EVENTFIRES WHEN
document.verifiedThe document analysis passes
document.rejectedThe document analysis fails — status: in_progress in the body = retryable, declined = final
document.review_requiredThe document goes to manual review
liveness.failedLiveness fails (same status rule)
session.approvedFinal approval: automatic, manual or reuse (reused: true)
session.rejectedFinal decline (status: declined): an analyst decision, an automatic decline with no retries left, or time running out with retries left
session.manual_reviewThe session goes to manual review
session.expiredTime ran out: status: expired (never started) or abandoned (started, did not finish); a session in review does not expire
applicant.aml_alertOngoing monitoring: a NEW list match on an approved applicant
session.resubmittedAn 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_expiredAn approved verification reached 365 days (status: kyc_expired); scanned once a day
{
  "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": [] }
}