Webhooks
Dry-run mode. Webhook delivery is not switched on yet. Each event is signed and recorded in the delivery log in System Settings → API access with status
DRY RUN, but nothing is sent to your URL. You can build and test your receiver now with the examples below.Endpoints
- Admins add a webhook URL in System Settings → API access and choose its events. The URL must be a public
https://address. - A signing secret starting with
whsec_is shown once. Afterwards only its first 8 characters are shown. - New endpoints start switched off; admins switch each one on. Up to 10 active endpoints per factory.
- Send test emits a
test.pingevent.
Events
| Event | Status |
|---|---|
test.ping | Available (from Send test). data is {"hello":"factora"}. |
sales_invoice.created | Reserved, not emitted yet |
sales_invoice.paid | Reserved, not emitted yet |
work_order.completed | Reserved, not emitted yet |
Request
When delivery is live, FactoraERP sends a POST with a 10-second timeout and these headers:
Content-Type: application/json
X-Factora-Event: test.ping
X-Factora-Signature: t=1793000000,v1=5f2b...e9
{"event":"test.ping","createdAt":"2026-10-31T09:00:00.000Z","data":{"hello":"factora"}}
Respond with any 2xx status to mark the delivery sent. Anything else, or a timeout, marks it failed. There are no automatic retries at the moment.
Verifying the signature
- Split
X-Factora-Signatureon commas to gett(Unix seconds) andv1. - Build the signed string:
t + "." + rawBody, whererawBodyis the exact request body. - Compute HMAC-SHA256 of that string with your
whsec_…secret (the whole string, prefix included) as the key, hex-encoded in lowercase. - Compare with
v1in constant time, and reject old timestamps (we suggest 5 minutes).
Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
// Use the raw body: the signature covers the exact bytes sent.
app.post("/factora/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Factora-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = parts.t, v1 = parts.v1 || "";
const body = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", process.env.FACTORA_WEBHOOK_SECRET) // whsec_...
.update(`${t}.${body}`)
.digest("hex");
const ok = v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // 5-minute tolerance
if (!ok || !fresh) return res.status(400).send("bad signature");
const event = JSON.parse(body);
console.log(event.event, event.data);
res.sendStatus(200);
});
app.listen(3000);
Python
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["FACTORA_WEBHOOK_SECRET"].encode() # whsec_...
@app.post("/factora/webhook")
def webhook():
header = request.headers.get("X-Factora-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
body = request.get_data(as_text=True) # raw body, unmodified
expected = hmac.new(SECRET, f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
abort(400)
if not t.isdigit() or abs(time.time() - int(t)) > 300:
abort(400)
event = request.get_json()
print(event["event"], event["data"])
return "", 200