Agent Wranglers

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

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.