curl -X POST 'https://verification.didit.me/v3/workflows/' \
-H 'x-api-key: YOUR_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", "config": {"face_match_score_decline_threshold": 40, "face_match_score_review_threshold": 60}}
]
}'import requests
payload = {
"workflow_label": "Standard KYC",
"features": [
{"feature": "OCR"},
{"feature": "LIVENESS", "config": {"face_liveness_method": "passive"}},
{"feature": "FACE_MATCH"},
],
}
resp = requests.post(
"https://verification.didit.me/v3/workflows/",
headers={"x-api-key": "YOUR_API_KEY"},
json=payload,
timeout=10,
)
resp.raise_for_status()
workflow = resp.json()
print("Workflow id:", workflow["uuid"])const res = await fetch("https://verification.didit.me/v3/workflows/", {
method: "POST",
headers: {
'x-api-key': 'YOUR_API_KEY',
"Content-Type": "application/json",
},
body: JSON.stringify({
workflow_label: "Standard KYC",
features: [
{ feature: "OCR" },
{ feature: "LIVENESS", config: { face_liveness_method: "passive" } },
{ feature: "FACE_MATCH" },
],
}),
});
if (!res.ok) throw new Error(`Didit ${res.status}: ${await res.text()}`);
const workflow = await res.json();
console.log("Workflow id:", workflow.uuid);<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://verification.didit.me/v3/workflows/",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'workflow_label' => 'Standard KYC',
'features' => [
[
'feature' => 'OCR',
'config' => [
'duplicated_user_action' => 'review'
]
],
[
'feature' => 'LIVENESS',
'config' => [
'face_liveness_method' => 'passive'
]
],
[
'feature' => 'FACE_MATCH',
'config' => [
'face_match_score_decline_threshold' => 40,
'face_match_score_review_threshold' => 60
]
]
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://verification.didit.me/v3/workflows/"
payload := strings.NewReader("{\n \"workflow_label\": \"Standard KYC\",\n \"features\": [\n {\n \"feature\": \"OCR\",\n \"config\": {\n \"duplicated_user_action\": \"review\"\n }\n },\n {\n \"feature\": \"LIVENESS\",\n \"config\": {\n \"face_liveness_method\": \"passive\"\n }\n },\n {\n \"feature\": \"FACE_MATCH\",\n \"config\": {\n \"face_match_score_decline_threshold\": 40,\n \"face_match_score_review_threshold\": 60\n }\n }\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://verification.didit.me/v3/workflows/")
.header("x-api-key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"workflow_label\": \"Standard KYC\",\n \"features\": [\n {\n \"feature\": \"OCR\",\n \"config\": {\n \"duplicated_user_action\": \"review\"\n }\n },\n {\n \"feature\": \"LIVENESS\",\n \"config\": {\n \"face_liveness_method\": \"passive\"\n }\n },\n {\n \"feature\": \"FACE_MATCH\",\n \"config\": {\n \"face_match_score_decline_threshold\": 40,\n \"face_match_score_review_threshold\": 60\n }\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://verification.didit.me/v3/workflows/")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["x-api-key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"workflow_label\": \"Standard KYC\",\n \"features\": [\n {\n \"feature\": \"OCR\",\n \"config\": {\n \"duplicated_user_action\": \"review\"\n }\n },\n {\n \"feature\": \"LIVENESS\",\n \"config\": {\n \"face_liveness_method\": \"passive\"\n }\n },\n {\n \"feature\": \"FACE_MATCH\",\n \"config\": {\n \"face_match_score_decline_threshold\": 40,\n \"face_match_score_review_threshold\": 60\n }\n }\n ]\n}"
response = http.request(request)
puts response.read_body{
"uuid": "c3d4e5f6-7890-12cd-ef34-333333333333",
"workflow_id": "c3d4e5f6-7890-12cd-ef34-333333333333",
"workflow_label": "Standard KYC",
"workflow_type": null,
"is_default": false,
"is_archived": false,
"total_price": 0.15,
"min_price": 0.15,
"max_price": 0.15,
"features": "OCR + LIVENESS + FACE_MATCH",
"is_simple_workflow": false,
"is_editable": false,
"workflow_url": "https://verify.didit.me/u/w9TkPOx8RWG3UAz5ZRQGVQ",
"workflow_graph": {
"start_node": "ocr",
"nodes": "... one node per feature plus a final status node ..."
},
"max_retry_attempts": 3,
"retry_window_days": 7,
"session_expiration_time": 604800,
"version": 1,
"status": "published",
"published_at": "2026-05-17T10:30:00Z",
"created_at": "2026-05-17T10:30:00Z",
"updated_at": "2026-05-17T10:30:00Z"
}Create Workflow
Create a simple workflow. Send features in execution order (ordering rules apply); defaults to published v1. The body accepts ONLY the fields listed in SimpleWorkflowRequest — any other key (including workflow_type) is rejected with a 400. Not idempotent — each call creates a new workflow. Maximum 50 workflows per application.
curl -X POST 'https://verification.didit.me/v3/workflows/' \
-H 'x-api-key: YOUR_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", "config": {"face_match_score_decline_threshold": 40, "face_match_score_review_threshold": 60}}
]
}'import requests
payload = {
"workflow_label": "Standard KYC",
"features": [
{"feature": "OCR"},
{"feature": "LIVENESS", "config": {"face_liveness_method": "passive"}},
{"feature": "FACE_MATCH"},
],
}
resp = requests.post(
"https://verification.didit.me/v3/workflows/",
headers={"x-api-key": "YOUR_API_KEY"},
json=payload,
timeout=10,
)
resp.raise_for_status()
workflow = resp.json()
print("Workflow id:", workflow["uuid"])const res = await fetch("https://verification.didit.me/v3/workflows/", {
method: "POST",
headers: {
'x-api-key': 'YOUR_API_KEY',
"Content-Type": "application/json",
},
body: JSON.stringify({
workflow_label: "Standard KYC",
features: [
{ feature: "OCR" },
{ feature: "LIVENESS", config: { face_liveness_method: "passive" } },
{ feature: "FACE_MATCH" },
],
}),
});
if (!res.ok) throw new Error(`Didit ${res.status}: ${await res.text()}`);
const workflow = await res.json();
console.log("Workflow id:", workflow.uuid);<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://verification.didit.me/v3/workflows/",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'workflow_label' => 'Standard KYC',
'features' => [
[
'feature' => 'OCR',
'config' => [
'duplicated_user_action' => 'review'
]
],
[
'feature' => 'LIVENESS',
'config' => [
'face_liveness_method' => 'passive'
]
],
[
'feature' => 'FACE_MATCH',
'config' => [
'face_match_score_decline_threshold' => 40,
'face_match_score_review_threshold' => 60
]
]
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://verification.didit.me/v3/workflows/"
payload := strings.NewReader("{\n \"workflow_label\": \"Standard KYC\",\n \"features\": [\n {\n \"feature\": \"OCR\",\n \"config\": {\n \"duplicated_user_action\": \"review\"\n }\n },\n {\n \"feature\": \"LIVENESS\",\n \"config\": {\n \"face_liveness_method\": \"passive\"\n }\n },\n {\n \"feature\": \"FACE_MATCH\",\n \"config\": {\n \"face_match_score_decline_threshold\": 40,\n \"face_match_score_review_threshold\": 60\n }\n }\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://verification.didit.me/v3/workflows/")
.header("x-api-key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"workflow_label\": \"Standard KYC\",\n \"features\": [\n {\n \"feature\": \"OCR\",\n \"config\": {\n \"duplicated_user_action\": \"review\"\n }\n },\n {\n \"feature\": \"LIVENESS\",\n \"config\": {\n \"face_liveness_method\": \"passive\"\n }\n },\n {\n \"feature\": \"FACE_MATCH\",\n \"config\": {\n \"face_match_score_decline_threshold\": 40,\n \"face_match_score_review_threshold\": 60\n }\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://verification.didit.me/v3/workflows/")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["x-api-key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"workflow_label\": \"Standard KYC\",\n \"features\": [\n {\n \"feature\": \"OCR\",\n \"config\": {\n \"duplicated_user_action\": \"review\"\n }\n },\n {\n \"feature\": \"LIVENESS\",\n \"config\": {\n \"face_liveness_method\": \"passive\"\n }\n },\n {\n \"feature\": \"FACE_MATCH\",\n \"config\": {\n \"face_match_score_decline_threshold\": 40,\n \"face_match_score_review_threshold\": 60\n }\n }\n ]\n}"
response = http.request(request)
puts response.read_body{
"uuid": "c3d4e5f6-7890-12cd-ef34-333333333333",
"workflow_id": "c3d4e5f6-7890-12cd-ef34-333333333333",
"workflow_label": "Standard KYC",
"workflow_type": null,
"is_default": false,
"is_archived": false,
"total_price": 0.15,
"min_price": 0.15,
"max_price": 0.15,
"features": "OCR + LIVENESS + FACE_MATCH",
"is_simple_workflow": false,
"is_editable": false,
"workflow_url": "https://verify.didit.me/u/w9TkPOx8RWG3UAz5ZRQGVQ",
"workflow_graph": {
"start_node": "ocr",
"nodes": "... one node per feature plus a final status node ..."
},
"max_retry_attempts": 3,
"retry_window_days": 7,
"session_expiration_time": 604800,
"version": 1,
"status": "published",
"published_at": "2026-05-17T10:30:00Z",
"created_at": "2026-05-17T10:30:00Z",
"updated_at": "2026-05-17T10:30:00Z"
}features array in the order users should complete the checks. Didit converts it into a node-based workflow internally and adds the final status step automatically.
This POST /v3/workflows/ endpoint creates simple, linear workflows only — a flat features list. For branching, conditional routing, Document AI, and decline/decision nodes, use the node graph format documented below (it powers the visual builder in the console and the Didit MCP workflow tools).
See Workflow Feature Configs for every config field accepted by each feature.
Basic KYC workflow
{
"workflow_label": "Standard KYC",
"features": [
{
"feature": "OCR",
"config": {
"documents_allowed": {
"USA": {
"DL": {
"enabled": 1,
"sides": 2
}
}
},
"duplicated_user_action": "REVIEW"
}
},
{
"feature": "LIVENESS",
"config": {
"face_liveness_method": "PASSIVE"
}
},
{
"feature": "FACE_MATCH"
}
]
}
Questionnaire workflow
First create the questionnaire withPOST /v3/questionnaires/. Then use the returned questionnaire_id as questionnaire_uuid.
{
"workflow_label": "Source of Funds",
"features": [
{
"feature": "QUESTIONNAIRE",
"config": {
"questionnaire_uuid": "11111111-2222-3333-4444-555555555555",
"review_questionnaire_manually": true
}
}
]
}
OCR before FACE_MATCH, NFC, DATABASE_VALIDATION, or user AML checks that depend on document data.
Feature config reference
Eachfeatures[] item has this shape:
{
"feature": "OCR",
"label": "ID Verification",
"config": {}
}
config object depends on the feature. Review the full field list, defaults, required settings, value ranges, and examples in Workflow Feature Configs.
For OCR, omit config.documents_allowed, send null, or send {} to allow every supported document type. Include it only when you want to restrict the workflow to specific countries or document types. For QUESTIONNAIRE, config.questionnaire_uuid is required.
Optional workflow settings
You can include any of these top-level fields when creating a workflow to control session behavior. They are all optional and fall back to the workflow defaults when omitted.| Field | Type | Description |
|---|---|---|
is_white_label_enabled | boolean | Enable white-label customization for sessions created with this workflow. Default false. |
is_desktop_allowed | boolean | Allow the verification flow to run on desktop browsers. Default false. |
max_retry_attempts | integer (0–10) | Maximum retry attempts allowed after a declined verification. 0 blocks the user after the first decline. Default 3. |
retry_window_days | integer (1–365) or null | Rolling window in days used to count retries (null = all-time limit). Default 7. |
session_expiration_time | integer (3600–2419200) | Seconds before an unfinished session expires. Minimum 1 hour, maximum 4 weeks. Default 604800 (7 days). |
{
"workflow_label": "Standard KYC",
"is_white_label_enabled": true,
"is_desktop_allowed": true,
"max_retry_attempts": 5,
"retry_window_days": 14,
"session_expiration_time": 86400,
"features": [
{ "feature": "OCR" },
{ "feature": "LIVENESS" },
{ "feature": "FACE_MATCH" }
]
}
Branching & node-based workflows (graph)
A linearfeatures list can’t express “if X then Y.” For conditional branching (decline on a status, route on an extracted field, gate a step on a match), a Document AI request step, or terminal decline/decision nodes, Didit workflows use a node graph — the same structure the console’s visual builder produces.
Graph shape
A graph is{ start_node, nodes }. nodes is a map of node id → node. Edges are implicit: each node points forward via next (unconditional) and/or branches[].goto (conditional).
| Node type | Shape |
|---|---|
feature | { "node_type": "feature", "feature": "OCR", "config": {…}, "next": "<id>", "branches": [] } |
branch | { "node_type": "branch", "branches": [ { "id", "logic": "and"|"or", "rules": [<rule>], "goto": "<id>" } ], "next": "<fallback id>" } |
status (terminal) | { "node_type": "status", "session_status": "Approved"|"Declined"|"In Review"|"Determine" } |
action | { "node_type": "action", "actions": [ { "type": "add_tag"|"add_review"|… } ], "next": "<id>" } |
webhook | { "node_type": "webhook", "config": { "url", "method" }, "branches": [], "next": "<id>" } |
start_node must be a feature node. Branches are evaluated in order, first match wins; an empty rules: [] is the catch-all “else” (keep it last). session_status: "Determine" auto-decides from the combined feature outcomes.
rules: [] that routes sessions matching none of the conditions. Don’t rely on the branch node’s next as the fallback: a bare next renders as an ambiguous edge in the builder and is easy to omit. When you send a branch node with a next and no else branch, the API converts that next into an explicit else branch for you (so { …, "branches": [<conditions>], "next": "liveness" } is saved as branches: [<conditions>, { "id": "else", "rules": [], "goto": "liveness" }]). Build the else yourself to be explicit; the conversion is a safety net, not a substitute.Branch rules & operators
Each rule compares a field to a value with an operator:{ "field": "kyc.extra_fields.profession", "operator": "fuzzy_match", "value": "Software Engineer", "score": 80 }
- Fields name a feature outcome (
kyc.status,aml.status,document_ai.status,face_match.score, …) or an extracted value (kyc.full_name,kyc.age,kyc.nationality,kyc.extra_fields.profession, a Document-AI fielddocument_ai.<key>,expected_details.*). A branch may only read a field whose feature has run on every path reaching it. - Operators:
equals,not_equals,greater_than(_or_equals),less_than(_or_equals),contains,not_contains,in,not_in,is_empty,is_not_empty,regex, andfuzzy_match(string fields only; requires ascore0–100 — a similarity threshold, e.g. profession ≥ 80% similar to “Software Engineer”).
Branch on why NFC did not run
After anNFC node, use nfc.skip_reason with equals or in to route sessions by why the chip read did not happen. The field has these six stable values:
USER_SKIPPEDDOCUMENT_WITHOUT_CHIPCHIP_CERTIFICATE_UNAVAILABLEDEVICE_WITHOUT_NFCINTEGRATION_WITHOUT_NFC_ACCESSMRZ_KEY_UNAVAILABLE
nfc.skip_reason is null, so neither an equals nor an in rule for a skip reason matches.
The field also resolves for gate-skipped NFC nodes. This includes cases where no NFC report or warning exists, and cases where allow_nfc_skip is false but the document or device gate prevents NFC from running. You can therefore route these sessions without relying on an nfc_verifications[] entry.
has_device_nfc_support: false. An older web session may therefore resolve to DEVICE_WITHOUT_NFC instead of INTEGRATION_WITHOUT_NFC_ACCESS.equals for a user skip, in for gate or capability skips, and an explicit else for successful NFC. The branch comes after the NFC node, and every destination points forward to a terminal node:
{
"start_node": "ocr",
"nodes": {
"ocr": {
"node_type": "feature",
"feature": "OCR",
"next": "nfc"
},
"nfc": {
"node_type": "feature",
"feature": "NFC",
"config": { "allow_nfc_skip": false },
"next": "route_nfc"
},
"route_nfc": {
"node_type": "branch",
"branches": [
{
"id": "user_skipped",
"logic": "and",
"rules": [
{ "field": "nfc.skip_reason", "operator": "equals", "value": "USER_SKIPPED" }
],
"goto": "manual_review"
},
{
"id": "nfc_unavailable",
"logic": "and",
"rules": [
{
"field": "nfc.skip_reason",
"operator": "in",
"value": [
"DOCUMENT_WITHOUT_CHIP",
"CHIP_CERTIFICATE_UNAVAILABLE",
"DEVICE_WITHOUT_NFC",
"INTEGRATION_WITHOUT_NFC_ACCESS",
"MRZ_KEY_UNAVAILABLE"
]
}
],
"goto": "manual_review"
},
{
"id": "else",
"logic": "and",
"rules": [],
"goto": "approved"
}
]
},
"manual_review": { "node_type": "status", "session_status": "In Review" },
"approved": { "node_type": "status", "session_status": "Approved" }
}
}
in with all six values. Keep an explicit else branch for successful NFC (nfc.skip_reason: null) and any future value that your graph does not yet recognize.
Document AI (e.g. proof of funds)
ADOCUMENT_AI feature node requests one or more documents and extracts named fields:
{
"node_type": "feature",
"feature": "DOCUMENT_AI",
"label": "Proof of Funds",
"config": {
"document_ai_documents": [
{
"document_key": "proof_of_funds",
"title": "Proof of Funds",
"description": "Upload a recent bank statement.",
"fields": [
{
"key": "account_holder",
"name": "Account holder",
"instruction": "The full name of the account holder as printed on the statement.",
"type": "text",
"required": true,
"is_full_name": true
},
{
"key": "closing_balance",
"name": "Closing balance",
"instruction": "The closing balance amount.",
"type": "number",
"required": true
}
]
}
],
"document_ai_missing_required_fields_action": "REVIEW",
"document_ai_name_mismatch_action": "REVIEW"
},
"next": "final_decision"
}
document_ai.<key>, and the step’s outcome is document_ai.status.
Set "is_full_name": true on the field that holds the document holder’s full name. Its extracted value is compared against the verified identity’s full name (the OCR/ID-verification name, or the session’s expected details), and document_ai_name_mismatch_action decides what happens on a mismatch below document_ai_name_match_score_threshold. The flag is optional — a document may mark no field — and at most one field per document may set it. See Workflow feature configs → Document AI for every Document AI config field.
Endpoints & draft lifecycle
Graph editing is per-application and uses the console/management endpoints (user-Bearer +read:workflows/write:workflows). A graph can only be edited on a draft version, so the lifecycle is create-draft → set graph → publish:
| Method | Path | Purpose |
|---|---|---|
GET | …/verification-settings/{settings_uuid}/workflow-graph/ | Current graph + status/is_editable |
POST | …/application/{app}/workflow-graph/validate/ (body { "graph": … }) | Dry-run validation |
GET | …/application/{app}/workflow-graph/field-definitions/ | Branchable fields + valid operators |
POST | …/verification-settings/{settings_uuid}/create-draft/ | Editable draft from a published version |
PUT | …/verification-settings/{settings_uuid}/workflow-graph/ (body { "graph": … }) | Save the graph |
PATCH | …/verification-settings/{settings_uuid}/ (body { "status": "published" }) | Publish the draft |
Validation warnings
The validate endpoint answers{ "is_valid": true, "graph": …, "warnings": [] }. A warning is advisory: the graph is valid, it saves and it publishes with warnings present. Each entry carries the branch node and arm it is about, so a builder can point at the exact condition:
{
"type": "branch_outcome_contradiction",
"node_id": "after_ocr",
"branch_id": "b1",
"severity": "warning",
"message": "Branch 1 only matches sessions where ID Verification Status is Approved or In Review, but every path from it ends in the session outcome \"Declined\". Check the condition or the outcome.",
"params": { "branchNumber": 1, "field": "ID Verification Status", "allowed": "Approved or In Review", "outcome": "Declined" }
}
type | What it means |
|---|---|
branch_outcome_contradiction | The arm’s condition and the outcome every path from it reaches contradict each other - for example kyc.status NOT EQUALS Declined routed into a Declined outcome. The outcome may be several steps further down the path. |
branch_condition_unreachable | The arm can never match, because every path reaching this branch already limits that status field to values the condition excludes. |
else_branch_unreachable | The else arm can never run: the other arms already cover every value the field can hold here. |
400 with errors and a best-effort warnings list. Only errors block a save.
Worked example: route Software Engineers to proof of funds
After ID verification: if it was declined, decline the session; if the holder’s profession ≈ “Software Engineer” (≥80% similar), request a proof-of-funds document; then make a final decision.
{
"start_node": "ocr_1",
"nodes": {
"ocr_1": { "node_type": "feature", "feature": "OCR", "label": "ID Verification", "next": "after_ocr" },
"after_ocr": {
"node_type": "branch",
"label": "Route after ID verification",
"branches": [
{ "id": "declined", "logic": "or", "rules": [ { "field": "kyc.status", "operator": "equals", "value": "Declined" } ], "goto": "decline" },
{ "id": "software_engineer", "logic": "and", "rules": [ { "field": "kyc.extra_fields.profession", "operator": "fuzzy_match", "value": "Software Engineer", "score": 80 } ], "goto": "proof_of_funds" },
{ "id": "else", "logic": "and", "rules": [], "goto": "final_decision" }
]
},
"proof_of_funds": {
"node_type": "feature",
"feature": "DOCUMENT_AI",
"label": "Proof of Funds",
"config": {
"document_ai_documents": [
{ "document_key": "proof_of_funds", "title": "Proof of Funds", "description": "Upload a recent bank statement.",
"fields": [
{ "key": "account_holder", "name": "Account holder", "type": "text", "required": true },
{ "key": "closing_balance", "name": "Closing balance", "type": "number", "required": true }
] }
],
"document_ai_missing_required_fields_action": "REVIEW"
},
"next": "final_decision"
},
"final_decision": { "node_type": "status", "session_status": "Determine", "label": "Final decision" },
"decline": { "node_type": "status", "session_status": "Declined", "label": "Declined" }
}
}
session_status: "Determine", which auto-decides (approve / decline / in-review) from whatever features actually ran on the session — so both the proof-of-funds path and the else path resolve correctly without any branch reading document_ai.status (which doesn’t exist on the path that skips Document AI). Remember: a branch may only read a field whose feature ran on every path reaching it.
Send it as { "graph": <above> } to the …/workflow-graph/ endpoint (validate first). To build a new workflow from scratch use the MCP’s didit_workflow_set_graph; to modify an existing workflow use didit_workflow_edit_graph (small ops, preserves the existing nodes and allow-lists). See the Didit MCP.Authorizations
Your application's API key, from Developers -> API keys in the Business Console. The primary key has full access. A named key can be scoped: none, read or write per resource, limited to some workflows or to approved sessions, to a list of IP addresses, and to an expiry date. 401 means the key is missing, wrong, revoked or expired; 403 means the key has no access to this resource or action, or the request came from an address outside its IP list; 404 on a session route means the session is outside the key's workflows or statuses. A key without media access receives image, video and PDF URLs as null, and a key without sessions write receives session links and tokens as null. See https://docs.didit.me/console/api-keys.
Body
Create a simple linear workflow. The API converts this feature list into a node-based workflow graph internally. STRICT field whitelist: any key not listed here (including workflow_type) is rejected with a 400.
Verification features in execution order. The API links each item to the next one and adds one final status node automatically.
1Show child attributes
Show child attributes
Display name for the workflow (max 50 characters). Defaults to "ID Verification" when omitted — always set it so workflows stay distinguishable.
50"Standard KYC"
Set this workflow as the default for new sessions.
Omit this field to create a published workflow ready for sessions. Use draft only when you want to save without publishing.
draft, published Enable white-label customization for sessions created with this workflow.
Allow the verification flow to run on desktop browsers.
Maximum retry attempts allowed after a declined verification. 0 blocks the user after the first decline.
0 <= x <= 10Rolling window in days used to count retries. null enforces an all-time limit.
1 <= x <= 365Maximum liveness submissions per session. Default 3: one initial attempt plus two retries. This value is also copied into generated liveness and age-estimation workflow nodes unless the feature-level config overrides it.
1 <= x <= 3Maximum face-match submissions per session. Default 3: one initial attempt plus two retries. This value is also copied into generated face-match workflow nodes unless the feature-level config overrides it.
1 <= x <= 3Seconds before an unfinished session expires. Minimum 1 hour (3600), maximum 4 weeks (2419200).
3600 <= x <= 2419200Response
Workflow created. The response body is the FULL workflow configuration — the same raw VerificationSettings serialization returned by GET /v3/workflows/{settings_uuid}/ (~190 fields: every feature toggle, threshold, action and the generated workflow_graph), not the compact list item. Store the uuid (also exposed as workflow_id) so you can reference it from later session-creation calls. Note: features is a " + "-joined string and prices are JSON numbers.
Full workflow configuration (abbreviated here — see GET /v3/workflows/{settings_uuid}/).