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:

DecisionMeaningUnderlying statuses
approvedVerification passed.approved
declinedVerification failed.declined
manual_reviewHeld for an operator.in_review, awaiting_user
in_progressNo final decision yet.not_started, in_progress, resubmitted
abandonedClosed / 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_idstring
The session UUID.
statusstring
The full, raw session status.
decisionstring
The stable decision from the table above.
decision_v3object
Per-category module results — see below.
reasonstring
Human-readable reason for a review or decline (may be empty).

decision_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": []
  }
}
KeyPopulated by
id_verificationsDocument verification (OCR / MRZ / authenticity).
liveness_checksPassive, colour-flash or 3D-action liveness.
face_matchesSelfie ↔ document face match.
aml_screeningsAML / PEP / sanctions screening.
reviewsOperator 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.

DecisionMeaning
approvedApplication completed successfully.
rejectedRejected by identity / risk / operator logic.
manual_reviewRequires an operator.
in_progressNo final decision yet.
abandonedClosed / 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
Passapprovedapproved
Faildeclinedrejected
Held for a humanmanual_reviewmanual_review
Still runningin_progressin_progress
Closed without finishingabandonedabandoned
Module-level detaildecision_v3 arraysNot 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_v3 array empty. That's a legitimate response, not a bug — check decision and status first, then use decision_v3 to explain why.
  • AML "could not run." Because AML screening fails closed, a transient upstream outage on the screening provider routes the session to manual_review rather than silently skipping the check or passing it — don't treat a missing aml_screenings result as a pass.
  • Reason string is empty on a clean approval. reason is populated for reviews and declines; on a straightforward approved decision it is typically "". Don't treat an empty reason as 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.

FAQ