Webhook signatures
Aeses signs every webhook with HMAC-SHA256 so you can verify it came from us and the payload was not tampered with in transit. Always verify the signature before acting on a webhook — an unverified payload is untrusted input from the public internet.
The signature header
Each webhook includes an X-Webhook-Signature header:
X-Webhook-Signature: t=1731600125,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t— Unix timestamp (seconds) when the event was signed.v1— the HMAC-SHA256 signature.
Future signature schemes will be added as v2, v3, etc. Always read all versions present and verify the one your client supports.
Verification algorithm
- Read the raw request body as bytes — do not deserialize or re-serialize before computing the signature.
- Parse
tandv1from theX-Webhook-Signatureheader. - Build the signed payload string:
"{t}.{raw_body}"(timestamp, a literal., then the body). - Compute
HMAC-SHA256(secret, signed_payload)using your endpoint's webhook secret (found in Dashboard → Developers → Webhooks). - Compare your computed signature with
v1using a constant-time comparison to avoid timing attacks. - Reject the request if the signatures do not match.
- Reject the request if
|now - t| > 300seconds — this prevents replay attacks.
Example: Node.js
verify-webhook.js
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
export function verifyWebhook(rawBody, header, secret) {
if (!header) throw new Error('Missing signature header')
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
)
const timestamp = Number(parts.t)
const signature = parts.v1
if (!timestamp || !signature) throw new Error('Malformed signature header')
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
throw new Error('Timestamp outside tolerance window')
}
const signedPayload = `${timestamp}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex')
if (
expected.length !== signature.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
) {
throw new Error('Signature mismatch')
}
}Example: Python
verify_webhook.py
import hmac
import hashlib
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body: bytes, header: str, secret: str) -> None:
if not header:
raise ValueError("Missing signature header")
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
signature = parts["v1"]
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
raise ValueError("Timestamp outside tolerance window")
signed_payload = f"{timestamp}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("Signature mismatch")Common pitfalls
Use the raw body
Frameworks that automatically parse JSON (Express, Flask, Rails) re-serialize the payload, which changes whitespace and key order. Use raw-body middleware before any JSON parsing, or you will get signature mismatches.
- Whitespace matters. Never re-format the body before hashing.
- Tolerance window prevents replay. Reject events older than 5 minutes — an attacker who captures a valid event cannot replay it later.
- Constant-time comparison. Use
crypto.timingSafeEqual/hmac.compare_digestrather than==. - Rotate the secret. You can rotate the webhook secret in the dashboard. Both old and new secrets work during a 24-hour overlap to give you time to redeploy.