Skip to content
API PlatformDevelopers

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

HeaderWhat it is
Webhook-Signaturet=<unix seconds>,v1=<hex> — see below
Webhook-IdThe event id. The same on every retry and replay: use it to skip duplicates
Webhook-Event-IdThe same value as Webhook-Id
Webhook-Delivery-IdThis attempt. New on every try
Webhook-Event-Typee.g. contact.created
Webhook-Event-VersionThe payload version, e.g. 1
Webhook-Event-Occurred-AtWhen it happened (ISO 8601)
Webhook-Api-VersionYour 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_).

  1. Read the raw body before parsing it.
  2. Recompute the HMAC and compare it to each v1 in constant time. During a secret rotation there are two.
  3. Refuse it if t is 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.