Webhooks
Receive signed requests from your flows and verify they came from Snagr.
A Call a webhook step POSTs JSON to a URL you choose. The URL must be public HTTPS; private and local addresses are refused, and redirects aren't followed.
What you receive
{
"type": "flow.step",
"id": "run_8f2c…:1",
"created_at": "2026-10-13T09:30:00.000Z",
"flow": {
"id": "…",
"name": "Checkout started — no purchase",
"trigger": "event:checkout_started"
},
"run": {
"id": "run_8f2c…",
"step": 1,
"started_at": "2026-10-13T09:00:00.000Z"
},
"person": { "email": "sam@example.com", "name": "Sam" },
"merge": { "customer_name": "Sam", "…": "…" }
}id is the same every time for a given run and step, so use it to ignore duplicates. merge carries the same values your email templates can use.
Verifying the signature
Every request has a snagr-signature header:
snagr-signature: t=1760000000,v1=5257a869e7…v1 is an HMAC-SHA256 of `${t}.${rawBody}` using your webhook secret (whsec_…, shown in Developers & API keys in the sidebar).
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return (
fresh &&
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}Respond with a 2xx status within 5 seconds. Anything else is recorded as a failed step on the person's timeline. Snagr doesn't retry, and a failing endpoint never holds up the rest of the flow.