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_abc123X-Tratto-Signature is not a bare hash. It has two fields:
| Field | Meaning |
|---|---|
t | Unix timestamp in milliseconds, taken when the delivery was signed |
v1 | Hex-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
- Parse
tand everyv1out of the header - Reject the request if
tis outside your tolerance window (5 minutes is a reasonable default) - Compute
HMAC-SHA256("{t}.{rawBody}", secret)using the raw body bytes - Compare against each
v1in 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}, 200PHP
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 timeout | 10 seconds |
| Retries | up to 5 |
| Backoff | 5s, 30s, 5min, 30min, 2h |
| Success | any 2xx response |
| Auto-disable | after 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
idfield.
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