Webhooks
One JSON POST per event, signed, so the other end can prove it came from us.
Settings → Integrations → Webhook takes any URL: a Zapier or Make catch hook, or your own endpoint. Six things trigger a delivery, the same six that post to Slack.
doc_senta quote, invoice, contract or letter went outdoc_viewa client opened one for the first timedoc_signa client signed onedoc_paidan invoice was marked paidq_completea form was submittedbookinga call was booked, moved or cancelled (a cancellation carries "status": "cancelled")What arrives
POST https://your-endpoint.example.com/systemos
Content-Type: application/json
X-SystemOS-Event: doc_sign
X-SystemOS-Signature: t=1789231272,v1=9f86d0818...
{
"event": "doc_sign",
"label": "Studio Kern signed quote BL-Q-2026-003",
"organizationId": "hT4kP2xQ1a",
"ts": "2026-09-12T09:41:12.004Z",
"docId": "kG0LOHCLHs"
}event, label, organizationId and ts are on every delivery. Document events add docId, bookings add bookingId. The label is the same sentence you see in the activity feed, so it is safe to post straight into a chat.
organizationId identifies the workspace the event came from, which the receiving end usually wants for routing. It is printed under the webhook URL in Settings → Integrations, with a copy button. It is not a secret.
Signing
Press “Create signing secret” under the webhook URL and hand the value to whoever receives the deliveries. From then on every POST carries X-SystemOS-Signature: t=<unix seconds>,v1=<hex>, where v1 is an HMAC-SHA256 of "<t>.<raw body>" keyed with the secret.
import { createHmac, timingSafeEqual } from "crypto";
// rawBody must be the exact bytes we posted, before any JSON parsing
function verify(rawBody, header, secret) {
const t = (header.match(/t=(\d+)/) || [])[1];
const v1 = (header.match(/v1=([a-f0-9]{64})/) || [])[1];
if (!t || !v1) return false;
// older than five minutes: treat it as a replay
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(t + "." + rawBody).digest();
const got = Buffer.from(v1, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}import hashlib, hmac, re, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
t = re.search(r"t=(\d+)", header)
v1 = re.search(r"v1=([a-f0-9]{64})", header)
if not t or not v1:
return False
if abs(time.time() - int(t.group(1))) > 300: # replay window
return False
signed = t.group(1).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.group(1))The timestamp is part of what is signed, so a delivery someone captured cannot be replayed later with a fresh header. Rejecting anything older than five minutes is what turns that into protection; both snippets above do it.
Rotating and turning it off
- Rotate replaces the secret immediately. Anything still verifying with the old one rejects every delivery until you send over the new value, so rotate when you can update both ends together.
- Turn off signing keeps the deliveries coming without the header. It exists for a receiver built before signing did.
- A workspace that set its webhook up before signing existed has no secret yet: deliveries are unsigned until you create one.