Verifying deliveries
Check the signature before you trust a request. Node, Python, PHP and C#.
Anyone can POST JSON at your endpoint. The signature is what tells you a delivery came from Signello and has not been altered or replayed.
The header
X-Signello-Signature: t=1788432862,v1=5f2c9ab4…tis the Unix timestamp, in seconds, of when we signed the request.v1is a HMAC-SHA256, hex-encoded, of{t}.{raw body}, keyed with the endpoint's signing secret.
The timestamp is part of the signed material, not just a hint. Signing the body alone would let anyone who captured one delivery replay it forever.
The four steps
Read the raw body
Take the bytes exactly as they arrived. Parsing to an object and
re-serialising changes key order and whitespace, and the signature will not
match. In Express that means express.raw(), not express.json().
Rebuild the signed string
Concatenate the timestamp, a literal dot, and the raw body: ${t}.${body}.
Compare in constant time
Compute HMAC-SHA256 with your secret and compare against v1 using a
timing-safe comparison. A plain == leaks how much of the signature you
matched, one byte at a time.
Reject anything old
If t is more than five minutes from now, refuse the request even when the
signature checks out. Without this the signature is valid forever and a
captured delivery can be replayed at will.
Code
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifySignelloSignature(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((piece) => piece.split("=", 2)));
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import express from "express";
const app = express();
app.post("/webhooks/signello", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifySignelloSignature(
req.get("X-Signello-Signature") ?? "",
req.body.toString("utf8"),
process.env.SIGNELLO_WEBHOOK_SECRET
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Answer first, do the work afterwards.
res.sendStatus(200);
void handle(event, req.get("X-Signello-Delivery-Id"));
});Testing it
Hit Test on the endpoint in Settings → Integrations → API & Webhook.
The ping it sends is signed exactly like a real delivery, so if your
verification passes on a ping it will pass on everything else.
If it never matches
The most common cause by far. express.json(), body-parser, and most frameworks' default JSON
middleware consume the stream and hand you an object. Re-serialising it produces different bytes.
Reach for the raw-body option on the webhook route specifically, and leave the rest of your app
alone.
The signed string is {t}.{body}, with a literal dot between them, not the body on its own.
Each endpoint has its own secret, and it is only ever shown in the response that created it. If two endpoints point at the same handler, pick the secret by matching the endpoint id, or give each one its own route.
Some gateways re-encode JSON or strip whitespace in transit. Verify at the edge where the bytes first land, or turn the rewriting off.