Verifying signatures
Prove a webhook came from Localoy by checking its HMAC-SHA256 signature and timestamp.
Every webhook carries an X-Localoy-Signature header. Check it before you trust anything in the body:
anyone who knows your endpoint's URL can send it a request.
X-Localoy-Signature: t=1790410530,v1=5b1f0c9e4a…How the signature is made#
signed_payload = "{t}.{raw_body}"
v1 = hex( HMAC-SHA256( key = endpoint secret, message = signed_payload ) )tis the Unix time in seconds when this attempt was sent. A retry has a newtand a newv1.- The key is your endpoint's secret exactly as shown in the portal, including the
whsec_prefix. raw_bodyis the request body byte-for-byte. Parsing and re-serialising the JSON changes it and breaks the signature.
Verifying#
- Split the header on
,and readtandv1. - Reject the request if
tis more than 5 minutes from your clock. - Compute the HMAC of
{t}.{raw_body}with your secret. - Compare it with
v1in constant time. Reject on mismatch. - Only then parse the body.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const TOLERANCE_SECONDS = 300;
app.post(
"/webhooks/localoy",
express.raw({ type: "application/json" }), // keep the raw bytes
(req, res) => {
const header = req.get("X-Localoy-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
const expected = createHmac("sha256", process.env.LOCALOY_WEBHOOK_SECRET)
.update(`${t}.`)
.update(req.body) // a Buffer: the raw body
.digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // acknowledge first…
queue.push(event); // …then process, de-duplicating on event.id
},
);Frameworks that parse JSON for you
Many frameworks parse the body before your handler runs. Configure the webhook route to give you the raw
bytes — express.raw() in Express, request.get_data() in Flask, php://input in PHP — and verify
those.
Preventing replays#
The timestamp check stops an attacker from re-sending an old, captured request. Within the five-minute
window, de-duplicate on X-Localoy-Event-Id: Localoy may deliver the same event more than once, and a
repeat must not be processed twice.
Rotating the secret#
Rotate an endpoint's secret from the portal. The new secret applies immediately and there is no period in which both are accepted, so events sent between the rotation and your deploy will fail verification. Because failed deliveries are retried for about five minutes, deploy the new secret promptly — or replay any failed deliveries from the portal afterwards.