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

Kind: reference · ~4 min · Verified v0.186.0 · Next: [Limits](https://pr-1807-stg.superartifacts.app/docs/reference/limits.md)

Every message is signed with your endpoint's secret, following [Standard Webhooks](https://www.standardwebhooks.com), so an existing verifier for that scheme works unchanged. Add an endpoint first: [Webhooks](https://pr-1807-stg.superartifacts.app/docs/webhooks.md).

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

```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.

| 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](https://pr-1807-stg.superartifacts.app/docs/troubleshooting.md).
