Workspace

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 out
doc_viewa client opened one for the first time
doc_signa client signed one
doc_paidan invoice was marked paid
q_completea form was submitted
bookinga 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.

Verify against the RAW request body, before any JSON parsing. Re-serializing the object changes the bytes and every signature will fail.
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.
Deliveries are fire and forget: one attempt, no retries, and a slow or failing endpoint never holds up the signature, payment or booking that triggered it. Treat the webhook as a notification, not as a ledger.