Skip to content

Verify webhook signatures

By Jon Henshaw2 min readUpdated

Every webhook delivery is signed so you can prove it came from Essential Support and was not tampered with in transit. Verify the X-ES-Signature header against your endpoint’s signing secret before you trust the payload.

The signature header

The header has the form t=<unix>,v1=<hex>, where t is the Unix timestamp of signing and v1 is HMAC-SHA256 of the string ${t}.${rawBody} keyed with your endpoint’s signing secret. Sign over the raw request body — the exact bytes you received, before any JSON parsing or re-serialization.

X-ES-Signature: t=1754754000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Verify it (Node)

Recompute the HMAC over t and the raw body, compare in constant time, and reject deliveries whose timestamp is more than 300 seconds old to blunt replay:

import crypto from 'node:crypto';

// Express: mount express.raw({ type: 'application/json' }) on the webhook route
// so req.body is the untouched Buffer (raw bytes), not a parsed object.
function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;   // stale (> 5 min)
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

Key points: use the endpoint’s own secret (each endpoint has its own), compare with timingSafeEqual rather than ===, and always hash the raw bytes — if your framework parses JSON first, re-stringifying it will change whitespace and the signatures will never match.

Rotating the secret

  1. On the endpoint’s row in Settings → Integrations → Webhooks, click Roll secret.
  2. A new secret appears once — copy it immediately. The old secret stops working the moment you roll.
  3. Deploy your receiver with the new secret. Roll during a quiet window if you cannot deploy instantly, because in-flight deliveries signed with the old secret will fail verification until the new one is live.

If verification fails

  • Wrong secret — you rolled the secret but your receiver still holds the old one.
  • Body was modified — a proxy or framework re-serialized the JSON. Hash the raw bytes instead.
  • Stale timestamp — the delivery is a late retry beyond your replay window. Widen the window slightly, or rely on X-ES-Delivery to dedupe.

Was this article helpful?

Thanks for your feedback.

Source: https://help.essential.support/integrations/verify-webhook-signatures/