FactoraERP Developersfactoraerp.com →

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

Events

EventStatus
test.pingAvailable (from Send test). data is {"hello":"factora"}.
sales_invoice.createdReserved, not emitted yet
sales_invoice.paidReserved, not emitted yet
work_order.completedReserved, 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

  1. Split X-Factora-Signature on commas to get t (Unix seconds) and v1.
  2. Build the signed string: t + "." + rawBody, where rawBody is the exact request body.
  3. Compute HMAC-SHA256 of that string with your whsec_… secret (the whole string, prefix included) as the key, hex-encoded in lowercase.
  4. Compare with v1 in 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