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 change t to make the replay look fresh — that changes the signed string and breaks v1.
  • An attacker who replays the original t unchanged 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

  1. Split the header into t and v1.
  2. Reject if t is stale — older than 5 minutes — to guard against replay.
  3. Compute HMAC-SHA256(secret, "<t>." + raw_request_body) as lowercase hex.
  4. Compare it to v1 in constant time.
  5. 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.

FAQ