Developers
Webhook signing
Every outbound webhook delivery is signed with HMAC-SHA256 so your receiver can verify the payload came from us and wasn't tampered with in transit.
Headers we send
Every signed delivery includes three custom headers in addition to the usual content-type: application/json.
X-SMB-Signature
Comma-separated key=value pairs: t=<unix-seconds>,v1=<hex-sha256>. Today only the v1 scheme exists; future schemes will be additive so older verifiers keep working.
X-SMB-Timestamp
The same unix-seconds value as the t= part of the signature header. Provided separately so verifiers can read it without splitting strings.
X-SMB-Webhook-Id
A UUID unique to this delivery. Use it to dedupe retries in your handler so the same event isn't processed twice.
Signing algorithm
The signed value is the timestamp and the raw request body joined by a literal period, then HMAC-SHA256'd with the org's signing secret and rendered as lower-case hex.
signedPayload = `${timestampSeconds}.${rawBody}`
v1 = hex(hmac_sha256(signingSecret, signedPayload))Reject any delivery whose t is more than 300 secondsfrom your server's wall clock — that's the replay window we use ourselves. Always compare hex digests with a constant-time comparator like Node's timingSafeEqual; never with ===.
The exact byte string is signed — not the parsed JSON. Read the raw request body before any middleware turns it into an object, or you'll re-stringify with different key ordering and the HMAC will mismatch.
Verification sample (TypeScript)
verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const SIGNING_SECRET = process.env.SMB_WEBHOOK_SECRET!; // "whsec_…"
const TOLERANCE_SECONDS = 300;
export function verifySmbWebhook(args: {
rawBody: string;
signatureHeader: string;
}): { ok: true } | { ok: false; reason: string } {
let ts: number | null = null;
let provided: string | null = null;
for (const part of args.signatureHeader.split(",").map((p) => p.trim())) {
if (part.startsWith("t=")) ts = Number(part.slice(2));
else if (part.startsWith("v1=")) provided = part.slice(3);
}
if (ts === null) return { ok: false, reason: "missing timestamp" };
if (provided === null) return { ok: false, reason: "missing signature" };
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - ts) > TOLERANCE_SECONDS) {
return { ok: false, reason: "timestamp outside tolerance" };
}
const expected = createHmac("sha256", SIGNING_SECRET)
.update(`${ts}.${args.rawBody}`)
.digest("hex");
if (provided.length !== expected.length) {
return { ok: false, reason: "signature mismatch" };
}
const a = Buffer.from(provided, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { ok: false, reason: "signature mismatch" };
}
return { ok: true };
}Express route
import express from "express";
import { verifySmbWebhook } from "./verify";
const app = express();
// IMPORTANT: capture the raw bytes BEFORE express.json(). The signature
// covers the exact request body — re-stringifying the parsed object can
// reorder keys and break HMAC.
app.use(express.raw({ type: "application/json" }));
app.post("/hooks/smb-drift", (req, res) => {
const raw = req.body.toString("utf8");
const sig = req.header("x-smb-signature") ?? "";
const result = verifySmbWebhook({ rawBody: raw, signatureHeader: sig });
if (!result.ok) return res.status(401).json({ error: result.reason });
const event = JSON.parse(raw);
// …handle the event…
return res.status(204).end();
});A runnable copy lives in our repo at docs/webhooks/sample-receiver/verify.ts.
Rotating the secret
Visit Settings → Integrations → Outbound webhook as an org owner and click Rotate secret. The new secret is shown immediately — copy it into your receiver before the next event fires.
There is no two-secret grace window today. The instant you rotate, every in-flight delivery that hadn't already grabbed the old secret will use the new one; verifiers running the old secret will fail until updated. Schedule rotations during quiet periods (or chain a receiver that accepts both temporarily).
Troubleshooting
- Signatures never match:you're probably stringifying the parsed JSON before hashing. Capture the raw body bytes first.
- Timestamp outside tolerance: check that your server clock is NTP-synced. Drift over a few minutes will trip the 300-second window.
- Header missing entirely:the org hasn't had a webhook secret provisioned yet. Send a test event from the integrations page — the first delivery lazy-creates the secret.
Looking for the REST API instead? See the API reference.