> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Integration Prompt

> One copy-paste prompt that integrates Didit end-to-end — account creation, SDK install, workflows, Session API, and HMAC webhook verification.

export const DIDIT_INTEGRATION_PROMPT = `# Integrate Didit into my application

You are integrating Didit — infrastructure for identity and fraud — into my application end-to-end. Didit ships KYC, KYB, AML screening, biometric verification, transaction monitoring, and wallet screening behind one API. 220+ countries, 14,000+ document types, 48+ languages. Pay-per-call from $0.30, 500 free verifications per core feature per month, no minimums, no contract.

## My application context

<my_stack>
Stack: [framework + language — e.g. Next.js 15 + TypeScript, Django, Rails, Go]
Surface: [web | iOS | Android | React Native | Flutter | backend-only]
Use case: [KYC at signup | KYB at business onboarding | age gate | re-auth | transaction monitoring | other]
Database: [Postgres | MySQL | Mongo | other]
</my_stack>

## Step 1 — Create the Didit account programmatically

Two API calls. No browser. No 2FA. Returns an \`api_key\` you use for every subsequent verification API request.

\`\`\`bash
# 1) Register — creates a pending account + emails a 6-character code (10 min TTL).
#    Password rules: ≥8 chars, 1 uppercase, 1 lowercase, 1 digit, 1 special (!@#$%^&*()_+-=[]{}|;:,.<>?)
#    IMPORTANT: substitute "you@yourdomain.com" with a real inbox. Reserved test domains
#    (@example.com, @example.org, @*.test, @*.example, @*.invalid) are refused by mail delivery and the call returns 500.
curl -X POST https://apx.didit.me/auth/v2/programmatic/register/ \
  -H "Content-Type: application/json" \
  -d '{"email": "you@yourdomain.com", "password": "StrongP@ss1"}'
# 201 -> {"message": "Registration successful...", "email": "you@yourdomain.com"}

# 2) Verify email with the code from the inbox — returns api_key, JWT tokens, org, app.
curl -X POST https://apx.didit.me/auth/v2/programmatic/verify-email/ \
  -H "Content-Type: application/json" \
  -d '{"email": "you@yourdomain.com", "code": "A3K9F2"}'
# 200 -> {
#   "access_token": "...", "refresh_token": "...", "expires_in": 86400,
#   "organization": { "uuid": "..." },
#   "application":  { "uuid": "...", "client_id": "...", "api_key": "..." }
# }
# Persist response.application.api_key — this is your only long-lived secret.
\`\`\`

Persist the returned \`api_key\` as the env var \`DIDIT_API_KEY\`. Auth header for every verification API call:
\`x-api-key: $DIDIT_API_KEY\`

If you lose the key, log in again with \`POST /programmatic/login/\` (same body as register) → \`GET /organizations/me/\` (gives org_id) → \`GET /organizations/me/{org_id}/applications/\` (lists apps; gives app_id) → \`GET /organizations/me/{org_id}/applications/{app_id}/\` and read \`api_key\` from the response. If your email verification code expired, request a fresh one with \`POST /programmatic/resend-otp/\` (email + password; 60s cooldown) or simply re-register (returns 200 with a new code).

## Base URLs

| Surface | Base URL |
|---|---|
| Verification API — every \`/v3/...\` endpoint (sessions, workflows, lists, billing, webhook destinations, entities, transactions, standalone APIs) | \`https://verification.didit.me\` |
| Auth / account-management API (\`/auth/v2/programmatic/...\`, \`/auth/v2/organizations/...\`) | \`https://apx.didit.me\` |
| Console (human UI) | \`https://business.didit.me\` |
| Hosted verification URL handed to end users | \`https://verify.didit.me\` |
| Docs (cross-check anything) | \`https://docs.didit.me\` |
| OpenAPI spec (verification API) | \`https://docs.didit.me/openapi-25.json\` |
| OpenAPI spec (auth + apps) | \`https://docs.didit.me/openapi-auth.json\` |

## Step 2 — Choose the integration approach

Pick exactly one, based on my use case:

- **Approach A — Sessions API + SDK (recommended for any end-user verification flow).** My backend creates a Didit session → my frontend opens the Didit-hosted verification UI (via SDK / iframe / redirect) → user completes verification → my backend receives a webhook with the decision. Best for KYC at signup, KYB at business onboarding, age verification, re-auth. Didit-hosted flows are A/B-tested and convert higher than custom UIs.
- **Approach B — Standalone APIs (server-to-server only).** My backend calls individual modules directly (\`/v3/id-verification/\`, \`/v3/aml/\`, \`/v3/face-match/\`, etc.) for batch jobs or fully custom UI. Best for back-office verification pipelines.

If unsure, default to Approach A.

## Step 3 — Create or reuse a workflow

A workflow defines which modules run during a session and the per-feature thresholds. Create one via API (or \`https://business.didit.me\` → Workflows → Create).

\`\`\`bash
# List existing workflows (newest first; paginated envelope {count, next, previous, results}, page size 50).
curl https://verification.didit.me/v3/workflows/ \
  -H "x-api-key: $DIDIT_API_KEY"

# Create a new KYC workflow.
# - workflow_label and features are REQUIRED; the v3 API rejects any unknown
#   body field with 400 (there is NO workflow_type field — see KYB note below).
# - features[] entries are objects: { "feature": "<UPPERCASE_ENUM>", "config": { ... } }
# - The config block is optional per feature; sensible defaults apply.
# - Defaults to a published v1 workflow, ready for sessions. Not idempotent —
#   each call creates a new workflow.
curl -X POST https://verification.didit.me/v3/workflows/ \
  -H "x-api-key: $DIDIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_label": "Standard KYC",
    "features": [
      { "feature": "OCR" },
      { "feature": "LIVENESS", "config": { "face_liveness_method": "PASSIVE" } },
      { "feature": "FACE_MATCH" },
      { "feature": "IP_ANALYSIS" }
    ]
  }'
# 201 -> the full workflow record. Save response.workflow_id (also exposed as
# response.uuid) as DIDIT_WORKFLOW_ID — it's what you pass to POST /v3/session/.
\`\`\`

Feature enum values (UPPERCASE, source: \`management-api/workflows/feature-configs.mdx\`):
- **KYC features:** \`OCR\`, \`NFC\`, \`LIVENESS\` (\`face_liveness_method\`: \`PASSIVE\` | \`ACTIVE_3D\` | \`FLASHING\`), \`FACE_MATCH\`, \`AGE_ESTIMATION\`, \`PHONE_VERIFICATION\`, \`EMAIL_VERIFICATION\`, \`DATABASE_VALIDATION\`, \`AML\`, \`IP_ANALYSIS\`, \`PROOF_OF_ADDRESS\`, \`QUESTIONNAIRE\`.
- **KYB features:** \`KYB_REGISTRY\`, \`KYB_DOCUMENTS\`, \`KYB_KEY_PEOPLE\` (each plus \`AML\` / \`DATABASE_VALIDATION\` as needed).

There is **no** \`workflow_type\` request field — including any KYB feature (\`KYB_REGISTRY\`, \`KYB_DOCUMENTS\`, \`KYB_KEY_PEOPLE\`) automatically makes the workflow KYB; sending \`workflow_type\` (or any other unknown field) returns 400. Keep KYC and KYB as separate workflows.

## Step 4 — Install the SDK that matches my stack

| Stack | Package | Install |
|---|---|---|
| Web (React, Vue, Next.js, Nuxt, Svelte, vanilla JS) | \`@didit-protocol/sdk-web\` | \`npm install @didit-protocol/sdk-web\` |
| iOS (Swift / SwiftUI) | \`sdk-ios\` | SPM: \`https://github.com/didit-protocol/sdk-ios\` |
| Android (Kotlin / Jetpack Compose) | \`me.didit:didit-sdk\` | Maven Central |
| React Native (Expo or bare) | \`@didit-protocol/sdk-react-native\` | \`npm install @didit-protocol/sdk-react-native\` |
| Flutter | \`didit_sdk\` | \`flutter pub add didit_sdk\` |
| Backend-only / batch / custom UI | none — call REST directly | — |

Always prefer native iOS / Android SDKs over WebView on mobile (NFC + camera + biometrics work natively).

## Step 5 — Create a session and present it to the user

**Backend creates the session:**

\`\`\`bash
curl -X POST https://verification.didit.me/v3/session/ \
  -H "x-api-key: $DIDIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_id": "'"$DIDIT_WORKFLOW_ID"'",
    "vendor_data": "internal-user-id",
    "callback": "https://myapp.com/done"
  }'
\`\`\`

Successful response (201 Created — always exactly these 10 keys):
\`\`\`json
{
  "session_id": "11111111-2222-3333-4444-555555555555",
  "session_number": 43762,
  "session_token": "3FaJ9wLqX2Mz",
  "url": "https://verify.didit.me/session/3FaJ9wLqX2Mz",
  "vendor_data": "internal-user-id",
  "metadata": null,
  "status": "Not Started",
  "workflow_id": "11111111-2222-3333-4444-555555555555",
  "workflow_version": 3,
  "callback": "https://myapp.com/done"
}
\`\`\`

- \`session_token\` is a **12-character URL-safe token** (not a JWT) embedded in \`url\`. Treat it as a secret — anyone holding it can open this session's verification flow.
- After the user finishes, Didit redirects to \`callback\` with \`?verificationSessionId={session_id}&status={status}\` appended.
- **Idempotent reuse:** if an unfinished session (\`Not Started\` / \`In Progress\` / \`Resubmitted\` / \`Awaiting User\`) with the same \`vendor_data\` already exists on the workflow's latest published version, that session is returned (still \`201\`) with its \`callback\` and \`metadata\` updated to the new values — retrying never creates duplicates. Finished sessions (\`Approved\`, \`Declined\`, \`In Review\`, \`Expired\`, \`Abandoned\`, \`Kyc Expired\`) are never reused.

\`vendor_data\` is **my internal user ID** — Didit links the session to a User entity (auto-created if new). For KYB workflows it links to a Business entity.

**Optional create-session fields:**

| Field | Type | Notes |
|---|---|---|
| \`callback_method\` | \`"initiator"\` \\| \`"completer"\` \\| \`"both"\` | Which side of a cross-device flow receives the \`callback\` redirect. Default \`"initiator"\`; set \`"both"\` if the callback isn't triggering reliably. |
| \`metadata\` | object | Arbitrary JSON; echoed back on every webhook for this session. |
| \`language\` | ISO 639-1 string | Locks the hosted UI to that language (defaults to browser detection). When set, the returned \`url\` gains a language path segment: \`https://verify.didit.me/{language}/session/{session_token}\`. |
| \`contact_details\` | \`{ email, send_notification_emails, email_lang, phone }\` | Lets Didit email the user the hosted-flow link and prefill email/phone verification steps. \`phone\` must be E.164. \`phone\` is ignored for KYB workflows. |
| \`expected_details\` | KYC: \`{ first_name, last_name, date_of_birth, gender, nationality, country, id_country, poa_country, address, identification_number, ip_address, expected_document_types }\` — KYB: \`{ company_name, registry_country, registration_number }\` | KYC: cross-validates extracted document data against what your app already knows (mismatches surface as warnings). \`expected_document_types\` restricts which documents the user may present — codes: \`P\`, \`ID\`, \`DL\`, \`RP\`, \`HIC\`, \`TC\`, \`SSC\`. \`address\` requires \`country\` or \`poa_country\`, else 400. KYB: the business fields pre-fill the hosted company search (locked country, editable name and number fields) and the asserted search runs automatically, combining both identifiers when both are set — prefill only, no mismatch warnings. \`registry_country\` is \`XX\` or \`XX-YY\` for state-level registries (e.g. \`US-CA\`). Person-level fields are ignored for KYB. |
| \`portrait_image\` | base64 string | Reference face (max 2MB; JPEG/PNG/WebP/TIFF). Required only for Biometric Authentication / Face-Match-first workflows; ignored otherwise. |
| \`sandbox_scenario\` | string | Sandbox apps only (e.g. \`"approve"\`, \`"decline_aml_hit"\`) — auto-populates magic inputs to force that outcome. 400 on live apps. |

**Auth failure shape.** On the session, workflow, and standalone-API endpoints, a missing, malformed, expired, or wrong-app \`x-api-key\` always returns **HTTP 403** (never 401) with body:
\`\`\`json
{ "detail": "You do not have permission to perform this action." }
\`\`\`
There is no machine-readable discriminator — handle missing/expired/wrong-app cases uniformly by re-checking your env var or rotating the key in the console. (Exception: the management families — \`/v3/users/\`, \`/v3/businesses/\`, \`/v3/transactions/\`, \`/v3/billing/\`, \`/v3/webhook/destinations/\` — return **401** for a missing/malformed key and reserve **403** for a valid key lacking permission. Treat any 401/403 as "fix the key".)

**Frontend presents the verification (pick one):**

| Pattern | Code |
|---|---|
| Web JS SDK (modal) | \`import { DiditSdk } from "@didit-protocol/sdk-web"; DiditSdk.shared.startVerification({ url })\` |
| Iframe (embedded) | \`<iframe src={url} allow="camera; microphone; fullscreen; autoplay; encrypted-media" />\` |
| Redirect (cross-device) | \`window.location.href = url\` |
| iOS / Android / RN / Flutter | \`DiditSdk.shared.startVerification(token: sessionToken)\` (iOS; each SDK has the equivalent \`startVerification\` — token = the create-session \`session_token\`, never the API key) |

## Step 6 — Set up the webhook to receive results

Build \`POST /api/webhooks/didit\` in my backend.

**Register the webhook destination once:**

\`\`\`bash
curl -X POST https://verification.didit.me/v3/webhook/destinations/ \
  -H "x-api-key: $DIDIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Production session webhooks",
    "url": "https://myapp.com/api/webhooks/didit",
    "webhook_version": "v3",
    "subscribed_events": ["status.updated", "data.updated"]
  }'
\`\`\`

Response includes \`secret_shared_key\` — save as \`DIDIT_WEBHOOK_SECRET\` immediately. Use \`webhook_version: "v3"\` for all new integrations (plural arrays). The \`(application, url)\` pair must be unique — POSTing the same URL twice returns 400.

**Cloudflare / restrictive firewall users:** allowlist \`18.203.201.92\` (Didit's webhook egress IP, User-Agent \`DiditWebhook/2.0 +https://didit.me\`). HTTPS only, public hostnames only (no localhost / private CIDRs — SSRF guard).

**Three signature headers** ship on every webhook. Verify **one** — \`X-Signature-V2\` is recommended because it survives JSON middleware re-encoding:

| Header | What it signs | When to use |
|---|---|---|
| \`X-Signature-V2\` ★ recommended | \`JSON.stringify(sortKeys(shortenFloats(parsed_body)))\` with unescaped Unicode | Default — works through Express / Django / FastAPI / Next.js body parsers |
| \`X-Signature\` | The raw request bytes verbatim (\`sort_keys=True, ensure_ascii=True\`) | Only if you can guarantee no middleware re-encodes the body |
| \`X-Signature-Simple\` | \`"{timestamp}:{session_id}:{status}:{webhook_type}"\` | Fallback when nothing else works — does NOT authenticate the \`decision\` payload, so treat any decision data as untrusted unless you re-fetch via \`GET /v3/session/{id}/decision/\` |

**Endpoint requirements (in order):**

1. Parse JSON (V2 is parser-tolerant; you can use any framework's default body parser).
2. Read headers: \`X-Signature-V2\` (HMAC-SHA256 hex), \`X-Timestamp\` (Unix seconds).
3. Reject if \`abs(now - X-Timestamp) > 300\` (replay protection).
4. Apply \`shortenFloats\` (whole-number floats → integers), then \`sortKeys\` (recursive lexicographic), then \`JSON.stringify\` (unescaped Unicode — default in JS).
5. Compute \`expected = HMAC-SHA256(DIDIT_WEBHOOK_SECRET, canonical_json, "utf8")\`.
6. Compare with \`X-Signature-V2\` using constant-time comparison (\`hmac.compare_digest\` in Python, \`crypto.timingSafeEqual\` in Node).
7. Dispatch on \`webhook_type\`. Return \`2xx\` within 5 seconds — Didit's delivery timeout is 5s, then it retries 5xx, 404, timeouts, and connection failures twice (~1 min, ~4 min). 3xx and other 4xx are never retried. Move heavy work to a queue.

**Node.js / Next.js App Router example (canonical V2):**

\`\`\`ts
import crypto from "node:crypto";

// Server-side normalisation: convert whole-number floats to integers.
function shortenFloats(v: unknown): unknown {
  if (Array.isArray(v)) return v.map(shortenFloats);
  if (v && typeof v === "object") {
    return Object.fromEntries(
      Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, shortenFloats(x)])
    );
  }
  if (typeof v === "number" && typeof v === "number" && v % 1 === 0) return Math.trunc(v);
  return v;
}

// Recursively sort object keys (arrays preserved in order).
function sortKeys(v: unknown): unknown {
  if (Array.isArray(v)) return v.map(sortKeys);
  if (v && typeof v === "object") {
    return Object.keys(v as object)
      .sort()
      .reduce<Record<string, unknown>>((acc, k) => {
        acc[k] = sortKeys((v as Record<string, unknown>)[k]);
        return acc;
      }, {});
  }
  return v;
}

export async function POST(req: Request) {
  const raw = await req.text();
  const sig = req.headers.get("x-signature-v2") ?? "";
  const ts = Number(req.headers.get("x-timestamp"));
  if (!ts || Math.abs(Date.now() / 1000 - ts) > 300)
    return new Response("stale", { status: 401 });

  const parsed = JSON.parse(raw);
  const canonical = JSON.stringify(sortKeys(shortenFloats(parsed)));
  const expected = crypto
    .createHmac("sha256", process.env.DIDIT_WEBHOOK_SECRET!)
    .update(canonical, "utf8")
    .digest("hex");
  if (
    sig.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
  )
    return new Response("bad sig", { status: 401 });

  // Idempotency: dedupe on parsed.event_id — it is STABLE per event (the same
  // id is reused on retries and sent to every destination the event fans out to).
  if (await alreadyProcessed(parsed.event_id)) return new Response("ok");
  await markProcessed(parsed.event_id);

  // parsed.webhook_type → dispatch …
  return new Response("ok");
}
\`\`\`

**Webhook event types** (use exact strings in \`subscribed_events\` — no wildcard; these nine are the complete set):
\`status.updated\`, \`data.updated\`, \`user.status.updated\`, \`user.data.updated\`, \`business.status.updated\`, \`business.data.updated\`, \`activity.created\` (reserved — accepted in subscriptions and sent by Try Webhook tests, but not delivered for live traffic), \`transaction.created\`, \`transaction.status.updated\`.

**Session webhook envelope (V3):**
\`\`\`json
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "session_id": "uuid",
  "status": "Approved",
  "workflow_id": "uuid",
  "workflow_version": 4,
  "vendor_data": "internal-user-id",
  "metadata": { "any": "json" },
  "decision": { /* present on Approved | Declined | In Review | Abandoned */ }
}
\`\`\`

\`environment\` is \`"live"\` or \`"sandbox"\` — check it before mutating production state. \`timestamp\` is refreshed on every retry; \`event_id\` is not. KYB sessions additionally include \`business_session_id\`, \`session_kind: "business"\`, and (when supplied) \`vendor_business_id\`. \`status: "Resubmitted"\` carries \`resubmit_info: { nodes_to_resubmit: [{ node_id, feature }], reasons: { "<node_id>": "why" } }\` instead of \`decision\`.

The literal session status strings are exactly: \`"Not Started"\`, \`"In Progress"\`, \`"Awaiting User"\`, \`"In Review"\`, \`"Approved"\`, \`"Declined"\`, \`"Resubmitted"\`, \`"Abandoned"\`, \`"Expired"\`, \`"Kyc Expired"\` (mixed-case literal). Compare case-sensitively.

**The \`decision\` object holds per-module plural arrays** (V3 schema). Every array item carries its workflow-graph \`node_id\` **and its own feature-level \`status\`** (\`Not Finished\`, \`Approved\`, \`Declined\`, \`In Review\`, \`Resub Requested\`; phone/email/ID steps can also be \`Expired\`). A block is \`null\` until that feature has run at least once. Key fields per array:
- \`id_verifications[]\`: \`first_name\`, \`last_name\`, \`document_type\`, \`document_subtype\`, \`document_number\`, \`personal_number\`, \`date_of_birth\`, \`age\`, \`gender\`, \`nationality\`, \`expiration_date\`, \`date_of_issue\`, \`issuing_state\`, \`address\`, \`parsed_address\`, \`mrz\`, \`portrait_image\`, \`front_image\`, \`back_image\`, \`front_image_quality_score\`, \`back_image_quality_score\`, \`warnings[]\`
- \`nfc_verifications[]\`: \`status\`, \`is_nfc_skipped\`, \`skip_reason\`, \`portrait_image\`, \`signature_image\`, \`chip_data\` (chip-read personal data: \`document_number\`, \`document_type\`, \`birth_date\`, \`expiry_date\`, \`mrz_string\`, …), \`authenticity\` (\`{ sod_integrity, dg_integrity }\` — both \`true\` means the chip is genuine), \`certificate_summary\`. The decision also carries a top-level \`nfc_skip_reason\` (\`USER_SKIPPED\` | \`DOCUMENT_WITHOUT_CHIP\` | \`CHIP_CERTIFICATE_UNAVAILABLE\` | \`DEVICE_WITHOUT_NFC\` | \`INTEGRATION_WITHOUT_NFC_ACCESS\` | \`MRZ_KEY_UNAVAILABLE\`, or \`null\`) stating why NFC did not run — including when the step was bypassed and \`nfc_verifications\` stays \`null\`.
- \`liveness_checks[]\`: \`status\`, \`score\` (0–100), \`method\` (\`PASSIVE\` | \`ACTIVE_3D\` | \`FLASHING\` — same uppercase enum as the workflow config), \`reference_image\`, \`video_url\` (active modes only), \`age_estimation\`, \`face_quality\`, \`face_luminance\`
- \`face_matches[]\`: \`status\`, \`score\` (0–100), \`source_image\`, \`target_image\`, \`source_image_session_id\`, \`warnings[]\`
- \`phone_verifications[]\`: \`status\`, \`full_number\` (E.164; also split as \`phone_number_prefix\` + \`phone_number\`), \`carrier\` (\`{ name, type }\`), \`is_disposable\`, \`is_virtual\`, \`verification_method\`
- \`email_verifications[]\`: \`status\`, \`email\`, \`is_breached\`, \`is_disposable\`, \`is_undeliverable\`, \`breaches[]\`
- \`poa_verifications[]\`: \`status\`, \`document_type\`, \`issuer\`, \`poa_address\`, \`poa_parsed_address\`, \`issue_date\`, \`expiration_date\`
- \`aml_screenings[]\`: \`status\`, \`score\` (0–100), \`total_hits\`, \`entity_type\` (\`"person"\` | \`"company"\`), \`hits[]\` (each: \`match_score\`, \`risk_score\`, \`datasets[]\`, \`pep_matches\`, \`sanction_matches\`, \`warning_matches\`, \`adverse_media_matches\`, \`review_status\`)
- \`ip_analyses[]\`: \`status\`, \`ip_address\`, \`ip_country\`, \`ip_country_code\`, \`ip_city\`, \`isp\`, \`is_vpn_or_tor\`, \`is_data_center\`, \`device_fingerprint\`, \`platform\`, \`time_zone\`
- \`database_validations[]\`: \`status\`, \`issuing_state\`, \`validation_type\` (\`one_by_one\` | \`two_by_two\` | \`not_enabled\`), \`match_type\` (\`full_match\` | \`partial_match\` | \`no_match\`), \`screened_data\`, \`validations[]\` (each: \`service_id\`, \`service_name\`, \`outcome_code\`, \`source_data\`)
- \`questionnaire_responses[]\`: also a plural array (\`null\` until a questionnaire runs) — each item is a full submission: \`questionnaire_id\`, \`title\`, \`status\`, \`sections[]\` with per-item \`answer\`
- \`reviews[]\`: manual-review audit trail — \`user\`, \`new_status\`, \`comment\`, \`created_at\`
- KYB-only: \`registry_checks[]\`, \`document_verifications[]\`, \`key_people_checks[]\`

All media values (\`*_image\`, \`video_url\`, \`document_file\`, …) are **short-lived presigned URLs** — download what you need promptly and never persist the URLs themselves; re-fetch \`GET /v3/session/{sessionId}/decision/\` for fresh links. For the full per-feature field reference see \`https://docs.didit.me/reference/data-models\`.

V2 → V3 migration: V2 used singular keys (\`aml\`, \`phone\`, \`email\`, \`poa\`, \`id_verification\`, \`nfc\`, \`liveness\`, \`face_match\`). V3 renamed them to plural arrays so a single workflow can run the same module multiple times on different nodes — always index by \`node_id\`. Do **not** read the legacy singular fields; they only ship when a destination is explicitly pinned to \`webhook_version: "v2"\`.

## Step 7 — Apply the decision to my database

\`\`\`ts
switch (event.status) {
  case "Approved":       user.verified = true; user.verifiedAt = new Date(); storeDecision(event.decision); break;
  case "Declined":       user.verificationStatus = "declined"; logWarnings(event.decision); break;
  case "In Review":      user.verificationStatus = "pending_review"; break;
  case "In Progress":    user.verificationStatus = "in_progress"; break;
  case "Awaiting User":  user.verificationStatus = "awaiting_user"; break; // KYB only — waiting for a UBO / officer KYC sub-session
  case "Resubmitted":    reopenNodes(event.resubmit_info.nodes_to_resubmit); break; // reviewer asked the user to retry specific steps
  case "Abandoned":      scheduleReminderEmail(user); break; // decision may still be present with partial data
  case "Expired":        break; // session URL aged out before the user finished
  case "Kyc Expired":    user.verified = false; createNewSession(user); break; // verified user's KYC has aged out per retention policy
  case "Not Started":    break;
}
\`\`\`

Idempotency:
- **Dedupe on \`event_id\`** — it is stable per event: the same id is reused on every retry and sent to every destination the event fans out to. Fallback compound key if you don't store \`event_id\`: \`session_id + status + webhook_type\`. Never key on \`timestamp\` — it is refreshed on each retry.
- Transaction webhooks (\`transaction.created\`, \`transaction.status.updated\`): same — dedupe on \`event_id\` (multiple events can target the same \`transaction_id\`).

Retry policy: on 5xx, 404, timeout, or connection failure Didit retries up to **2 times** with backoff (~1 min, then ~4 min after the first retry); 3xx and other 4xx are not retried. The outbound HTTP timeout is 5 seconds. After the second retry the delivery is dropped — replay from the **Deliveries** tab in the console if needed. Always return \`2xx\` immediately, even if processing is async.

After handling the webhook you can call \`GET /v3/session/{session_id}/decision/\` at any time — it returns the same decision (plus fresh presigned media URLs), and \`session_kind\` (\`"user"\` | \`"business"\`) tells you which shape you received.

## Module catalogue (use whichever ones match my use case)

| Module | Endpoint | Price | Free tier |
|---|---|---|---|
| Core KYC bundle (ID + Liveness + Face Match + IP) | session w/ workflow | **$0.30** | 500/mo (per feature) |
| ID Verification (standalone) | \`POST /v3/id-verification/\` | $0.20 | 500/mo |
| Passive Liveness (standalone) | \`POST /v3/passive-liveness/\` | $0.05 | 500/mo |
| Active Liveness (\`ACTIVE_3D\` / \`FLASHING\`) | session-only | $0.15 | — |
| Face Match 1:1 | \`POST /v3/face-match/\` | $0.05 | 500/mo |
| Face Search 1:N | \`POST /v3/face-search/\` | $0.05 | — |
| Age Estimation | \`POST /v3/age-estimation/\` | $0.10 | — |
| AML Screening | \`POST /v3/aml/\` | $0.20 | — |
| Ongoing AML Monitoring | org-level continuous (per active user/yr) | $0.07 / user / year | — |
| Database Validation (gov registries) | \`POST /v3/database-validation/\` | variable | — |
| Proof of Address | \`POST /v3/poa/\` | $0.20 | — |
| Email Verification | \`POST /v3/email/send/\` + \`POST /v3/email/check/\` | $0.03 | — |
| Phone Verification (SMS / WhatsApp / voice / RCS / Telegram) | \`POST /v3/phone/send/\` + \`POST /v3/phone/check/\` | $0.04 + carrier | — |
| NFC Verification (native SDKs only) | session module | $0.15 | — |
| Biometric Authentication (re-auth) | session module | $0.10 | — |
| Device & IP Analysis | session module | $0.03 | — |
| Custom Questionnaire | session module | $0.10 | — |
| Business Verification (KYB) | \`POST /v3/session/\` w/ KYB workflow | $2.00 | — |
| Company AML Screening | KYB module | $0.20 | — |
| Person AML (per UBO / officer) | KYB module | $0.20 | — |
| Transaction Screening (rule engine) | \`POST /v3/transactions/\` | $0.02 / tx | — |
| AML Transaction Screening (counterparty sanctions + wallet risk via Crystal / Merkle Science) | inside \`/v3/transactions/\` | $0.15 / tx | — |
| Reusable KYC (share verified user across partners) | session feature | Free | — |
| White Label | workflow option | $0.20 | — |

## Operational APIs your code should know

| Action | Endpoint |
|---|---|
| Read full decision JSON | \`GET /v3/session/{sessionId}/decision/\` |
| List sessions (filter by status, workflow, vendor_data, date) | \`GET /v3/sessions/\` (trailing slash; the slash-less URL 301-redirects) |
| Manually approve / decline / request review | \`PATCH /v3/session/{sessionId}/update-status/\` |
| Patch metadata or extracted KYC/POA/NFC data | \`PATCH /v3/session/{sessionId}/update-data/\` |
| Download compliance PDF | \`GET /v3/session/{sessionId}/generate-pdf/\` |
| Delete a single session (GDPR / cleanup) | \`DELETE /v3/session/{sessionId}/delete/\` |
| Batch delete sessions | \`POST /v3/sessions/delete/\` |
| Mint a Reusable KYC share token | \`POST /v3/session/{sessionId}/share/\` |
| Redeem a Reusable KYC share token | \`POST /v3/session/import-shared/\` |
| Submit a transaction for monitoring | \`POST /v3/transactions/\` |
| List / create lists (blocklist, allowlist, custom) | \`GET/POST /v3/lists/\` |
| Add entry to a list | \`POST /v3/lists/{list_uuid}/entries/\` |
| Upload a face image to a blocklist | \`POST /v3/lists/{list_uuid}/entries/face-upload/\` |
| Check credit balance | \`GET /v3/billing/balance/\` |
| Top up credits (returns Stripe checkout URL) | \`POST /v3/billing/top-up/\` |
| List / get / update / delete vendor users | \`GET /v3/users/\`, \`GET/PATCH /v3/users/{vendor_data}/\`, \`POST /v3/users/delete/\` |
| List / get / update / delete vendor businesses | \`GET /v3/businesses/\`, \`GET/PATCH /v3/businesses/{vendor_data}/\`, \`POST /v3/businesses/delete/\` |
| List / create / update / delete webhook destinations | \`GET/POST /v3/webhook/destinations/\`, \`GET/PATCH/DELETE /v3/webhook/destinations/{destination_uuid}/\` |

Every \`/v3/...\` endpoint above is served from \`https://verification.didit.me\` and authenticates with \`x-api-key\`. Auth/registration is the only surface on \`https://apx.didit.me\`.

## Best-practice checklist before you mark this done

- [ ] \`DIDIT_API_KEY\`, \`DIDIT_WORKFLOW_ID\`, \`DIDIT_WEBHOOK_SECRET\` in env. Never committed.
- [ ] Backend route that creates a session for a given user, returns \`url\` (the hosted verification URL on \`verify.didit.me\`) and the \`session_id\`.
- [ ] Frontend that opens the session \`url\` via the right SDK / iframe / redirect for my stack.
- [ ] Webhook endpoint with: \`X-Timestamp\` freshness check (≤ 300s), canonical-V2 JSON re-serialisation (\`shortenFloats\` → \`sortKeys\` → unescaped \`JSON.stringify\`) HMAC-SHA256, constant-time compare against \`X-Signature-V2\`, dispatch on \`webhook_type\`, return 2xx within 5 seconds.
- [ ] DB schema + update logic for \`status in ("Not Started", "In Progress", "Awaiting User", "In Review", "Approved", "Declined", "Resubmitted", "Abandoned", "Expired", "Kyc Expired")\` — compare case-sensitively against the exact strings above.
- [ ] Idempotent webhook handler (dedupe on \`event_id\`).
- [ ] No API key shipped to the browser. The session creation is server-side only.
- [ ] User-facing consent / disclosure shown BEFORE the session \`url\` opens (Didit handles biometric consent inside its flow but the legal layer outside is the integrator's responsibility).
- [ ] (Optional) \`/v3/session/{sessionId}/generate-pdf/\` surfaced in compliance UI.
- [ ] (Optional) Webhook destination created via \`POST /v3/webhook/destinations/\` with \`webhook_version: "v3"\` and \`secret_shared_key\` stored in a secrets manager.

## When you need more detail

- API root: \`https://verification.didit.me/v3/\` (everything \`/v3/\`). Auth only: \`https://apx.didit.me/auth/v2/programmatic/\`.
- Sessions overview: \`https://docs.didit.me/sessions-api/overview\`
- Create session: \`https://docs.didit.me/sessions-api/create-session\`
- Retrieve session decision: \`https://docs.didit.me/sessions-api/retrieve-session\`
- Standalone APIs index: \`https://docs.didit.me/standalone-apis/id-verification\`
- Programmatic registration: \`https://docs.didit.me/integration/programmatic-registration\`
- AI agent / MCP integration: \`https://docs.didit.me/integration/ai-agent-integration\`
- Webhooks reference: \`https://docs.didit.me/integration/webhooks\`
- Verification statuses (literal API strings): \`https://docs.didit.me/integration/verification-statuses\`
- Data models (canonical V3 schema for every \`decision.*\` array): \`https://docs.didit.me/reference/data-models\`
- SDKs overview: \`https://docs.didit.me/integration/sdks\`
- Full OpenAPI 3 spec (verification API): \`https://docs.didit.me/openapi-25.json\`
- Auth + apps OpenAPI: \`https://docs.didit.me/openapi-auth.json\`
- MCP server config (drop into \`.cursor/mcp.json\`, \`claude_desktop_config.json\`, or equivalent):

\`\`\`json
{
  "mcpServers": {
    "didit": {
      "command": "npx",
      "args": ["@didit-protocol/mcp-server"],
      "env": { "DIDIT_API_KEY": "your_api_key" }
    }
  }
}
\`\`\`

The MCP server exposes 40+ tools spanning auth, sessions, workflows, questionnaires, users, billing, blocklist, and every standalone API — see \`https://docs.didit.me/integration/ai-agent-integration\` for the full tool catalogue.

Now build the integration. If anything in \`## My application context\` is missing, ask once at the top of your reply, then ship the complete change set: env vars wired, backend route, frontend SDK call, webhook endpoint with signature verification, decision-handling DB logic, and idempotent dedupe keyed on \`event_id\`. Use my stack's idioms (\`fetch\` / \`axios\` / \`requests\` / \`okhttp\`), keep the API key server-side only, treat the V3 plural-array contract (\`id_verifications[]\`, \`nfc_verifications[]\`, …) as load-bearing, and follow the verification status state machine above.
`;

export const AgentPromptAccordion = ({prompt, title = "AI Agent Integration Prompt"}) => {
  const [copied, setCopied] = React.useState(false);
  const handleCopy = e => {
    e.stopPropagation();
    if (!prompt) return;
    navigator.clipboard.writeText(prompt.trim()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    });
  };
  const agents = ["Claude Code", "Codex", "Cursor", "Devin", "Windsurf", "GitHub Copilot"];
  return <div className="didit-agent-card">
      {}
      <div className="didit-agent-titlebar">
        <div className="didit-agent-dots" aria-hidden="true">
          <span className="didit-agent-dot didit-agent-dot-red"></span>
          <span className="didit-agent-dot didit-agent-dot-yellow"></span>
          <span className="didit-agent-dot didit-agent-dot-green"></span>
        </div>
        <span className="didit-agent-filename">{title}</span>
        <button type="button" className={`didit-agent-copy ${copied ? "didit-agent-copy-copied" : ""}`} onClick={handleCopy} title="Copy prompt to clipboard" aria-label={copied ? "Copied!" : "Copy prompt to clipboard"}>
          {copied ? <>
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
                <path d="M3 8.5l3.5 3.5L13 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <span>Copied</span>
            </> : <>
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
                <rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
                <path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" stroke="currentColor" strokeWidth="1.5" />
              </svg>
              <span>Copy</span>
            </>}
        </button>
      </div>

      {}
      <pre className="didit-agent-body"><code>{prompt.trim()}</code></pre>

      {}
      <div className="didit-agent-footer">
        <span className="didit-agent-footer-label">Paste into</span>
        <div className="didit-agent-chips">
          {agents.map(name => <span key={name} className="didit-agent-chip">{name}</span>)}
        </div>
      </div>
    </div>;
};

## One prompt. End-to-end Didit integration.

The fastest way to integrate Didit into any application is to hand this prompt to an AI coding agent. It walks the agent through programmatic account creation, SDK install, workflow setup, session creation, webhooks with HMAC-SHA256 signature verification, and decision handling — every endpoint, header, package name, and price has been cross-checked against the canonical docs on this site.

**How to use:**

1. Open **Claude Code**, **Codex**, **Cursor**, **GitHub Copilot**, **Devin**, or any AI coding agent that can run shell commands.
2. Click **Copy Prompt** below.
3. Paste into your agent's chat.
4. Fill in the `<my_stack>` block with your framework / language / use case.
5. Hit enter.

The agent registers your Didit account, picks the right SDK for your stack, ships the session-creation route, builds the webhook receiver with signature verification + idempotent dedupe, and wires decision-handling logic into your database — usually in one session.

<AgentPromptAccordion title="Didit Integration Prompt (copy and paste)" prompt={DIDIT_INTEGRATION_PROMPT} />

## What the prompt covers

| Step               | What the agent does                                                                                                                                                                                                                                                       |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. Register        | Two API calls to `https://apx.didit.me/auth/v2/programmatic/{register,verify-email}/` — returns the `api_key` (used as `x-api-key` for every verification API call)                                                                                                       |
| 2. Choose approach | Sessions API + SDK (recommended for any user-facing flow) or Standalone APIs (server-to-server, custom UI)                                                                                                                                                                |
| 3. Create workflow | `POST /v3/workflows/` with the canonical body shape: `{workflow_label, features: [{feature: "OCR"}, ...]}` using UPPERCASE feature enums (`face_liveness_method` for LIVENESS: `PASSIVE` / `ACTIVE_3D` / `FLASHING`)                                                      |
| 4. Install SDK     | Picks the right package for your stack: `@didit-protocol/sdk-web`, the iOS SDK via Swift Package Manager (`github.com/didit-protocol/sdk-ios`), `me.didit:didit-sdk` (custom Maven repo, not Maven Central), `@didit-protocol/sdk-react-native`, or `didit_sdk` (Flutter) |
| 5. Create session  | `POST /v3/session/` with `workflow_id`, `vendor_data`, `callback` — frontend opens the returned `url` via SDK / iframe / redirect. Auth failures are uniformly `HTTP 403 {"detail": "..."}`                                                                               |
| 6. Set up webhook  | Registers a `webhook_version: "v3"` destination, builds the receiver with the canonical X-Signature-V2 pipeline (`shortenFloats` → `sortKeys` → `JSON.stringify` → HMAC-SHA256 → constant-time compare) and 5-second response budget                                      |
| 7. Apply decision  | Database-update logic for all 10 session statuses (`Not Started`, `In Progress`, `Awaiting User`, `In Review`, `Approved`, `Declined`, `Resubmitted`, `Abandoned`, `Expired`, `Kyc Expired`) + idempotent dedupe on `event_id` + V3 plural-array decision parsing         |

The prompt also bundles a **module catalogue with public per-feature pricing**, an **operational APIs table** (decision retrieval, status updates, PDF generation, billing, webhook destinations, and more), and the **MCP server install JSON** for `.cursor/mcp.json` / `claude_desktop_config.json`.

## Why this works as a one-prompt integration

* **Self-sufficient.** Every step has a runnable cURL or code snippet — the agent never has to leave the prompt to look up a body shape, header, or endpoint.
* **Canonical.** Each endpoint, header, status literal, and feature-array name is verified against [`openapi-25.json`](/openapi-25.json), [`openapi-auth.json`](/openapi-auth.json), and the live `.mdx` reference pages on this site (linked at the bottom of the prompt).
* **V3 plural-array contract.** The prompt names every `decision.*` array explicitly (`id_verifications[]`, `nfc_verifications[]`, `liveness_checks[]`, `face_matches[]`, `aml_screenings[]`, …) and warns agents off the legacy V2 singular fields, so the integration handles multi-instance workflows (e.g. two ID checks in a step-up flow) from day one.
* **Bounded.** A few thousand tokens — fits in any modern agent context with room left for your stack description and the agent's planning notes.
* **Idempotent.** Tells the agent to dedupe on `event_id` — Didit reuses the **same** `event_id` across retries and fan-out destinations, which is exactly what makes it a stable idempotency key — with `session_id + status + webhook_type` as a fallback compound key.
* **Status-machine complete.** The agent's database-update logic handles every status the production fleet emits — including the KYB-only `Awaiting User` state, the `Resubmitted` flow with `resubmit_info`, and the post-onboarding `Kyc Expired` state most integrators miss.

## See also

* [Programmatic Registration](/integration/programmatic-registration) — the 2-call register / verify-email flow used at the top of the prompt
* [AI Agent Integration (MCP)](/integration/mcp/overview) — full MCP tool catalogue (115 tools) referenced at the bottom of the prompt
* [SDKs Overview](/integration/sdks) — pick the right SDK for your stack
* [Webhooks](/integration/webhooks) — canonical X-Signature-V2 implementation + retry policy
* [Create Session](/sessions-api/create-session) — the request shape the agent uses in step 5
* [Retrieve Session Decision](/sessions-api/retrieve-session) — the V3 plural-array decision shape returned to the agent
* [Verification Statuses](/integration/verification-statuses) — the literal status strings the agent compares against
* [Data Models](/reference/data-models) — canonical per-feature schema for every `decision.*` array
* [Pricing](/getting-started/pricing) — the public per-module prices the prompt references
