Webhooks

Verify signatures

Every delivery is signed with your endpoint's secret. Check the signature before you trust a delivery, so nobody else can send you fake events.

HTTP header
Flux-Signature: t=1793528400,v1=5f0c…64 hex characters…
  • t is when Flux signed the delivery, in Unix seconds. Each retry is signed again with a new t.
  • v1 is the hex-encoded HMAC-SHA256 of t + "." + body, keyed with your endpoint's secret (whsec_…).
  • The header can carry more than one v1. Accept the delivery if any of them matches.

How to verify#

  1. Split Flux-Signature on , and each part on the first =. Take t and every v1.
  2. Compute the hex HMAC-SHA256 of t + "." + rawBody with your secret. Use the raw request body, byte for byte. Parsing and re-serializing the JSON changes it and breaks the signature.
  3. Compare your result with each v1 in constant time.
  4. Reject the delivery if t is more than 300 seconds from now. This stops an old delivery being replayed.

Return 400 for a delivery that fails, and don't process it.

Code#

// Node.js. With Express, mount the route with express.raw({ type: "application/json" })
// so req.body is the raw Buffer, not parsed JSON.
import crypto from "node:crypto";

export function verifyFluxSignature(secret, header, rawBody, toleranceSeconds = 300) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
  );
  return signatures.some((s) => {
    const given = Buffer.from(s);
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

// app.post("/flux", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyFluxSignature(process.env.FLUX_WEBHOOK_SECRET, req.get("Flux-Signature"), req.body)) {
//     return res.sendStatus(400);
//   }
//   const event = JSON.parse(req.body);
//   ...
// });

Getting the raw body#

FrameworkRaw body
Express (Node.js)express.raw({ type: "application/json" }) on the route, then req.body
Next.js route handlerawait request.text()
Flaskrequest.get_data()
Djangorequest.body
Laravel$request->getContent()
ASP.NET CoreRead Request.Body with a StreamReader before model binding
Go net/httpio.ReadAll(r.Body)

Worked example#

Check your code against this delivery. The secret is published for this example only. Because t is in the past, turn the 300-second check off (or pass a large tolerance) while you test.

InputValue
Secretwhsec_DocsExampleOnly0NotARealSecret12
Raw body{"id":"evt_01JC8Z5T6Q1W3M4N5P6R7S8T9V","type":"webhook.test","created":"2026-11-01T10:20:00.4121933Z","data":{"object":{"endpointId":3,"message":"Test event from Flux ERP"}}}
Flux-Signaturet=1793528400,v1=7d6b8ca702474b03c774aa73d62e80609c1db53535dbeb2d19bab0eeede8af5f

The signed string is 1793528400. followed by the raw body. Your code should produce the same v1.

Signature checker#

Check a delivery, or sign a sample body to send to your own endpoint. It runs in your browser: nothing you enter leaves this page. Use a staging secret, not a production one.

Starts with whsec_.
Exactly as received. Spaces and line breaks matter.