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:
| Attempt | Trigger | Delay before this attempt |
|---|---|---|
| 1 | Immediate, in-process, when the event is enqueued | — |
| 2 | Celery beat retry pass (runs every 60s, picks up due rows) | 1 minute |
| 3 | Retry pass | 5 minutes |
| 4 | Retry pass | 30 minutes |
| 5 | Retry pass | 1 hour |
| 6 (final) | Retry pass | 24 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_keyon your queue twice. - Events can arrive out of order. A
.reviewevent's retry can be in-flight when the.approvedevent that supersedes it fires and succeeds on the first try. Don't assume monotonic arrival — comparecreated_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
| Field | Type | Meaning |
|---|---|---|
payload_version | string | Schema version of the envelope (currently 2026-05-17). |
event | string | Legacy event name — see Events for the exact strings in use today. |
application | object | Full application snapshot: id, external_ref, tracking_id, status, decision, flow_id, current_step_id, created_at, updated_at. |
form_data | object | Pre-SDK applicant data plus any product selection. |
product | object | null | Selected product/plan, when the flow captured one. |
sdk_response | object | Raw verification result from the identity step. |
blocklist_match | object | null | Present only on a risk-list hit; carries blocklist_id, blocklist_name, block_type, value, action. |
created_at | string | When the event was generated. |
event_type | string | Added by delivery storage; mirrors event. Prefer this for routing. |
idempotency_key | string | Added 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
| Header | Present when | Value |
|---|---|---|
Content-Type | Always | application/json |
X-Webhook-Signature | A signing secret is configured for the tenant | t=<unix_seconds>,v1=<hex_hmac_sha256> — see verifying signatures |
X-Webhook-Delivery | Always | The 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
- Open Console → Developers → Webhooks.
- 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
failedwith the reason for diagnosis. - Copy the signing secret and store it in your server config. Rotating it in the console invalidates the previous secret immediately.
- Use the test-send button to fire a delivery and confirm your endpoint returns
2xxand verifies the signature.