Verify signatures
Check that a webhook delivery came from transglot, unaltered and recent, before you act on it.
Anyone who learns your endpoint's URL can send it a request. The signature is what tells a genuine delivery from everything else, so verify it on every request and reject the ones that fail.
The headers
| Header | Value |
|---|---|
X-Transglot-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the signed message |
X-Transglot-Timestamp | Unix time in seconds when this attempt was signed, for example 1791820860 |
X-Transglot-Event | The event name, for example batch.completed |
X-Transglot-Delivery | A UUID for the delivery |
How the signature is computed
Build the signed message
Join the X-Transglot-Timestamp value, a full stop, and the raw request body:
1791820860.{"batch":{"id":4821, ... },"event":"batch.completed", ... }Compute the HMAC
Compute HMAC-SHA256 of that message, keyed with the endpoint's whole signing secret, including the whsec_ prefix. Hex-encode the result in lowercase and prefix it with sha256=.
Compare
Compare your value with X-Transglot-Signature using a constant-time comparison. If they differ, reject the request.
Use the raw body
Sign the exact bytes you received, before any JSON parsing. Parsing and re-serialising changes whitespace, key order and escaping, and the signature will not match. The body is UTF-8 JSON with slashes and non-ASCII characters unescaped.
Reject old timestamps
Each attempt is signed when it is sent, so X-Transglot-Timestamp is always recent for a genuine delivery, including a retry or a redelivery. Reject a timestamp more than five minutes from your clock: that stops someone who captured a delivery from replaying it later. Because the timestamp is part of the signed message, it cannot be changed without breaking the signature.
Verification code
Each function returns true only for a genuine, recent delivery. Store the secret in an environment variable such as TRANSGLOT_WEBHOOK_SECRET.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyTransglotSignature(rawBody, timestamp, signature, secret) {
if (!timestamp || !signature) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Wiring it into a receiver
The receiver has to hand the function the raw body. Then answer with a 2xx quickly and do slow work afterwards: a delivery that takes longer than 10 seconds to answer counts as failed and is retried.
import express from 'express';
import { verifyTransglotSignature } from './verify.mjs';
const app = express();
// express.raw keeps req.body as the unparsed Buffer the signature covers.
app.post('/hooks/transglot', express.raw({ type: 'application/json' }), (req, res) => {
const genuine = verifyTransglotSignature(
req.body,
req.get('X-Transglot-Timestamp'),
req.get('X-Transglot-Signature'),
process.env.TRANSGLOT_WEBHOOK_SECRET,
);
if (!genuine) return res.status(400).send('invalid signature');
const payload = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204);
// Handle payload.event after answering.
});
app.listen(3000);Check a signature by hand
To debug a mismatch, recompute the signature from a delivery you captured. Save the body exactly as received to body.json, then:
TIMESTAMP=1791820860 # the X-Transglot-Timestamp header
printf '%s.%s' "$TIMESTAMP" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$TRANSGLOT_WEBHOOK_SECRET" \
| sed 's/^.*= /sha256=/'The output should equal the X-Transglot-Signature header. If it does not, the usual causes are a body that was parsed and re-serialised before verifying, a secret copied without its whsec_ prefix, or the secret of a different endpoint.