Verifying a signature
Anyone can POST JSON at your URL. The signature is what proves a delivery came from Localoy — check it before you act on anything.
The scheme
signed_payload = "{timestamp}.{rawBody}"
v1 = HMAC_SHA256(your_signing_secret, signed_payload) // hex
header = "t={timestamp},v1={hex}"Verify against the RAW body
The signature covers the exact bytes we sent. If your framework parses the JSON and you then re-serialize it to verify, the result will differ by key order and whitespace and every signature will look wrong. Capture the raw body first — in Express that means express.raw(), not express.json().
A complete receiver (Node / Express)
const express = require("express");
const crypto = require("node:crypto");
const app = express();
const SECRET = process.env.LOCALOY_WEBHOOK_SECRET; // whsec_…
// ⚠️ express.raw(), not express.json(). The signature covers the exact bytes we
// sent; a re-serialized body will not match, however correct it looks.
app.post(
"/webhooks/localoy",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Localoy-Signature") ?? "";
const raw = req.body.toString("utf8");
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2)),
);
const timestamp = Number(parts.t);
// Reject a stale replay. The timestamp is inside the signed string, so it
// cannot be rewritten without invalidating the signature.
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send("stale");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(timestamp + "." + raw)
.digest();
const provided = Buffer.from(parts.v1 ?? "", "hex");
// Constant-time, and length-checked first — timingSafeEqual throws on a
// length mismatch.
if (
provided.length !== expected.length ||
!crypto.timingSafeEqual(provided, expected)
) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(raw);
// ANSWER FIRST, WORK AFTER. You have 10 seconds, and anything slower is
// recorded as a timeout and retried.
res.status(200).send("ok");
// Delivery is at-least-once: dedupe on the event id before acting.
if (alreadyProcessed(event.id)) return;
markProcessed(event.id);
handle(event);
},
);
app.listen(8080);- Compare in constant time. A byte-by-byte comparison that returns early leaks how much of a forged signature was right.
- Reject a stale timestamp — five minutes is a sensible window. The timestamp is inside the signed string, so an attacker cannot rewrite it to refresh a captured request.
- Deduplicate on X-Localoy-Event-Id. Delivery is at-least-once: a retry after your server accepted but failed to reply will arrive again, carrying the same id.
- Rotating the secret takes effect immediately, with no grace period. Deploy the new secret at the moment you rotate — a receiver still verifying the old one rejects everything.