Titentiten.devꦠꦶꦠꦺꦤ꧀
v0.1.1 · npmGitHub
DocsIntegrateWebhooks & events

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.

GET /v1/events11 event kindsHMAC-SHA256metadata only

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.16172.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.

THE WEBHOOK SECRET IS STORED RECOVERABLY

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

WHAT ARRIVES

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

receiver.tstypescript
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.

THE RETRY SCHEDULE IS RECORDED, NOT YET DRIVEN

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.

↑↓ navigate↵ open