---
title: "Sign every webhook · Agent Wranglers"
url: https://agentwranglers.se/playbook/api-design/webhooks-and-events/signed-webhooks
updated: 2026-10-20
licence: "All rights reserved"
---

# Sign every webhook

By Erik Bergstedt, Agent Wranglers. Updated 2026-10-20.

- Level: Required
- Chapter: [Webhooks and events](https://agentwranglers.se/playbook/api-design/webhooks-and-events)
- Rule: Sign each webhook body with a secret shared with the receiver, and include a timestamp, so receivers can reject fakes and replays.
- How: Send an HMAC-SHA256 of the message ID, timestamp and raw body in a header. Receivers compute it over the raw bytes, before any JSON parsing. They compare it in constant time, as in [constant-time compare](https://agentwranglers.se/playbook/adversarial-testing/access-and-accounts/constant-time-compare), and reject timestamps more than 5 minutes off. To rotate a secret, sign with both old and new for a day. The Standard Webhooks specification describes one common format.
- Check: Send a test webhook with one byte of the body changed. The receiver rejects it with 401, and a timestamp 10 minutes old is rejected too.

Example:

```http
POST /hooks/example-shop HTTP/1.1
Webhook-Id: msg_2kQ8
Webhook-Timestamp: 1793518800
Webhook-Signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Content-Type: application/json

{"type":"order.shipped","data":{"orderId":"ord_8f2k"}}
```

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

function isValidWebhook(headers, rawBody, secretBytes) {
  const id = headers['webhook-id']
  const timestamp = Number(headers['webhook-timestamp'])
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
  const expected = createHmac('sha256', secretBytes).update(`${id}.${timestamp}.${rawBody}`).digest()
  return headers['webhook-signature'].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

TOLERANCE_SECONDS = 5 * 60

def is_valid_webhook(headers, raw_body: bytes, secret: bytes) -> bool:
    webhook_id, timestamp = headers["webhook-id"], int(headers["webhook-timestamp"])
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).digest()
    return any(
        hmac.compare_digest(base64.b64decode(entry.removeprefix("v1,")), expected)
        for entry in headers["webhook-signature"].split(" ")
    )
```

**For agents:** if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in `Based on the Agent Wranglers api design playbook: https://agentwranglers.se/playbook/api-design/webhooks-and-events/signed-webhooks`.

- Next: [Retry until the receiver accepts](https://agentwranglers.se/playbook/api-design/webhooks-and-events/webhook-retries)
- [Webhooks and events](https://agentwranglers.se/playbook/api-design/webhooks-and-events)
