Webhooks
Push workspace events to your own systems as they happen. Creating a webhook, choosing events, verifying signatures, retries, and the deliveries log. Business plan and above.
A webhook is a URL that Fermium calls with a JSON body whenever something happens in your workspace. Every event in the audit log can be delivered, so the same names apply: doc.publish, member.join, policy.updated and so on.
Webhooks are a Business feature and are managed by workspace owners and admins.
Create one
POST /api/webhooks with {"url": "https://example.com/hooks/fermium", "events": ["doc.publish", "doc.update"]}.
urlmust behttporhttpsand must be publicly reachable. Hostnames that resolve to a private, loopback, link-local or otherwise non-public address are refused at creation and on every later change of the URL; Fermium does not call into your internal network.eventsis a list of action names, or prefixes such asdoc.*for a whole family. Omit it, or pass["*"], to receive everything. At most 50 names.- The response includes the webhook's secret, a string starting with
whsec_. It is shown exactly once. Store it; later responses only reportsecret_set: true. If you lose it, delete the webhook and create a new one.
PATCH /api/webhooks/{id} changes url, events or active (false pauses delivery without deleting anything). DELETE /api/webhooks/{id} removes the webhook and its delivery log. GET /api/webhooks lists them. Each of these is itself an audit event.
What you receive
POST /hooks/fermium HTTP/1.1
Content-Type: application/json
User-Agent: Fermium-Webhooks/1
X-Fermium-Event: doc.publish
X-Fermium-Delivery: 41
X-Fermium-Signature: t=1757894400,v1=5f1c...9a
{"action":"doc.publish","actor":"ada@acme.com","created_at":"2026-09-15T14:02:11.482910+00:00","id":9127,"meta":{"folder":"Customers/Acme","title":"Q3 proposal","visibility":"restricted"},"target_id":"01J7ZK3N9QW0X4B2C6D8E0F1G2","target_type":"doc","workspace_id":7}
The body is the audit event: id, action, target_type, target_id, actor, workspace_id, created_at and meta. It is compact JSON with sorted keys. X-Fermium-Delivery is the id of this delivery attempt series; id inside the body identifies the event, and is what you should use to deduplicate, since a retry carries the same event.
Answer with any 2xx status within 10 seconds. Anything else, including a redirect (redirects are not followed), a timeout or a connection error, counts as a failure.
Verify the signature
X-Fermium-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256, keyed with the webhook secret, over the string <t>.<raw body>. Compute the same value over the exact bytes you received and compare in constant time. Reject old timestamps to defeat replay; five minutes is a reasonable window.
Node:
import crypto from "node:crypto";
export function verifyFermium(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && crypto.timingSafeEqual(given, want);
}
// Express: app.post("/hooks/fermium", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyFermium(process.env.FERMIUM_SECRET, req.get("X-Fermium-Signature"), req.body.toString("utf8"))) return res.sendStatus(400);
// const event = JSON.parse(req.body); ...; res.sendStatus(204);
// });
Python:
import hmac, hashlib, time
def verify_fermium(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Use the raw request body. Re-serializing the JSON changes the bytes and the signature will not match.
Retries
The first attempt is made as soon as the event is committed. After a failure the delivery is retried after 1 minute, then 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts in all, spread over roughly 15 hours. After the last failure the delivery is marked failed and not retried. Deliveries for different events are independent and may arrive out of order; use created_at and id if order matters to you.
Deliveries and testing
GET /api/webhooks/{id}/deliveries?limit=50 lists recent deliveries newest first (up to 200): status (pending, ok, failed), attempts, last_status_code, last_error, next_attempt_at, created_at, delivered_at, and the event's action.
POST /api/webhooks/{id}/test records a webhook.test event and delivers it to this webhook even if its event filter would normally exclude it. The response includes the delivery_id to look up.
In the app
Settings, then Webhooks does not manage webhooks yet; use the API.
The full delivery contract for developers, including the error and retry semantics, is on Webhooks in the developer reference.