Reference
Verifying signatures
HMAC-SHA256 over the raw body with a timestamp — verify before you parse.
Every delivery carries a Stripe-style X-Webhook-Signature header signed with
your tenant webhook secret (managed in Console → Developers → Webhooks):
X-Webhook-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>Verify it before you trust anything in the payload. An unsigned or mis-signed delivery is either a misconfiguration or a spoof attempt — reject it.
Note
Connect endpoints (Zapier, n8n, granular
subscriptions) use this exact same scheme, except each endpoint signs with
its own whsec_… secret (returned once when the endpoint is created,
rotatable from the console). Everything on this page applies unchanged —
just use the per-endpoint secret.
Warning
If no signing secret is saved for the tenant, Obsidian sends the delivery
without an X-Webhook-Signature header at all. Configure a secret before
going to production and treat a missing header the same as a bad signature —
reject the request.
Why HMAC + a timestamp, not just HMAC
A bare HMAC-SHA256(secret, body) proves the body wasn't tampered with, but
it does nothing to stop a captured request from being replayed later —
the signature would still check out. Folding the Unix timestamp into the
signed string ("<t>.<raw_body>") means:
- An attacker who captures a valid
(header, body)pair can't changetto make the replay look fresh — that changes the signed string and breaksv1. - An attacker who replays the original
tunchanged gets caught by the staleness check (reject anything more than 5 minutes old).
Both checks are required — the HMAC alone doesn't prevent replay, and the timestamp alone (without being part of the signed input) doesn't prevent tampering.
The algorithm
- Split the header into
tandv1. - Reject if
tis stale — older than 5 minutes — to guard against replay. - Compute
HMAC-SHA256(secret, "<t>." + raw_request_body)as lowercase hex. - Compare it to
v1in constant time. - Only then parse the JSON.
Warning
Sign over the raw bytes of the request body. If your framework parses JSON and you re-serialize it, whitespace and key order change and every signature fails. Capture the raw body before any JSON middleware touches it.
Reference implementations
The Python example below is the canonical one from the Obsidian API reference.
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)import crypto from "node:crypto";
export function verify(secret: string, header: string, rawBody: Buffer): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = parts.t, v1 = parts.v1;
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}func verify(secret, header string, rawBody []byte) bool {
var t, v1 string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
continue
}
if kv[0] == "t" {
t = kv[1]
} else if kv[0] == "v1" {
v1 = kv[1]
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}Warning
hmac.compare_digest (Python), crypto.timingSafeEqual (Node), and
hmac.Equal (Go) are all constant-time comparisons. Never compare
signatures with ==, ===, or plain string equality — a byte-by-byte
early-exit comparison leaks timing information an attacker can use to forge a
valid signature one byte at a time.
Complete Node.js / TypeScript example
The snippet above is the verification primitive; this is what it looks like
wired into a full, dependency-light handler — raw body capture, signature
check, fast acknowledgement, and a minimal idempotency guard — using only
Node's built-in http module (no framework required):
import http from "node:http";
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.TF_WEBHOOK_SECRET!;
const MAX_BODY_BYTES = 1_000_000; // 1MB — generous for these payloads
const seenKeys = new Set<string>(); // swap for a real store (Redis, Postgres) in production
function verify(secret: string, header: string, rawBody: Buffer): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5-minute window
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/obsidian") {
res.writeHead(404).end();
return;
}
const chunks: Buffer[] = [];
let size = 0;
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
req.destroy(); // guard against an oversized body tying up the process
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
const signature = req.headers["x-webhook-signature"];
if (typeof signature !== "string" || !verify(WEBHOOK_SECRET, signature, rawBody)) {
res.writeHead(400, { "Content-Type": "text/plain" }).end("bad signature");
return;
}
let event: any;
try {
event = JSON.parse(rawBody.toString("utf8"));
} catch {
res.writeHead(400, { "Content-Type": "text/plain" }).end("invalid json");
return;
}
// Ack immediately — Obsidian's 15s timeout starts from connect, not from
// when you finish business logic. Don't make the sender wait on your DB.
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
// Idempotency: same key means a retry or a manual resend of an event
// you've already seen. In production, back this with a persistent store
// (unique constraint on idempotency_key), not an in-memory Set.
const key = event.idempotency_key;
if (key && seenKeys.has(key)) return;
if (key) seenKeys.add(key);
void processEvent(event).catch((err) => {
console.error("webhook processing failed", key, err);
// Route to your own retry/alerting path — Obsidian already got its 2xx.
});
});
req.on("error", () => res.writeHead(400).end());
});
async function processEvent(event: any): Promise<void> {
switch (event.event_type) {
case "identity.kyc.session.approved":
// event.vendor_data identifies your user
break;
case "identity.kyc.session.declined":
break;
case "identity.application.manual_review":
// event.application.decision === "manual_review"
break;
default:
// Unrecognised event_type — log and move on; don't throw.
console.warn("unhandled event_type", event.event_type);
}
}
server.listen(3000, () => console.log("listening on :3000"));Note
This example deliberately avoids a framework so the raw-body requirement is
visible end to end. If you use Express, the equivalent is
express.raw({ type: "application/json" }) mounted before any
express.json() middleware on the webhook route — see the wiring example
below.
Wiring it into an Express handler
Grab the raw body, verify, acknowledge, then process off the request thread.
import express from "express";
const app = express();
app.post(
"/webhooks/obsidian",
express.raw({ type: "application/json" }), // raw body — not express.json()
(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"));
res.status(200).end(); // ack fast
void queue.enqueue(event); // work later, deduped on idempotency_key
},
);Warning
The most common integration bug here is mounting express.json() globally
and then reading req.body on the webhook route expecting a Buffer — by
then it's already a parsed object, re-serializing it for verification
produces different bytes than what was actually signed, and every signature
"fails" even though nothing is wrong. Scope express.raw() to the webhook
route specifically, or make sure it runs before any global JSON parser.
Testing webhooks locally
Your development machine isn't publicly reachable, so Obsidian can't POST
to http://localhost:3000 directly. Two ways to test:
Console → Developers → Webhooks has a test-send button that fires a
signed delivery at your currently configured URL using your real secret.
Use it to confirm your endpoint verifies signatures correctly before wiring
up anything else — but note the URL still has to be reachable from
Obsidian's servers, so this alone doesn't solve pure localhost development.
Warning
Never point a production tenant's webhook URL at a tunnel for anything beyond a short, deliberate test. The URL is authoritative for real customer decisions; treat swapping it the same as any other production config change.
Tip
Because the signature is computed over the exact raw bytes Obsidian sent, a tunnel is safe to use for this — it doesn't need to (and shouldn't) modify the body or headers in flight. If your tunnel tool rewrites headers or buffers/re-encodes the body, verification will fail for reasons unrelated to your code; prefer a tunnel that passes bytes through unmodified.
Replay protection
The timestamp t is part of the signed string, so an attacker cannot replay an
old body with a fresh timestamp — changing t breaks the signature, and reusing
the original t trips the 5-minute staleness check. Keep these two guards:
- Reject stale timestamps. More than 5 minutes of skew is rejected. Keep your server clock in sync (NTP).
- Deduplicate on
idempotency_key. Legitimate retries reuse the same key, so recording processed keys both dedupes retries and blocks in-window replays.
Rotating the secret
Rotating the secret in Console → Developers → Webhooks invalidates the previous one immediately. Plan a brief window, or point the new secret at a staging consumer and cut over once it verifies cleanly.
Warning
Because rotation is immediate, every delivery attempted between your clicking "rotate" and your server picking up the new secret will fail verification on your side and fall onto the normal retry/backoff schedule (see Webhooks overview) rather than being lost — but plan the rotation as a single atomic change (update secret in console, deploy new secret to your server) to minimize the window.