Webhooks
Add an endpoint in Settings › API & webhooks or with POST /webhook-endpoints. Pick the events (see the catalogue); an endpoint receives only the events the scopes it was made with may read. Up to 10 endpoints per workspace, each a public https address on port 443.
What we send
POSTwith a JSON body:{ "id": "evt_…", "object": "event", "type": "lead.submitted", "api_version": "v1", "created_at": "…", "workspace_id": "…", "data": { "object": { … } } }NorReach-Webhook-Id— the delivery id, the same on every retry (use it to drop duplicates).NorReach-Webhook-Timestamp— unix seconds.NorReach-Signature—v1=+ hex HMAC-SHA256 of<id>.<timestamp>.<raw body>with your signing secret. During a rotation there are two, comma-separated; accept either.
Verifying signatures
Reject a request whose timestamp is more than 300 seconds away from your clock, and compare in constant time. Use the raw body, before parsing it.
TypeScript (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string, before JSON.parse).
export function verifyNorReach(headers, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const id = headers["norreach-webhook-id"];
const timestamp = headers["norreach-webhook-timestamp"];
const signature = headers["norreach-signature"] ?? "";
if (!id || !/^\d+$/.test(timestamp ?? "")) return false;
if (Math.abs(nowSeconds - Number(timestamp)) > 300) return false; // replay window
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${rawBody}`).digest();
return signature.split(",").some((part) => {
const m = /^\s*v1=([0-9a-f]{64})\s*$/.exec(part);
if (!m) return false;
const got = Buffer.from(m[1], "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}Python
import hmac, hashlib, re, time
def verify_norreach(headers, raw_body: bytes, secret: str, now: int | None = None) -> bool:
webhook_id = headers.get("norreach-webhook-id")
timestamp = headers.get("norreach-webhook-timestamp", "")
signature = headers.get("norreach-signature", "")
if not webhook_id or not timestamp.isdigit():
return False
if abs((now or int(time.time())) - int(timestamp)) > 300: # replay window
return False
message = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
for part in signature.split(","):
m = re.fullmatch(r"\s*v1=([0-9a-f]{64})\s*", part)
if m and hmac.compare_digest(m.group(1), expected):
return True
return FalseBy hand
# Check one delivery by hand (bash + openssl):
ID="<NorReach-Webhook-Id>"; TS="<NorReach-Webhook-Timestamp>"; BODY='<the raw body>'
printf '%s.%s.%s' "$ID" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$NORREACH_WEBHOOK_SECRET" | sed 's/^.* /v1=/'
# compare with the NorReach-Signature header (one of its comma-separated values)Retries and the delivery log
Any 2xx within 10 seconds is a success; redirects are not followed. Otherwise we try again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h — about two days in all. An answer of 410 Gone turns the endpoint off at once; 200 failures in a row or three days of nothing but failures turn it off too, and the workspace admins are told. Events are not guaranteed to arrive in order: use created_at.
The delivery log (30 days) shows every attempt, your endpoint answer and the exact body; send any delivery again from there or with POST /webhook-deliveries/{id}/redeliver. Send test queues a signed ping.
Message text
Payloads are thin by default. Message events include the subject and text only when the endpoint was created with include_content: true.
Polling instead
GET /events returns the same events, oldest first. Keep the next_cursor and ask again.