Webhook signing and limits
Webhook headers, verifying a signature in Node and Python, secret rotation, custom headers, refused addresses, the retry schedule and every limit.
reference~4 minVerified v0.186.0
Every message is signed with your endpoint's secret, following Standard Webhooks, so an existing verifier for that scheme works unchanged. Add an endpoint first: Webhooks.
Headers
| Header | Value |
|---|---|
webhook-id | The message id, msg_ and a uuid. The same on every retry and replay. |
webhook-timestamp | Unix seconds when this attempt was sent. It changes on each attempt. |
webhook-signature | v1, and a base64 signature. Two, separated by a space, during a rotation. |
content-type | application/json |
user-agent | SuperArtifacts-Webhooks/1 |
The signature
Sign the webhook-id, a dot, the webhook-timestamp, a dot and the raw body. The key is the base64 decoding of the secret after whsec_. The signature is the base64 of the HMAC-SHA256.
Verify the bytes exactly as received and parse the JSON afterwards: a body parsed and written out again differs, and the signature fails. Refuse a missing header or a timestamp more than five minutes from your clock, compare in constant time and answer 401. A 401 is not retried.
Node
import { createHmac, timingSafeEqual } from 'node:crypto' // rawBody: the request body exactly as received, as a string. export function verify(secret, headers, rawBody) { const id = headers['webhook-id'] const timestamp = headers['webhook-timestamp'] const signatures = headers['webhook-signature'] if (!id || !timestamp || !signatures) return false const age = Math.abs(Date.now() / 1000 - Number(timestamp)) if (!(age <= 300)) return false const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest() return signatures.split(' ').some((entry) => { const given = Buffer.from(entry.replace(/^v1,/, ''), 'base64') return given.length === expected.length && timingSafeEqual(given, expected) }) }
Python
import base64, hashlib, hmac, time def verify(secret: str, headers: dict, raw_body: bytes) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not (msg_id and timestamp and signatures): return False try: if abs(time.time() - int(timestamp)) > 300: return False except ValueError: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any( hmac.compare_digest(entry.removeprefix("v1,"), expected) for entry in signatures.split(" ") )
Secrets and rotation
A secret starts whsec_ and is made by us. Reveal or rotate it on the endpoint's Overview. After a rotation the old secret keeps verifying for a while and every message carries both signatures, so a verifier that accepts any match keeps working while you deploy the new secret.
Custom headers
Add them on Advanced. They travel beside the signature headers.
| Allowed | Refused |
|---|---|
Names starting x-, such as x-environment. authorization, whose value is stored encrypted and never shown again. | host, user-agent, transfer-encoding, connection, cookie, any name starting content- or webhook-, a repeated name, a value with a line break. |
Addresses we refuse
| Refused | Why |
|---|---|
| Anything but https | The message and its signature travel in the clear. |
| An IP address, or a name with a user and password in it | Only a plain host name is a target. |
Our own hosts, and names ending localhost, local, internal or home.arpa | A message must not reach our network or yours. |
| A name that resolves to a private address | Checked when you save, and again on every connect, so a change of DNS in between cannot slip through. |
| A redirect | Never followed; it fails the attempt, which is retried. |
We send from addresses that change, so do not allowlist ours. The signature is the check.
Retry schedule
| Attempt | Sent | Time since the event |
|---|---|---|
| 1 | Immediately | At once |
| 2 | 5 seconds after the one before | 5 seconds |
| 3 | 5 minutes after the one before | 5 minutes 5 seconds |
| 4 | 30 minutes after the one before | 35 minutes 5 seconds |
| 5 | 2 hours after the one before | 2 hours 35 minutes |
| 6 | 5 hours after the one before | 7 hours 35 minutes |
| 7 | 10 hours after the one before | 17 hours 35 minutes |
| 8 | 10 hours after the one before | 27 hours 35 minutes |
You can change the schedule on Advanced. A retry can land up to a minute after it is due.
Limits
| Limit | Value |
|---|---|
| Endpoints in one account | 20 |
| Custom headers on one endpoint | 10 |
| A custom header value | 512 characters |
| One event body | 16 KiB |
| Time to connect | 5 seconds |
| Time for the whole request | 10 seconds |
| How much of your answer is read | the first 4 KiB |
| How much of it is kept in the log | 1,024 characters |
| A `Retry-After` on 429 or 503 is honoured up to | 1 hour |
| Attempts per delivery, by default | 8 |
| A custom retry schedule | 1–10 steps, each up to 24 hours |
| Deliveries to one endpoint | 60 per minute |
| Saving endpoints | 20 per hour, per account |
| Test events | 10 per hour, per endpoint |
| Replays and recovers | 10 per minute, per account |
| Secret rotations | 10 per hour, per endpoint |
| Messages a Recover resends | up to 500 a call |
| An endpoint is switched off after | 50 failures in a row and no success for 3 days |
| Messages and attempts are kept | 30 days |
| An old secret keeps verifying for | 24 hours |
| A receiver should refuse a timestamp further away than | 5 minutes |
Next: Troubleshooting.