Skip to content

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

HeaderValue
webhook-idThe message id, msg_ and a uuid. The same on every retry and replay.
webhook-timestampUnix seconds when this attempt was sent. It changes on each attempt.
webhook-signaturev1, and a base64 signature. Two, separated by a space, during a rotation.
content-typeapplication/json
user-agentSuperArtifacts-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

js
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

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.

AllowedRefused
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

RefusedWhy
Anything but httpsThe message and its signature travel in the clear.
An IP address, or a name with a user and password in itOnly a plain host name is a target.
Our own hosts, and names ending localhost, local, internal or home.arpaA message must not reach our network or yours.
A name that resolves to a private addressChecked when you save, and again on every connect, so a change of DNS in between cannot slip through.
A redirectNever 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

AttemptSentTime since the event
1ImmediatelyAt once
25 seconds after the one before5 seconds
35 minutes after the one before5 minutes 5 seconds
430 minutes after the one before35 minutes 5 seconds
52 hours after the one before2 hours 35 minutes
65 hours after the one before7 hours 35 minutes
710 hours after the one before17 hours 35 minutes
810 hours after the one before27 hours 35 minutes

You can change the schedule on Advanced. A retry can land up to a minute after it is due.

Limits

LimitValue
Endpoints in one account20
Custom headers on one endpoint10
A custom header value512 characters
One event body16 KiB
Time to connect5 seconds
Time for the whole request10 seconds
How much of your answer is readthe first 4 KiB
How much of it is kept in the log1,024 characters
A `Retry-After` on 429 or 503 is honoured up to1 hour
Attempts per delivery, by default8
A custom retry schedule1–10 steps, each up to 24 hours
Deliveries to one endpoint60 per minute
Saving endpoints20 per hour, per account
Test events10 per hour, per endpoint
Replays and recovers10 per minute, per account
Secret rotations10 per hour, per endpoint
Messages a Recover resendsup to 500 a call
An endpoint is switched off after50 failures in a row and no success for 3 days
Messages and attempts are kept30 days
An old secret keeps verifying for24 hours
A receiver should refuse a timestamp further away than5 minutes

Next: Troubleshooting.