Webhooks

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

HeaderValue
X-Transglot-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of the signed message
X-Transglot-TimestampUnix time in seconds when this attempt was signed, for example 1791820860
X-Transglot-EventThe event name, for example batch.completed
X-Transglot-DeliveryA 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:

Signed message
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.

verify.mjs
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.

server.mjs (Express)
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:

Recompute with openssl
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.

On this page