Docs
Webhooks

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

  • POST with 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)

TypeScript
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

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 False

By hand

Shell
# 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.