Browse all docs

Webhooks and API

Verify webhook signatures

Verify the X-SiteTrellis-Signature header with HMAC-SHA256 in PHP, Node.js and Python, with timestamp tolerance and replay protection.

Last updated:

Every webhook request carries a signature so you can prove it came from SiteTrellis and was not altered. Verifying it is the most important part of a webhook receiver. This page explains the algorithm and gives complete examples in PHP, Node.js and Python.

The header#

X-SiteTrellis-Signature: t=1791381802,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix timestamp (seconds, UTC) at which SiteTrellis signed the request. Each retry is signed again with a fresh timestamp.
  • v1 is the lowercase hexadecimal HMAC-SHA256 of the string t + "." + body, using your endpoint's signing secret (the value starting whsec_) as the key. body is the raw request body exactly as received.

The algorithm#

  1. Read the raw request body as bytes or an unparsed string. Do not parse and re-encode the JSON: re-serialising changes whitespace and key order, and the signature will not match.
  2. Parse the header: split on commas, then split each part on the first =, to get t and v1. Reject the request if either is missing.
  3. Check the timestamp. If abs(now - t) is more than 300 seconds (5 minutes), reject the request. This limits replay of a captured request. Keep your server clock synchronised (NTP).
  4. Compute expected = hex(HMAC_SHA256(secret, t + "." + rawBody)).
  5. Compare expected with v1 using a constant-time comparison function. Do not use == or ===, which can leak information through timing.
  6. If everything matches, parse the JSON and process the event. Otherwise respond 400 and do nothing else.

PHP#

<?php
declare(strict_types=1);

function verify_sitetrellis_signature(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
    $t = null;
    $sig = null;
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($k === 't') {
            $t = $v;
        } elseif ($k === 'v1') {
            $sig = $v;
        }
    }
    if ($t === null || $sig === null || !ctype_digit($t)) {
        return false;
    }
    if (abs(time() - (int)$t) > $tolerance) {
        return false;                                   // too old (or from the future)
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $sig);                // constant-time compare
}

$raw = file_get_contents('php://input');                // RAW body
$header = $_SERVER['HTTP_X_SITETRELLIS_SIGNATURE'] ?? '';
if (!verify_sitetrellis_signature($header, $raw, getenv('ST_WEBHOOK_SECRET') ?: '')) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// ... skip if $event['id'] was already processed, then handle $event['event'] ...
http_response_code(200);
echo 'ok';

Node.js#

const crypto = require('crypto');

function verifySiteTrellisSignature(header, rawBody, secret, toleranceSeconds = 300) {
  if (!header) return false;
  const parts = {};
  for (const piece of header.split(',')) {
    const i = piece.indexOf('=');
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  if (!/^\d+$/.test(parts.t || '') || !/^[a-f0-9]{64}$/.test(parts.v1 || '')) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (age > toleranceSeconds) return false;

  // rawBody must be the Buffer or string exactly as received
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: use express.raw() on this route so req.body is the untouched Buffer
// app.post('/hooks/sitetrellis', express.raw({ type: 'application/json' }), (req, res) => {
//   if (!verifySiteTrellisSignature(req.get('X-SiteTrellis-Signature'), req.body, process.env.ST_WEBHOOK_SECRET)) {
//     return res.status(400).send('invalid signature');
//   }
//   const event = JSON.parse(req.body.toString('utf8'));
//   res.sendStatus(200);
// });

Python#

import hashlib
import hmac
import time


def verify_sitetrellis_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    parts = {}
    for piece in (header or "").split(","):
        key, sep, value = piece.strip().partition("=")
        if sep:
            parts[key] = value
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not sig:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        t.encode("ascii") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)


# Flask example
# from flask import Flask, request, abort
# app = Flask(__name__)
#
# @app.post("/hooks/sitetrellis")
# def hook():
#     raw = request.get_data()            # raw bytes, before any JSON parsing
#     if not verify_sitetrellis_signature(request.headers.get("X-SiteTrellis-Signature", ""), raw, SECRET):
#         abort(400)
#     event = request.get_json(force=True)
#     return "ok", 200

Replay protection#

The timestamp check stops an attacker from replaying a captured request later than five minutes after it was signed. To also stop replays inside that window, and to cope with SiteTrellis' own at-least-once retries, record the event id (also sent in the X-SiteTrellis-Delivery header) for at least ten minutes, ideally longer, and ignore an id you have already processed. A database unique constraint on the event id makes this easy to do safely under concurrency.

Testing your verification#

  1. Use Send test event on the endpoint in the dashboard. A correct implementation accepts it.
  2. Change one character of the secret in your configuration and send another test. Your receiver must now reject it with 400.
  3. Redeliver an old delivery from the delivery log after waiting for more than five minutes. Redeliveries are re-signed with the current time, so they pass. To test the timestamp check, call your verifier directly with a stale t value in a unit test.

Common mistakes#

MistakeSymptomFix
Verifying a parsed-and-re-encoded bodySignature never matches.Use the raw bytes. In Express use express.raw(); in frameworks that parse for you, enable raw body capture.
A proxy or framework changes the body (trimming, charset conversion)Intermittent mismatches.Capture the body as early as possible and as bytes.
Using the wrong secretAlways fails.Each endpoint has its own secret. If you lost it, create a new endpoint and delete the old one.
Server clock off by minutesAll requests rejected as too old.Enable time synchronisation.
Ordinary string comparisonWorks, but timing leak.Use hash_equals, crypto.timingSafeEqual or hmac.compare_digest.

More about events and delivery: Webhooks.