Webhook Signature Verification

Verify the X-Tratto-Signature header, which signs a timestamp together with the body.

Every webhook Tratto sends is signed with HMAC-SHA256. Always verify the signature. An unverified endpoint accepts anything anyone posts to it.

The signature header

Each delivery carries two headers:

X-Tratto-Signature: t=1784977714054,v1=0b37dd97144dcdf176572a1b...
X-Tratto-Webhook-Id: wh_abc123

X-Tratto-Signature is not a bare hash. It has two fields:

FieldMeaning
tUnix timestamp in milliseconds, taken when the delivery was signed
v1Hex-encoded HMAC-SHA256, version 1 of the scheme. May appear more than once

What is signed

The signed string is the timestamp and the raw body joined by a full stop:

{timestamp}.{rawBody}

Signing the body alone will never match. The timestamp is part of the signed material, which is what makes it trustworthy for replay protection.

Verification algorithm

  1. Parse t and every v1 out of the header
  2. Reject the request if t is outside your tolerance window (5 minutes is a reasonable default)
  3. Compute HMAC-SHA256("{t}.{rawBody}", secret) using the raw body bytes
  4. Compare against each v1 in constant time, accepting if any matches

Your secret is shown once when the webhook is created, in the form whsec_ followed by 48 hex characters.

Node.js

const crypto = require('crypto');

// A header may carry more than one `v1` during a secret rotation, so collect
// them all rather than keeping the last one.
function parseSignature(header) {
  let timestamp = NaN;
  const signatures = [];

  for (const part of String(header ?? '').split(',')) {
    const [key, value] = part.split('=').map((s) => s.trim());
    if (key === 't') timestamp = Number(value);
    else if (key === 'v1' && value) signatures.push(value);
  }

  return { timestamp, signatures };
}

function verifyWebhook(rawBody, header, secret, toleranceMs = 5 * 60_000) {
  const { timestamp, signatures } = parseSignature(header);
  if (!Number.isFinite(timestamp)) return false;

  // Reject replays before doing any crypto.
  if (Math.abs(Date.now() - timestamp) > toleranceMs) return false;

  const expected = Buffer.from(
    crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex'),
    'hex',
  );

  // Accept the delivery if any signature matches.
  return signatures.some((candidate) => {
    const actual = Buffer.from(candidate, 'hex');
    // timingSafeEqual takes buffers, not strings, and throws on unequal
    // lengths, so compare lengths first.
    if (actual.length !== expected.length) return false;
    return crypto.timingSafeEqual(expected, actual);
  });
}

Wire it up with the raw body preserved. express.json() discards it by default:

const express = require('express');
const app = express();

app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf;
    },
  }),
);

app.post('/webhooks/tratto', (req, res) => {
  const ok = verifyWebhook(
    req.rawBody,
    req.header('x-tratto-signature'),
    process.env.TRATTO_WEBHOOK_SECRET,
  );

  if (!ok) return res.status(401).json({ error: 'Invalid signature' });

  // Acknowledge fast, then process: Tratto retries if you time out.
  res.json({ ok: true });

  const event = JSON.parse(req.rawBody);
  console.log(event.type, event.emailId);
});

Edge runtimes (Vercel Edge, Cloudflare Workers)

Edge runtimes have no node:crypto. WebCrypto is available everywhere, including Node 18+, so this version is portable:

function parseSignature(header: string | null) {
  let timestamp = NaN;
  const signatures: string[] = [];

  for (const part of (header ?? '').split(',')) {
    const [key, value] = part.split('=').map((s) => s.trim());
    if (key === 't') timestamp = Number(value);
    else if (key === 'v1' && value) signatures.push(value);
  }

  return { timestamp, signatures };
}

async function verifyWebhook(
  rawBody: string,
  header: string | null,
  secret: string,
  toleranceMs = 5 * 60_000,
): Promise<boolean> {
  const { timestamp, signatures } = parseSignature(header);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() - timestamp) > toleranceMs) return false;

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  const mac = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`${timestamp}.${rawBody}`),
  );

  const expected = [...new Uint8Array(mac)]
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');

  // Accept the delivery if any signature matches, comparing in constant time.
  return signatures.some((candidate) => {
    if (candidate.length !== expected.length) return false;
    let diff = 0;
    for (let i = 0; i < expected.length; i++) {
      diff |= expected.charCodeAt(i) ^ candidate.charCodeAt(i);
    }
    return diff === 0;
  });
}

export async function POST(request: Request) {
  // Read the body once, as text, before parsing it.
  const rawBody = await request.text();

  const ok = await verifyWebhook(
    rawBody,
    request.headers.get('x-tratto-signature'),
    process.env.TRATTO_WEBHOOK_SECRET!,
  );

  if (!ok) return new Response('Invalid signature', { status: 401 });

  const event = JSON.parse(rawBody);
  return Response.json({ ok: true });
}

Python

import hmac
import hashlib
import time

def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
    timestamp, signatures = None, []
    for part in (header or '').split(','):
        key, _, value = part.partition('=')
        key, value = key.strip(), value.strip()
        if key == 't':
            timestamp = value
        elif key == 'v1' and value:
            signatures.append(value)

    try:
        timestamp = int(timestamp)
    except (TypeError, ValueError):
        return False

    # `t` is in milliseconds.
    if abs(time.time() * 1000 - timestamp) > tolerance_s * 1000:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return any(hmac.compare_digest(expected, s) for s in signatures)


@app.route('/webhooks/tratto', methods=['POST'])
def tratto_webhook():
    raw_body = request.get_data()  # bytes, before any parsing
    header = request.headers.get('X-Tratto-Signature', '')

    if not verify_webhook(raw_body, header, os.environ['TRATTO_WEBHOOK_SECRET']):
        return {'error': 'Invalid signature'}, 401

    event = json.loads(raw_body)
    return {'ok': True}, 200

PHP

function verify_webhook(string $raw_body, string $header, string $secret, int $tolerance_s = 300): bool {
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $piece) {
        $kv = explode('=', $piece, 2);
        if (count($kv) !== 2) continue;
        [$key, $value] = [trim($kv[0]), trim($kv[1])];
        if ($key === 't') $timestamp = (int) $value;
        elseif ($key === 'v1' && $value !== '') $signatures[] = $value;
    }

    if ($timestamp === null || !$signatures) return false;
    if (abs(round(microtime(true) * 1000) - $timestamp) > $tolerance_s * 1000) return false;

    $expected = hash_hmac('sha256', $timestamp . '.' . $raw_body, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) return true;
    }
    return false;
}

$raw_body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_TRATTO_SIGNATURE'] ?? '';

if (!verify_webhook($raw_body, $header, getenv('TRATTO_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}

Common mistakes

❌ Signing the body alone

// WRONG: the timestamp is part of the signed string
const sig = hmac('sha256', rawBody, secret);
// RIGHT
const sig = hmac('sha256', `${timestamp}.${rawBody}`, secret);

❌ Treating the header as a bare hash

// WRONG: the header is `t=…,v1=…`, not a hex digest
crypto.timingSafeEqual(computed, req.header('x-tratto-signature'));

Parse out v1 first.

❌ Signing parsed JSON

// WRONG: re-serialising changes whitespace and key order
const sig = hmac('sha256', JSON.stringify(JSON.parse(body)), secret);

Use the raw bytes exactly as received.

❌ Comparing with ===

// WRONG: leaks information through timing
if (computed === signature) { /* … */ }

Use crypto.timingSafeEqual on equal-length buffers, or the constant-time loop shown in the edge example.

Replay protection

The t field is covered by the signature, so it cannot be altered without invalidating v1. Rejecting timestamps outside a tolerance window is therefore sufficient, and it is already built into every example above.

Do not use occurredAt from the payload for this. It is when the event happened, not when the delivery was signed, and the two differ on retries.

Delivery behaviour

Knowing how Tratto retries shapes how your handler should behave:

Request timeout10 seconds
Retriesup to 5
Backoff5s, 30s, 5min, 30min, 2h
Successany 2xx response
Auto-disableafter 10 consecutive failures

Two consequences worth designing for:

  • Acknowledge before you process. Anything slower than 10 seconds counts as a failure and will be retried.
  • Handle duplicates. Retries mean the same event can arrive more than once. Deduplicate on the event id field.

Secret rotation

Rotation takes effect immediately. There is no grace period and the previous secret stops working the moment the new one is issued. Any delivery signed afterwards will fail verification until your endpoint has the new value.

curl -X POST https://api.tratto.email/v1/webhooks/wh_abc123/rotate-secret \
  -H "Authorization: Bearer tratto_live_..."

The response contains the new secret, shown once.

To rotate without dropping events, deploy an endpoint that accepts either secret first, then rotate, then remove the old one:

const secrets = [
  process.env.TRATTO_WEBHOOK_SECRET,
  process.env.TRATTO_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

const ok = secrets.some((secret) => verifyWebhook(req.rawBody, header, secret));

Rotation also resets the failure counter and re-enables a webhook that had been disabled.


See Webhooks for registration and event types, and Deployment for reading the raw body on each platform.


Edit this page on GitHub

Last updated on