Skip to content

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.