Skip to content
snagr
Snagr docs

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.