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

  1. Read the raw request body as bytes — do not deserialize or re-serialize before computing the signature.
  2. Parse t and v1 from the X-Webhook-Signature header.
  3. Build the signed payload string: "{t}.{raw_body}" (timestamp, a literal ., then the body).
  4. Compute HMAC-SHA256(secret, signed_payload) using your endpoint's webhook secret (found in Dashboard → Developers → Webhooks).
  5. Compare your computed signature with v1 using a constant-time comparison to avoid timing attacks.
  6. Reject the request if the signatures do not match.
  7. Reject the request if |now - t| > 300 seconds — 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_digest rather 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.