Reference

Webhooks overview

Signed server-to-server events — the authoritative channel for verification outcomes.

Obsidian pushes a signed HTTP POST to your endpoint when something decisive happens: a verification session settles, an application changes decision, or a risk-list match forces a review. The webhook is the channel you build on — the result your browser or mobile SDK receives is a UX hint, nothing more.

Configure the endpoint URL and signing secret in Console → Developers → Webhooks. That screen also lists every delivery attempt with its response code, lets you resend a delivery, and has a test-send button.

Info

Need more than one endpoint, or events beyond terminal decisions (per-module results, AML hits, review-case movement, applicant progress)? Use the Connect layer — granular multi-endpoint subscriptions that also power the Zapier and n8n integrations. See Integrations. This page documents the classic single tenant-level webhook, which keeps working unchanged.

Warning

The client result is advisory. Grant access, open accounts, or move money only after a signed webhook — or a server-side GET /v3/session/<id>/decision/ — confirms the decision. A user can close the tab after approval, lose connectivity mid-redirect, or tamper with the client; the webhook is HMAC-signed and cannot be spoofed.

Why a webhook and not just the redirect result

Client result

Fast, great for UX ("Verification complete — redirecting…"), but it travels through a browser or mobile process you don't control. It can be delayed, skipped (tab closed), or — in a hostile scenario — forged by a modified client.

Webhook

Slower by design (server-to-server, after the decision is durably recorded), HMAC-signed with a secret only you and Obsidian hold, retried until your endpoint acknowledges it, and immune to anything the end-user's device does.

Use the client result to update the UI. Use the webhook — or a direct GET .../decision/ poll — to gate anything that matters: account creation, fund transfers, access grants.

Delivery lifecycle

Every delivery is a row in the tenant's WebhookDelivery log (visible in Console → Developers → Webhooks) that moves through three states:

                    enqueue_delivery()
                            │
                            ▼
                    ┌───────────────┐
    application/    │    PENDING    │   HTTP POST, 15s timeout
    session reaches │  attempt = 0  │   X-Webhook-Signature +
    a terminal state└───────┬───────┘   X-Webhook-Delivery headers
                            │
              immediate in-process attempt (attempt 1)
                            │
              ┌─────────────┴─────────────┐
              │                           │
         2xx response                non-2xx / timeout /
              │                       SSRF-blocked / DNS fail
              ▼                           ▼
      ┌───────────────┐           ┌────────────────────┐
      │   DELIVERED    │           │ retry scheduled at  │
      │ delivered_at   │           │  next_attempt_at    │
      │ set, done      │           │  (backoff schedule) │
      └───────────────┘           └──────────┬──────────┘
                                              │
                          Celery beat polls every 60s for
                          due rows → attempt_delivery() again
                                              │
                              ┌───────────────┴───────────────┐
                              │                                │
                         2xx response                  non-2xx again,
                              │                     attempt >= max attempts
                              ▼                                ▼
                      ┌───────────────┐             ┌────────────────────┐
                      │   DELIVERED    │             │       FAILED        │
                      └───────────────┘             │  (visible + resend-  │
                                                      │   able in console)  │
                                                      └────────────────────┘

The first attempt happens synchronously, in-process, at the moment the event is enqueued — so a healthy endpoint typically sees the delivery within the same request cycle that produced the decision. Only failures fall through to the polling retry path.

Retry and backoff behavior

Delivery is at-least-once. A failed attempt (any non-2xx status, a timeout, a DNS failure, or an SSRF-guard rejection) is retried on a fixed backoff schedule, doubling the previous gap roughly five-to-tenfold each step:

AttemptTriggerDelay before this attempt
1Immediate, in-process, when the event is enqueued—
2Celery beat retry pass (runs every 60s, picks up due rows)1 minute
3Retry pass5 minutes
4Retry pass30 minutes
5Retry pass1 hour
6 (final)Retry pass24 hours

If attempt 6 also fails, the delivery is marked failed permanently — it will not be retried automatically again, but it stays visible in Console → Developers → Webhooks and you (or your tenant admin) can trigger a manual Resend, which creates a fresh delivery row with the same idempotency_key and makes one synchronous attempt immediately.

Consequences of this model, worth designing around:

  • The same event can arrive more than once. A resend, a retry racing a slow-but-eventually-successful original attempt, or an operator-triggered resend of an already-delivered event can all put the same idempotency_key on your queue twice.
  • Events can arrive out of order. A .review event's retry can be in-flight when the .approved event that supersedes it fires and succeeds on the first try. Don't assume monotonic arrival — compare created_at, or re-read the authoritative decision from the API when in doubt.
  • An endpoint that is down for more than ~26 hours loses events for good (1 + 5 + 30 + 60 + 1440 minutes of backoff ≈ 26 hours of retrying). Anything that fails permanently is still visible and resendable in the console, but nothing pages you automatically — monitor delivery health from your side too (see Troubleshooting below).

Tip

Every attempt has a 15-second timeout and follows no redirects — a 3xx response from your endpoint is treated as a failure, not followed. Point the webhook URL at the final endpoint, not a URL that 301s elsewhere.

Two envelope shapes

There are two families of events, and their payloads differ. Both are signed the same way and both add event_type and idempotency_key.

Terminal ThirdFactor Verify sessions (/v3) emit the session's core fields at the top level, plus session — the complete session, the same object GET /v3/session/<id>/ returns at the moment of the event:

{
  "session_id": "6f0e2b7a-…",
  "status": "approved",
  "workflow_id": "9c1f…",
  "vendor_data": "your-user-reference-123",
  "decision": {
    "LIVENESS": {
      "feature": "LIVENESS", "status": "approved", "score": 91.2, "warnings": [],
      "data": { "score": 91.2, "method": "PASSIVE", "passed": true, "engine": "…" }
    },
    "ID_VERIFICATION": { "feature": "ID_VERIFICATION", "status": "approved", "score": 98, "warnings": [], "data": { "…": "…" } }
  },
  "decision_reason": "All checks passed",
  "application_id": null,
  "created_at": "2026-07-03T08:00:00+00:00",
  "completed_at": "2026-07-03T08:06:12+00:00",
  "event_type": "identity.kyc.session.approved",
  "idempotency_key": "b2a1…",
  "session": {
    "id": "6f0e2b7a-…",
    "status": "approved",
    "decision_reason": "All checks passed",
    "risk": { "score": 10, "level": "low", "reasons": ["…"], "rules": ["…"], "decision": "approved", "escalated": false },
    "identity": { "full_name": "Sita Sharma", "date_of_birth": "1994-03-12", "document_number": "…", "…": "…" },
    "modules": [
      { "node_id": "ID_VERIFICATION", "feature": "ID_VERIFICATION", "label": "ID Verification",
        "status": "approved", "score": 98, "warnings": [],
        "started_at": "2026-07-03T08:02:10Z", "completed_at": "2026-07-03T08:02:14Z",
        "data": { "fields": { "…": "…" }, "front_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/…" },
        "step_status": "completed", "version": null, "edited": false },
      { "node_id": "QUESTIONNAIRE", "feature": "QUESTIONNAIRE", "label": "Questionnaire",
        "status": "approved", "score": 100, "warnings": [],
        "started_at": "2026-07-03T08:03:02Z", "completed_at": "2026-07-03T08:04:40Z",
        "data": { "form_id": "account-purpose", "answers": { "purpose": "savings" }, "…": "…" },
        "step_status": "completed", "version": 2, "edited": true }
    ],
    "media": [
      { "kind": "document", "side": "front", "feature": "ID_VERIFICATION", "node_id": "ID_VERIFICATION",
        "url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/…", "content_type": "image/jpeg",
        "source_id": "3c1d9a4e-…", "captured_at": "2026-07-03T08:02:09Z" },
      { "kind": "selfie", "feature": "LIVENESS", "node_id": "LIVENESS",
        "url": "https://acme.thirdfactor.ai/v3/media/kyc/faces/…", "content_type": "image/jpeg",
        "source_id": "7d2e4f60-…", "captured_at": "2026-07-03T08:05:30Z" }
    ],
    "network": { "ip_addresses": [{ "ip": "203.0.113.5", "country": "NP", "is_vpn": false, "…": "…" }],
                 "precise_location": { "latitude": 27.7172, "longitude": 85.324, "…": "…" },
                 "clients": [{ "role": "mobile", "label": "iPhone · Safari", "…": "…" }] },
    "duplicates": [],
    "allow_back": true, "back_count": 1, "edited": true,
    "decision": { "…": "the per-node snapshot, evidence as URLs" },
    "decision_v3": { "id_verifications": ["…"], "liveness_checks": ["…"], "…": "…" },
    "tags": [], "credits_charged": 3.0, "metadata": {}, "…": "…"
  }
}

Branch on the top-level status and identify the user by vendor_data. application_id is null for a pure /v3 session and set when the session was spawned by a hosted flow step. Every field is listed in the KYC session payload below.

The KYC session payload, field by field

The top-level keys are the original payload and keep their exact shape and values — existing handlers need no change.

FieldTypeMeaning
session_idstringThe session UUID.
statusstringapproved, declined, in_review (event .review), abandoned or expired. Branch on this.
workflow_idstring | nullThe workflow the session ran.
vendor_datastringYour user reference, as passed at creation.
decisionobjectThe per-node decision snapshot recorded when the session finished, keyed by node id (the feature key, e.g. LIVENESS, for single-node features): {feature, status, score, warnings, data} per module. data is the module's stored result, so evidence appears as ids (document_id); the URLs are in session. Empty for sessions that expired or were abandoned.
decision_reasonstringWhy the session ended as it did (All checks passed, Flagged for manual review, a reviewer's reason, …).
application_idstring | nullThe parent application when a hosted flow spawned the session.
created_at / completed_atstringISO 8601 timestamps.
guardian_reviewobject | nullAdded (additive). Set whenever a guardian risk signal on a Minor branch applies to the session — also on a session declined at finalisation, and after an operator decides a held session. held is true only when guardian risk turned an automatic approval into a review; branch on status for the outcome. It is the same object as session.risk.guardian (see below). null when no guardian signal applies.
event_type / idempotency_keystringAdded by delivery storage; idempotency_key is the same on every retry and resend.
sessionobjectThe complete session — byte-for-byte what GET /v3/session/<id>/ returns at the moment of the event (see below).
session_omittedarrayOnly on an unusually large session (over 256 KB): which parts of session were left out to fit (decision_v3, then decision, then modules.data). Fetch GET /v3/session/<id>/ for the rest.

Inside session:

FieldMeaning
modules[]Every node of the workflow in order — all features, including ones decision_v3 has no array for — with label, status, score, warnings, started_at, completed_at and the full module data (OCR fields, liveness engine and score, face similarity, AML matches and lists, IP / VPN flags, questionnaire answers, business-document extracts, …). Evidence inside data is resolved to URLs. A node that never ran is not_started. A new module type appears here automatically.
modules[].step_status, modules[].version, modules[].editedAdded (additive) with Back navigation. step_status is the node's place on the applicant's path: completed (finished on the path), active (on the path and still to do — or running again after an applicant change), superseded (finished, then a branch change after an applicant edit took it off the path; its result was reset and the earlier data is kept for audit) or skipped (never on the applicant's path — a branch not taken, the contact channel they didn't pick). version is an input step's data version — questionnaire, product selection, branch visit, confirm details: 1 as first submitted, plus one for each accepted change; null for every other step (ID, liveness, AML, …) and for input steps not submitted yet. edited is true when the applicant changed the step after first submitting it. A later questionnaire whose read-only answers are prefilled from a changed step gets them re-derived — a new version, edited: true.
allow_back, back_count, editedAdded (additive). allow_back — whether the session started with the workflow's Allow applicants to go back setting on (pinned at creation, default true). back_count — how often the applicant pressed Back. edited — the applicant changed at least one step after first submitting it. See Back navigation.
media[]Every evidence file the session holds, flat and oldest first: document sides (identity, address, business, questionnaire upload, custom document), the selfie and its liveness video, active-liveness event stills, reference images, the signature capture, video-call recording and stills. Each has kind, feature, node_id, url, content_type, captured_at and source_id; documents add side and document_type, videos add status.
identityThe identity read so far: name (and the romanized full_name_en for non-Latin documents), date of birth, nationality, document type / number / country / dates, email and phone with their verified flags, address.
riskThe risk profile: score, level, reasons (the risk factors), rules (the rules that triggered), decision and whether the rules escalated the outcome. risk.guardian is set whenever a guardian risk signal applies (declined and operator-decided sessions included): signals and their labels, current_signals, sources (current, earlier_attempt, repeat_submission), points (already included in score), tier (maker_checker or compliance), action (maker_checker_review or compliance_review), held (whether it stopped an automatic approval), and risk_score / risk_level.
networkip_addresses[] (geo-IP, VPN / proxy / Tor / hosting flags, ISP, abuse score, precise GPS captured alongside), precise_location (browser GPS, when the workflow asked for it) and clients[] (every device that touched the session, both ends of a desktop → phone handoff).
duplicates[]1:N duplicate-identity hits: the other identity (matched_individual_id, matched_external_user_id), face score, corroborating signals and the review status.
decision, decision_v3The same decision data as the API: the per-node snapshot and the grouped decision_v3 arrays, both with evidence as URLs.
decision_reason, decline_reason, review_notesThe outcome's reasons and the reviewer's notes.
stage, tags, credits_charged, metadata, workflow_name, workflow_version, verification_type, individual_id, url, expires_at, started_atAs on Get session.

Every url is a stable /v3/media/ link on the API host, fetched with your x-api-key — safe to store and fetch later (it stops resolving once evidence retention purges the file). A video's url is null while its WebM is still being produced (status: pending). See Evidence media.

Warning

Session webhooks are now typically 10–50 KB (up to about 256 KB for a very large session, plus the top-level decision). Some frameworks cap request bodies at 100 KB by default — raise it for this route (Express: express.raw({ type: "application/json", limit: "1mb" })).

The application envelope, field by field

FieldTypeMeaning
payload_versionstringSchema version of the envelope (currently 2026-05-17).
eventstringLegacy event name — see Events for the exact strings in use today.
applicationobjectFull application snapshot: id, external_ref, tracking_id, status, decision, flow_id, current_step_id, created_at, updated_at.
form_dataobjectPre-SDK applicant data plus any product selection.
productobject | nullSelected product/plan, when the flow captured one.
sdk_responseobjectRaw verification result from the identity step.
blocklist_matchobject | nullPresent only on a risk-list hit; carries blocklist_id, blocklist_name, block_type, value, action.
created_atstringWhen the event was generated.
event_typestringAdded by delivery storage; mirrors event. Prefer this for routing.
idempotency_keystringAdded by delivery storage; stable across retries and resends of the same event.

Note

Legacy event names still appear for backwards compatibility. For applications, key off application.decision rather than pattern-matching the human-readable event name — see Events for exactly why that matters.

Headers on every delivery

HeaderPresent whenValue
Content-TypeAlwaysapplication/json
X-Webhook-SignatureA signing secret is configured for the tenantt=<unix_seconds>,v1=<hex_hmac_sha256> — see verifying signatures
X-Webhook-DeliveryAlwaysThe WebhookDelivery row's UUID. Stable across retries of the same attempt chain; useful for correlating your logs with the console's delivery list.
AuthorizationA bearer token is configured for the destinationBearer <token> — see receiver authentication
Your additional headersConfigured for the destinationExactly the names and values you saved (e.g. X-Api-Key) — see receiver authentication

Warning

If no signing secret is saved for the tenant, X-Webhook-Signature is omitted entirely — the request is sent unsigned. Always configure a secret before going to production, and reject unsigned deliveries in your handler.

Designing an idempotent webhook handler

Because delivery is at-least-once, your handler must treat "processed already" as the normal case, not an edge case. The shape that holds up in production:

Capture the raw body and verify the signature

Before any JSON parsing. Reject anything that doesn't verify — see verifying signatures.

Acknowledge with 2xx immediately

Do this before any business logic runs. A slow or failing handler causes retries and a delivery backlog on Obsidian's side, and ties up your own request thread.

Look up idempotency_key in a dedupe store

Use a table or cache keyed on idempotency_key with a unique constraint. If the key already exists, you're looking at a retry or a resend of an event you already handled — skip re-processing (but it's fine to no-op successfully).

Process and record atomically

Insert the idempotency_key and apply the business effect (activate account, update risk status, …) in the same transaction, so a crash between the two can't cause a silent double-process on the next retry.

import express from "express";
import { Pool } from "pg";

const app = express();
const db = new Pool();

app.post(
  "/webhooks/obsidian",
  express.raw({ type: "application/json", limit: "1mb" }), // raw body, not express.json()
  async (req, res) => {
    const sig = req.header("X-Webhook-Signature") ?? "";
    if (!verify(process.env.TF_WEBHOOK_SECRET!, sig, req.body)) {
      return res.status(400).send("bad signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));

    // Ack fast — do the real work after responding.
    res.status(200).end();

    try {
      await handleIdempotently(event);
    } catch (err) {
      // Log and let the delivery fail naturally if you returned non-2xx —
      // here we've already acked, so failures need their own alerting path
      // (dead-letter queue, error tracker) rather than relying on Obsidian's retry.
      console.error("webhook processing failed", event.idempotency_key, err);
    }
  },
);

async function handleIdempotently(event: any) {
  const client = await db.connect();
  try {
    await client.query("BEGIN");

    // Unique constraint on idempotency_key does the real dedupe work —
    // this insert either succeeds once, or throws a unique-violation.
    const inserted = await client.query(
      `INSERT INTO processed_webhooks (idempotency_key, event_type, received_at)
       VALUES ($1, $2, now())
       ON CONFLICT (idempotency_key) DO NOTHING
       RETURNING idempotency_key`,
      [event.idempotency_key, event.event_type],
    );

    if (inserted.rowCount === 0) {
      // Already processed this exact delivery — no-op.
      await client.query("ROLLBACK");
      return;
    }

    // Apply the actual business effect in the same transaction.
    if (event.event_type === "identity.kyc.session.approved") {
      await client.query(
        `UPDATE users SET kyc_status = 'approved' WHERE external_ref = $1`,
        [event.vendor_data],
      );
    }

    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
from fastapi import FastAPI, Request, HTTPException
from sqlalchemy import text
from db import engine  # your SQLAlchemy engine

app = FastAPI()

@app.post("/webhooks/obsidian")
async def receive_webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("X-Webhook-Signature", "")

    if not verify(WEBHOOK_SECRET, signature, raw_body):
        raise HTTPException(status_code=400, detail="bad signature")

    event = await request.json()

    # Ack fast: hand off to a background task/queue and return 200 immediately
    # in real deployments. Shown inline here for clarity.
    handle_idempotently(event)
    return {"ok": True}


def handle_idempotently(event: dict) -> None:
    with engine.begin() as conn:  # one transaction for dedupe + effect
        result = conn.execute(
            text("""
                INSERT INTO processed_webhooks (idempotency_key, event_type, received_at)
                VALUES (:key, :event_type, now())
                ON CONFLICT (idempotency_key) DO NOTHING
                RETURNING idempotency_key
            """),
            {"key": event["idempotency_key"], "event_type": event["event_type"]},
        )
        if result.rowcount == 0:
            return  # already processed this exact delivery

        if event["event_type"] == "identity.kyc.session.approved":
            conn.execute(
                text("UPDATE users SET kyc_status = 'approved' WHERE external_ref = :ref"),
                {"ref": event.get("vendor_data")},
            )

Note

Acknowledge-then-process (shown above) trades a small "we said 2xx but our own processing then failed" risk for protection against Obsidian's retry timing out your request. If your business effect absolutely cannot be lost, put the raw event on a durable queue before responding 200, and process it from a worker — that way even a crash between ack and processing doesn't lose the event on your side.

Handling deliveries well

Verify the signature first

Before parsing, before touching your database. See verifying signatures. Verify over the raw request bytes.

Acknowledge fast, work later

Return 2xx in milliseconds and push the event onto a queue. A slow handler causes retries and a delivery backlog.

Deduplicate on idempotency_key

Deliveries are retried and can be manually resent, so the same event can arrive more than once. Make handling idempotent by recording the idempotency_key.

Branch on the outcome field

status (session) and application.decision (application) are the stable contract. A session's decision is its per-module breakdown, not a verdict.

Configuring in the console

  1. Open Console → Developers → Webhooks.
  2. Set the destination URL. It must be publicly reachable over HTTPS and pass the SSRF guard — private, loopback, link-local, and reserved IP ranges are rejected (both IPv4 and IPv6), redirects are not followed, and a rejected delivery is recorded as failed with the reason for diagnosis.
  3. Copy the signing secret and store it in your server config. Rotating it in the console invalidates the previous secret immediately.
  4. If your receiver sits behind a gateway that requires credentials, open Authentication (optional) and add a bearer token and/or additional headers — see receiver authentication.
  5. Use the test-send button to fire a delivery and confirm your endpoint returns 2xx and verifies the signature.

Troubleshooting

FAQ

Next