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 a flat payload:

{
  "session_id": "6f0e2b7a-…",
  "status": "approved",
  "decision": "approved",
  "decision_reason": "",
  "vendor_data": "your-user-reference-123",
  "workflow_id": "9c1f…",
  "application_id": null,
  "created_at": "2026-07-03T08:00:00Z",
  "completed_at": "2026-07-03T08:06:12Z",
  "event_type": "identity.kyc.session.approved",
  "idempotency_key": "b2a1…"
}

The fields you branch on are top-level: status / decision and vendor_data (your user reference). application_id is null for a pure /v3 session and set when the session was spawned by a hosted flow step.

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.

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" }), // 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 decision, not raw status

decision (session) and application.decision (application) are the stable contract. Raw statuses are workflow diagnostics.

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. Use the test-send button to fire a delivery and confirm your endpoint returns 2xx and verifies the signature.

Troubleshooting

FAQ

Next