EVT-25Required
Sign every webhook
Sign each webhook body with a secret shared with the receiver, and include a timestamp, so receivers can reject fakes and replays.
How to do it
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, 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.
Examples
HTTP example
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"}}
JavaScript example
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 example
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(" ")
)
How to 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.
Sources
Related rules
- EML-73 Accept bounce and complaint events only when signed in Email
- PAY-18 Let provider events move the order in Payments and billing
- NTF-29 Track what happened to each text in Notifications
- ECM-27 Update delivery status from carrier events in E-commerce and fulfilment
- APS-13 Compare secrets in constant time in Application security
- EVT-28 Store each incoming webhook before you act on it in Integration and events
- EVT-29 Let customers test and inspect their webhook endpoints in Integration and events
- PRC-8 Warn customers before they reach a cap or run out in Pricing, plans and entitlements
For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/events/webhooks/signed-webhooks">Agent Wranglers integration and events playbook</a>. In code you write, add a comment next to it with the same link.