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

Section: Integrate · Source: https://titen.dev/docs/webhooks
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/reference/api.md

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

```json
{ "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.

<div class="table-wrap">

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

</div>

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

```sh
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"]}'
```

```json
{ "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" } }
```

<div class="rules">

<p><code>url</code> must be <code>https://</code>, or <code>http://localhost</code> / <code>http://127.0.0.1</code> for development.</p>
<p>Private and link-local hosts (<code>10.</code>, <code>192.168.</code>, <code>169.254.</code>, <code>172.16</code>–<code>172.31</code>) are rejected: an outbound POST you control the target of is an SSRF primitive.</p>
<p><code>secret</code> is at least 16 characters, and <code>events</code> is a non-empty array of at most 50 entries. <code>*</code> subscribes to every kind.</p>
<p>One organization may hold several hooks; each carries its own subscription list.</p>

</div>

<div class="callout">
<strong>THE WEBHOOK SECRET IS STORED RECOVERABLY</strong>

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.
</div>

## Verifying a delivery

<div class="kv">
<strong>WHAT ARRIVES</strong>

<p><code>X-Titen-Signature</code> <code>sha256=</code> plus the HMAC-SHA256 of the raw body, hex</p>
<p><code>X-Titen-Event</code> the event kind, so you can route before parsing</p>
<p><code>X-Titen-Delivery</code> the delivery id (<code>dlv_…</code>) — dedupe on this</p>
<p><code>body</code> the event's <code>payload</code> object, exactly as stored</p>

</div>

<figure class="code"><figcaption>receiver.ts<b>typescript</b></figcaption>

```ts
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));
}
```

</figure>

Sign the bytes you received, not a re-serialized object: a JSON round trip reorders keys and
the signature stops matching.

## Pause, resume, inspect

<div class="table-wrap">

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

</div>

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

```json
{ "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.

<div class="callout callout--alarm">
<strong>THE RETRY SCHEDULE IS RECORDED, NOT YET DRIVEN</strong>

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.
</div>

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

<div class="table-wrap">

| `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 |

</div>

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.

```sh
curl -X POST "http://127.0.0.1:8787/v1/webhooks/deliver?limit=50" \
  -H "authorization: Bearer $TITEN_API_KEY"
```

```json
{ "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.

<div class="cards">

<a href="/docs/deploy-cloudflare"><strong>Cloudflare Workers</strong><span>Where <code>background_repair</code> is <code>external</code> and a Cron Trigger owns the drain.</span></a>
<a href="/docs/keys-scopes"><strong>Keys &amp; scopes</strong><span><code>webhooks:write</code>, <code>webhooks:read</code> and <code>events:read</code> — and why they are separate.</span></a>
<a href="/docs/api"><strong>HTTP API</strong><span>The seven webhook routes and two event routes in the full table.</span></a>

</div>