Concepts
Decisions
The stable decision contract you branch on, the raw statuses you should not, and the decision_v3 per-module summary shape.
Both API surfaces expose a stable decision that is deliberately coarser than the raw workflow status. Branch your integration on the decision, not on the status. Raw statuses exist for diagnostics and can change or gain new values; the decision set is the contract.
Warning
Whatever the browser or mobile SDK hands back is advisory — it exists so you
can show the right screen. The authoritative decision is the HMAC-signed
webhook, or a server-side read of
GET /v3/session/<id>/decision/. Grant access, open accounts and move money
only on that.
Session decisions (/v3)
For a ThirdFactor Verify session, decision collapses the raw
status to one of five values:
| Decision | Meaning | Underlying statuses |
|---|---|---|
approved | Verification passed. | approved |
declined | Verification failed. | declined |
manual_review | Held for an operator. | in_review, awaiting_user |
in_progress | No final decision yet. | not_started, in_progress, resubmitted |
abandoned | Closed / expired without completing. | abandoned, expired, kyc_expired |
Read it with the compact decision endpoint:
curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/session/6f0e2b7a-…/decision/"const res = await fetch(`${BASE}/v3/session/${sessionId}/decision/`, {
headers: { "x-api-key": process.env.TF_API_KEY },
});
const { decision, decision_v3, reason } = await res.json();
if (decision === "approved") {
// safe to grant access / open the account here
}resp = requests.get(
f"{BASE}/v3/session/{session_id}/decision/",
headers={"x-api-key": os.environ["TF_API_KEY"]},
)
body = resp.json()
if body["decision"] == "approved":
... # safe to grant access / open the account here{
"session_id": "6f0e2b7a-…",
"status": "approved",
"decision": "approved",
"decision_v3": {
"id_verifications": [],
"liveness_checks": [],
"face_matches": [],
"aml_screenings": [],
"reviews": []
},
"reason": ""
}session_idstringstatusstringdecisionstringdecision_v3objectreasonstringdecision_v3 — the per-module breakdown
decision_v3 groups every module result the workflow produced into typed
arrays, so you can see why a session landed where it did without parsing raw
module output. Each entry in an array carries the module's own node_id
(matters once a feature repeats — see Workflows),
its status, a score where applicable, any warnings, and a module-specific
data object:
{
"decision_v3": {
"id_verifications": [
{
"node_id": "ID_VERIFICATION",
"status": "approved",
"score": 0.97,
"warnings": [],
"data": { "document_type": "passport", "country": "NPL" }
}
],
"liveness_checks": [
{ "node_id": "LIVENESS", "status": "approved", "score": 0.99, "warnings": [], "data": { "method": "PASSIVE" } }
],
"face_matches": [
{ "node_id": "FACE_MATCH", "status": "approved", "score": 0.93, "warnings": [], "data": {} }
],
"aml_screenings": [
{ "node_id": "AML_SCREENING", "status": "approved", "score": 0.0, "warnings": [], "data": { "hits": 0 } }
],
"reviews": []
}
}| Key | Populated by |
|---|---|
id_verifications | Document verification (OCR / MRZ / authenticity). |
liveness_checks | Passive, colour-flash or 3D-action liveness. |
face_matches | Selfie ↔ document face match. |
aml_screenings | AML / PEP / sanctions screening. |
reviews | Operator review actions. |
Additional keys such as nfc_verifications, poa_verifications,
phone_verifications, email_verifications, ip_analyses,
database_validations and questionnaire_responses appear when those modules
run. Empty arrays mean the category was not part of this workflow — not that
the module failed.
Note
If a workflow repeats a module (two ID checks, for instance), that category's
array holds one entry per node_id, not one entry per feature. Match on
node_id if you need to tell two instances of the same module apart.
Application decisions (/api/v1)
For an application, workflow
statuses are more granular than the public contract. Prefer application.decision.
| Decision | Meaning |
|---|---|
approved | Application completed successfully. |
rejected | Rejected by identity / risk / operator logic. |
manual_review | Requires an operator. |
in_progress | No final decision yet. |
abandoned | Closed / abandoned. |
Note
The application contract uses rejected, while a /v3 session uses
declined for the failed outcome. They mean the same thing on different
surfaces — map both to your "failed" branch.
Raw statuses such as form_filled, sdk_redirected, completed and
kyc_failed may still appear for workflow diagnostics. Do not build long-term
external branching on raw statuses unless it is agreed in your integration
contract.
Comparing the two decision contracts
/v3 session decision | /api/v1 application decision | |
|---|---|---|
| Pass | approved | approved |
| Fail | declined | rejected |
| Held for a human | manual_review | manual_review |
| Still running | in_progress | in_progress |
| Closed without finishing | abandoned | abandoned |
| Module-level detail | decision_v3 arrays | Not exposed directly — read the child session's decision_v3 |
Why a session lands in review
A session becomes manual_review / in_review rather than a clean pass or fail
when:
- A module scored inside its review band rather than clearly passing or failing (e.g. a face match just under threshold).
- AML screening returned a hit, or could not run — screening fails closed.
- A module with no real web implementation (currently NFC verification and database validation) is enabled by a legacy or imported workflow. Rather than fake a pass, the engine routes it to review so a human decides.
- The applicant used "Continue anyway" after a failed check, where the module allows bypass.
- An operator manually held it.
An operator resolves it in Console → Verifications. Approving or declining there emits the terminal webhook and, for flow-spawned sessions, resumes the parent application.
Edge cases
- A session with zero populated arrays. A session that reached a terminal
state without running any module (e.g. abandoned at the welcome screen) has
every
decision_v3array empty. That's a legitimate response, not a bug — checkdecisionandstatusfirst, then usedecision_v3to explain why. - AML "could not run." Because AML screening fails closed, a transient
upstream outage on the screening provider routes the session to
manual_reviewrather than silently skipping the check or passing it — don't treat a missingaml_screeningsresult as a pass. - Reason string is empty on a clean approval.
reasonis populated for reviews and declines; on a straightforwardapproveddecision it is typically"". Don't treat an emptyreasonas an error. - Retrying a read after an operator overrides the status.
update-status/changes the authoritative decision immediately; a decision read issued right after should reflect the override, but always re-read rather than caching a decision you fetched before the override.
Warning
Common pitfall: branching on status instead of decision. Raw statuses
like awaiting_user or resubmitted can be added or reshuffled over time;
the five-value decision enum (and the application's five-value equivalent) is
the stable contract. If your code has a switch statement on status, that's
a sign to refactor onto decision.
The golden rule
Show the applicant an outcome from the SDK/browser result if you like — but
change your own system state (accounts, access, funds) only after the signed
webhook or a server-side GET /v3/session/<id>/decision/ confirms the decision.