Webhooks and signatures
Receive events, check they came from us, handle retries.
Create an endpoint with the events you want: through the API (Create an endpoint) or in the app. We POST each event to your URL as JSON.
What the body looks like
Endpoints made through the API get the envelope ("payload_format": "envelope", the default):
{
"id": "evt_01J8Z7BZQ4F6G8J2K5N7Q9S1VC",
"type": "contact.created",
"version": "1",
"api_version": "2026-10-01",
"occurred_at": "2026-09-24T09:20:00Z",
"company_id": 1001,
"data": { … }
}Endpoints made in the app, and older ones, get only the event data object ("payload_format": "data"), with the event id, type and time in the headers below. Switch an endpoint with Update an endpoint ("payload_format": "envelope"). The signature is always over the exact body you receive, whichever format it is.
Headers on every delivery
| Header | What it is |
|---|---|
Webhook-Signature | t=<unix seconds>,v1=<hex> — see below |
Webhook-Id | The event id. The same on every retry and replay: use it to skip duplicates |
Webhook-Event-Id | The same value as Webhook-Id |
Webhook-Delivery-Id | This attempt. New on every try |
Webhook-Event-Type | e.g. contact.created |
Webhook-Event-Version | The payload version, e.g. 1 |
Webhook-Event-Occurred-At | When it happened (ISO 8601) |
Webhook-Api-Version | Your endpoint's API version |
Your own custom headers are added too, but names starting Webhook- or Wc-, and Content-*, Host, User-Agent, Idempotency-Key and Proxy-*, are reserved. Older integrations may also see the same values under Wc-* names during a transition.
The signature scheme is HMAC like Stripe's (t=…,v1=…), not the Standard Webhooks format, so use the code below rather than a Standard Webhooks library.
Answer quickly
Answer with any 2xx within 30 seconds. Anything else is retried with backoff (30 s, 1 m, 5 m, 30 m, 1 h … up to 15 tries). A 4xx is not retried. After 15 failures in a row the endpoint is paused.
Check the signature
Each delivery carries Webhook-Signature: t=<unix seconds>,v1=<hex>. The signature is an HMAC-SHA256 of t + "." + raw body, keyed with your endpoint's signing secret (the whole string, including whsec_).
- Read the raw body before parsing it.
- Recompute the HMAC and compare it to each
v1in constant time. During a secret rotation there are two. - Refuse it if
tis more than 5 minutes from now.
Node.js
import crypto from 'node:crypto';
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((int(v) for k, v in parts if k == "t"), 0)
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in parts if k == "v1")PHP
function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$t = 0; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $sigs[] = $v;
}
if (!$t || abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $s) if (hash_equals($expected, $s)) return true;
return false;
}Go
func Verify(rawBody []byte, header, secret string, tolerance time.Duration) bool {
var t int64
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(part, "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
sigs = append(sigs, v)
}
}
if t == 0 || time.Since(time.Unix(t, 0)).Abs() > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%d.", t)))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range sigs {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}Duplicates happen
A retry or a replay delivers the same event again. Use the event id (the Webhook-Id header, and id in the envelope) to skip ones you have already handled.