Cairn Webhooks — Outbound Delivery & Signature Verification (ATH-251)

Cairn POSTs signed JSON to HTTPS endpoints you register. This document is the contract: the envelope, the signature, the replay window, the retry schedule, and what redelivery does. Implementation lives in packages/core/src/Webhooks/; any change to the envelope, the header format, or the replay window requires updating this file, because a signature scheme nobody can independently implement is worthless.

Doc 06 §5.


1. Registering an endpoint

POST /api/v1/workspaces/{workspaceId}/webhook-endpoints

{
  "url": "https://hooks.example.com/cairn",
  "description": "Production receiver",
  "events": ["ticket.opened", "conversation.message_received"]
}

The 201 response is the only time the signing secret is ever returned. It is stored encrypted at rest and there is no reveal endpoint — if you lose it, rotate it (PATCH a new endpoint in, delete the old one). Store it as you would any API credential.

{ "data": { "id": "01K…", "secret": "whsec_9f86d081…", "…": "…" } }

GET /api/v1/workspaces/{workspaceId}/webhook-event-types lists every event you may subscribe to. Subscribing to anything else is a 422 — you will never be silently subscribed to something that cannot arrive.

Your URL must be a public http(s) address. Loopback, link-local, private, cloud-metadata and internal-suffix hosts are rejected with a 422, and the check is repeated with DNS resolution on every single delivery attempt — a hostname that resolves into private space later is refused then, and the refusal appears in your delivery log as a failed attempt with a reason.

1.1 Registering from an integration (ATH-253)

The endpoints above are session-authenticated: they back the portal's Settings → API & Webhooks screen and expect a logged-in admin.

If you are building an integration that subscribes on a customer's behalf — an iPaaS connector, or anything using a REST-hook pattern where subscribing happens when a user switches an automation on — use the public API instead. It takes an API key or an OAuth access token, and takes no workspace in the path, because the credential names the tenant:

GET /v1/webhook-event-types the subscribable catalog · ability webhooks:read
GET /v1/webhooks list your endpoints, for reconciliation · webhooks:read
POST /v1/webhooks subscribe; returns secret once · webhooks:write
DELETE /v1/webhooks/{id} unsubscribe · webhooks:write

Request and response bodies are the same shape as above, minus fields that only make sense inside the portal. There is deliberately no PATCH on this surface: an integration subscribes and unsubscribes, and withholding update means a leaked token cannot silently repoint one of your established subscriptions at somebody else's server.

Both surfaces write to the same endpoints, so anything an integration creates is visible — and can be switched off — in the portal.

The official Zapier app and n8n node are built on exactly these calls; see tools/ipaas in the Cairn repository if you want a worked example of subscribe, verify, and unsubscribe.


2. The payload envelope

Every delivery, for every event type, has this shape:

{
  "id": "01K5Z8X9QW3M4N6P7R8S9T0V1W",
  "type": "ticket.opened",
  "api_version": "2026-07-19",
  "created_at": "2026-07-19T12:00:00+00:00",
  "workspace_id": "01K5Z8X9QW3M4N6P7R8S9T0V1X",
  "data": {
    "ticket_id": "01K…",
    "reference": "T-1042",
    "priority": "urgent",
    "contact_id": "01K…",
    "origin": "portal"
  }
}
Field Meaning
id The delivery's stable identity. Same value as the Idempotency-Key header. Constant across retries and across a redelivery.
type The event type. Route on this.
api_version Dated payload-envelope version, pinned when your endpoint was created.
created_at When the event occurred (not when this attempt was sent).
workspace_id The Cairn workspace the event belongs to.
data Event-specific fields.

Versioning

data is a deliberately chosen allowlist per event type, not a dump of our internal model — so it will not grow a field because someone added a database column. Note in particular that message and ticket bodies are not delivered. Webhook payloads are retried, logged, and replayed across infrastructure we do not control, so the text of a customer conversation is not put in one. Fetch it from the REST API, where your key's abilities are checked.


3. Headers

Header Example Notes
Cairn-Signature t=1752921600,v1=9f86d081… See §4.
Idempotency-Key 01K5Z8X9QW3M4N6P7R8S9T0V1W Dedupe on this.
Cairn-Delivery-Id 01K5Z8X9QW… Same value, named for humans.
Cairn-Event-Type ticket.opened Also in the body.
Cairn-Attempt 3 Which try this is. Informational only — do not dedupe on it.
Cairn-Payload-Version 2026-07-19 Also in the body.
Content-Type application/json

Delivery is at-least-once. You will occasionally receive the same event twice; deduplicating on Idempotency-Key is not optional.


4. Verifying the signature

Cairn-Signature: t=1752921600,v1=9f86d081884c7d659a2feaa0c55ad015…

Three rules that matter:

  1. Sign the raw body, exactly as received — the bytes, before any JSON parse and re-serialize. Re-encoding will change escaping or key order and the signature will not match.
  2. Compare in constant time (hash_equals, crypto.timingSafeEqual). A byte-at-a-time comparison leaks the correct MAC to anyone who can time your endpoint.
  3. Enforce the replay window. The timestamp is inside the MAC, so an attacker cannot rewrite it — but you must still reject requests whose t is too old, or a captured request stays valid forever.

Replay window: 300 seconds (5 minutes)

Reject any request where abs(now - t) > 300.

Why 300 and not 30: t is stamped when we build the request, and between there and your clock sit our queue latency, the network, and — usually the biggest term — how well your server's clock is disciplined. A 30-second window turns mild clock skew into a total delivery outage that looks like "every signature is invalid". Why not an hour: the window is your replay exposure.

Our own retries are re-signed with a fresh t each time, so a legitimate retry two hours later verifies normally. The window bounds an attacker's reuse of a captured request; deduplication is the Idempotency-Key's job, and the two are independent.

Node.js

const crypto = require('crypto')

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=', 2)),
  )
  const timestamp = Number(parts.t)
  if (!Number.isInteger(timestamp) || !parts.v1) return false

  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - timestamp) > toleranceSeconds) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(parts.v1, 'utf8')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Express: express.raw({ type: 'application/json' }) — NOT express.json(),
// which discards the raw bytes you need.
app.post('/cairn', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body.toString('utf8'), req.get('Cairn-Signature'), SECRET)) {
    return res.sendStatus(400)
  }
  res.sendStatus(200) // ack FAST, work afterwards — see §5
})

PHP

function cairn_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        $pair = explode('=', trim($piece), 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }

    if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
        return false;
    }

    if (abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);

    return hash_equals($expected, $parts['v1']);
}

Python

import hmac, hashlib, time

def cairn_verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(
        p.strip().split("=", 1) for p in header.split(",") if "=" in p
    )
    if "t" not in parts or "v1" not in parts or not parts["t"].isdigit():
        return False
    if abs(int(time.time()) - int(parts["t"])) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        parts["t"].encode() + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

5. Responding, retries, and auto-disable

Respond 2xx quickly. Anything else — including a 3xx we cannot follow safely — counts as a failure. Each attempt is given 10 seconds; acknowledge first and do your work asynchronously.

Retry schedule — 5 attempts, ≈2h36m total

Attempt Sent
1 immediately
2 + 1 minute
3 + 5 minutes
4 + 30 minutes
5 + 2 hours

After the fifth, the delivery is marked failed. It is not retried again automatically — replay it from the portal or the API (§6).

Auto-disable — 10 consecutive failed deliveries

An endpoint is switched off automatically after 10 consecutive deliveries that exhausted all five attempts. Note the unit: a single exhausted delivery already represents 5 attempts over ~2h36m, so no endpoint is ever disabled on a deploy blip or a brief restart, at any traffic level. Any single success resets the counter to zero, so a merely flaky endpoint stays enabled indefinitely; this fires only on sustained, total failure.

When it happens the endpoint's status becomes disabled and disabled_reason records the last error. Re-enable it with PATCH …/webhook-endpoints/{id} and {"status": "enabled"}, which also clears the counter. Nothing is delivered while it is disabled, and the events that fired meanwhile are not backfilled — replay the ones you need from the delivery log.


6. Delivery log and redelivery

Redelivery creates a new delivery that sends the original's exact bytes with a fresh signature. The original keeps its status and attempt history — that history is the evidence behind an auto-disable, so a retry click never erases it.

The replay carries the original Idempotency-Key. If your server already processed that event, it should ignore the replay — which is correct, because redelivery exists for the case where you never received it. It returns 409 if the endpoint is disabled (re-enable it first) or if the delivery is still being attempted.


7. Event catalog

Fetch the live list from GET …/webhook-event-types. At the time of writing:

Event Fires when
article.feedback_submitted A reader rates a help-center article
contact.unsubscribed A contact opts out of messaging
content.published A guide or article is published
content.retracted A guide or article is unpublished
content.verification_due Content has gone its full review interval without a human re-reading it
content.verification_expired A due review went unattended through its grace period
content.verified Someone re-read a piece of content and vouched for it
conversation.created A conversation is opened on any channel (its kind distinguishes a ticket from a chat); carries no subject or message body
conversation.message_received An inbound message lands in a conversation
conversation.quarantined A conversation is held back as spam (ATH-221)
conversation.released A quarantined conversation is restored to the inbox
conversation.tagged Tags are applied to a conversation, by a model or a person
guide.captured A new guide finishes capture
guide.drifted A guide's selectors stop matching its source
guide.drift_cleared A drifted guide starts matching again
segment.contact_entered A contact enters a segment
segment.contact_left A contact leaves a segment
status.incident_opened A status-page incident is opened
status.incident_updated A status-page incident is updated or resolved
ticket.opened A ticket is created
ticket.sla_breached A ticket misses its SLA target

Not every internal Cairn event is deliverable. Events carrying our billing state, workspace staff data, or AI tool arguments are deliberately excluded — see PubliclyDeliverable for the rule.

The same rule applies WITHIN a payload. conversation.quarantined tells you that a conversation was held as spam, but carries no message content, no contact identity, and not the classifier's stated reason — that reason is model prose quoting the message, and the messages this event describes are the ones most likely to be hostile. Read the conversation through the API if you need its contents.

conversation.tagged fires only when tags actually change, and its source field (ai / human / workflow) says who applied them. If you sync tags onward, use it: a label a model guessed and one a person chose are not the same fact.