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
- We may add keys to
dataor to the envelope at any time without changingapi_version. Your parser must ignore unknown keys. - Removing a key, renaming one, or changing its type is a breaking
change and ships as a new dated
api_version. Your endpoint keeps receiving the version it was created with until you move it.
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…
t— unix timestamp (seconds) at which this attempt was signed.v1— hexHMAC-SHA256of"{t}.{raw_request_body}", keyed with your endpoint'swhsec_…secret.
Three rules that matter:
- 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.
- 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. - Enforce the replay window. The timestamp is inside the MAC, so
an attacker cannot rewrite it — but you must still reject requests
whose
tis 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
GET …/webhook-deliveries— filterable byendpoint_idandstatus.GET …/webhook-deliveries/{id}— every attempt with its status code, duration and error, plusbody: the verbatim bytes we signed and sent, so you can reproduce a signature mismatch exactly.POST …/webhook-deliveries/{id}/redeliver— replay it.
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.