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: 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" } }

Content never appears in the payload. 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, which 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 (10., 192.168., 169.254., 172.16172.31) are rejected: 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. A database read discloses the secret, and the repo names the fix: envelope encryption with a KMS-held key. 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: the deliveries route reports it and nothing re-attempts. 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, which 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, which is the only warning an operator gets that the outbox is not draining.

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 to keep each pass cheap.

↑↓ navigate↵ open