Webhooks & events
Every canonical write records an event in the same transaction; webhooks deliver those events to one signed HTTPS destination, at most once per event per hook.
The event log comes first
eventStatement returns a statement rather than executing one, so the caller folds it into
the same atomic batch as the write it describes. An event can never exist for a write that
rolled back, and can never be missing for one that committed. Webhooks are a delivery
mechanism layered on that log — they are not the log, and losing a delivery never loses an
event.
GET /v1/events is cursor polling: after (an event id), limit (1 to 200, default 50),
kind. GET /v1/events/:id fetches one.
{ "data": { "events": [
{ "id": "evt_c7a15e9b3790444dad5fcba5668a73da",
"kind": "observation.appended",
"actor_id": "agent_8df7b8699a0945488ceb3d4b104006d6",
"resource_type": "observation",
"resource_id": "obs_ed91a2d0f66143fbba66c932494d5e64",
"payload": { "subject_id": "user_rama", "kind": "tool_result",
"trust": "verified", "visibility": "team" },
"created_at": "2026-07-30T10:38:43.062Z" } ],
"cursor": "evt_4ff141c765f045d6824c7e2b66732eb5" },
"meta": { "request_id": "req_da2420eee58d4a88b146e1cf297fc5d9" } }
Note what the payload is not: content never appears. You get scope and classification, plus
resource_id to fetch the record through an authorized route.
| Kind | Emitted when |
|---|---|
observation.appended |
evidence is appended through POST /v1/observations |
claim.materialized |
a consolidation creates a claim |
claim.superseded, claim.revoked, claim.expired |
a claim leaves eligibility |
lease.acquired |
a coordination lease is taken |
handoff.created, handoff.accepted, handoff.rejected |
a handoff is offered or answered |
release.published, release.revoked |
a channel release changes state |
Eleven kinds, and that is the whole list: key, webhook, workspace, membership, policy and
federation administration emits nothing. Neither does titen_remember — the MCP write path
skips the event statement, so an observation appended through a tool triggers no webhook.
Registering a webhook
curl -X POST http://127.0.0.1:8787/v1/webhooks \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"url":"https://hooks.example.internal/titen",
"secret":"a-secret-at-least-16-chars",
"events":["claim.materialized","handoff.created"]}'
{ "data": { "webhook_id": "whk_07218864d17b4f44a2367b8e717ff3bb",
"url": "https://hooks.example.internal/titen",
"events": ["claim.materialized", "handoff.created"],
"status": "active", "created_at": "2026-07-30T10:47:59.369Z",
"signing": { "header": "X-Titen-Signature", "algorithm": "HMAC-SHA256",
"format": "sha256=<hex>",
"note": "Compute HMAC-SHA256 of the raw JSON body using your secret to verify deliveries." } },
"meta": { "request_id": "req_d67ca979b1b24c47b319d5689cc6fa58" } }
url must be https://, or http://localhost / http://127.0.0.1 for development.
Private and link-local hosts are rejected outright — 10., 192.168., 169.254. and 172.16–172.31 — because an outbound POST you control the target of is an SSRF primitive.
secret is at least 16 characters, and events is a non-empty array of at most 50 entries. * subscribes to every kind.
One organization may hold several hooks; each carries its own subscription list.
HMAC is symmetric, so the signing key cannot be a digest — the receiver verifies with the same secret it gave you. Titen stores it alongside a SHA-256 fingerprint, which confirms which secret is configured without printing it. A webhook secret and a federation peer’s shared key are the only two credentials stored recoverably; API keys are only ever hashed. The repo names the ceiling and the fix: a database read discloses the secret, and envelope encryption with a KMS-held key closes it. No response ever echoes it.
Verifying a delivery
X-Titen-Signature sha256= plus the HMAC-SHA256 of the raw body, hex
X-Titen-Event the event kind, so you can route before parsing
X-Titen-Delivery the delivery id (dlv_…) — dedupe on this
body the event's payload object, exactly as stored
const enc = new TextEncoder();
export async function verify(secret: string, rawBody: string, header: string | null) {
if (!header?.startsWith('sha256=')) return false;
const hex = header.slice(7);
if (hex.length !== 64) return false;
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const sig = Uint8Array.from(hex.match(/../g)!.map((b) => parseInt(b, 16)));
return crypto.subtle.verify('HMAC', key, sig, enc.encode(rawBody));
}
Sign the bytes you received, not a re-serialized object — a JSON round trip reorders keys and the signature stops matching.
Pause, resume, inspect
| Route | Effect |
|---|---|
POST /v1/webhooks/:id/pause |
status: "paused". Delivery stops; events keep accruing in the log. |
POST /v1/webhooks/:id/resume |
status: "active" and failure_count back to 0. |
GET /v1/webhooks/:id/deliveries |
Delivery metadata, newest first. limit default 50, max 200. |
DELETE /v1/webhooks/:id |
Removes the hook and its delivery rows. |
Ten consecutive failures move a hook to disabled, and that state is terminal: pause answers
Cannot pause a disabled webhook. and resume answers
Cannot resume a disabled webhook. Re-register it. Both are 403.
{ "data": { "deliveries": [
{ "id": "dlv_6cabce9807f741b58609e6a5dc3b809e",
"event_id": "evt_7890988d21c6467095bf714f9dc845fc",
"status": "pending", "attempts": 1,
"last_attempt_at": "2026-07-30T10:48:05.752Z",
"next_retry_at": "2026-07-30T10:49:05.752Z",
"response_status": null,
"created_at": "2026-07-30T10:48:05.752Z" } ] } }
Delivery rows are metadata too — a status, an attempt count and the response code, never a response body.
Exactly-once delivery per event per webhook is enforced by a dedup guard on
(webhook_id, event_id) inside deliverEvent, so the timer and the drain endpoint both
inherit it — and a failed first attempt is skipped on the next pass along with the successful
ones. The backoff schedule (1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, capped at 5
attempts) and the next_retry_at column both exist, but no shipped code path acts on that
column — it is reported by the deliveries route and never re-attempted. Poll GET /v1/webhooks/:id/deliveries for rows stuck at pending, and treat the
event log as the source of truth for anything you must not miss.
Who drives delivery
An outbound request cannot happen inside a canonical write — the write must not wait on
someone else’s server — so delivery is pulled from the log afterwards. Who does the pulling
depends on the runtime, and /readyz reports it under
capabilities.background_repair, because an operator whose outbox never drains has no other
way to notice.
background_repair |
Meaning | What you must call |
|---|---|---|
enabled |
This process does it. The Bun runtime’s in-process timer, default every 15 seconds. | nothing |
external |
The runtime cannot host a timer and depends on a scheduler it cannot see: Cloudflare Workers with a Cron Trigger. | configure triggers.crons in wrangler.jsonc — the scheduled handler runs the pass, and without a trigger nothing drains |
disabled |
Nothing drains. | POST /v1/webhooks/deliver and POST /v1/index/drain, on your own schedule |
On Bun the timer arms only when the maintenance interval is above zero and either a vector
capability is configured or you passed an interval explicitly. A plain titen serve with no
embedding endpoint therefore reports background_repair: "disabled" — if you registered a
webhook on that deployment, nothing will deliver it until you call the drain.
curl -X POST "http://127.0.0.1:8787/v1/webhooks/deliver?limit=50" \
-H "authorization: Bearer $TITEN_API_KEY"
{ "data": { "events_drained": 5,
"cursor": "evt_7890988d21c6467095bf714f9dc845fc",
"pending_deliveries": 2 },
"meta": { "request_id": "req_7ff7533b486a4d68a8b7fec51566c79c" } }
after and limit (1 to 200, default 50) bound the pass. Without after it scans from the
start of the organization’s event log every time and leans on the dedup guard to avoid
resending, so feed the returned cursor back on the next call and each pass stays cheap.