# HTTP API

> Every route the service declares, with the scope its credential must hold and the two response envelopes.

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

---
The route table below is the router's own contents. Anything absent from it does not
exist yet, and `scripts/verify-live.ts` fails if the router declares a route the
verification script never called.

## Authentication

Two routes are unauthenticated: `GET /healthz` and `GET /readyz`. Every other route
declares a required scope and authenticates first.

<div class="rules">

<p>Send <code>Authorization: Bearer titen_sk_…</code>. A token that does not start with <code>titen_sk_</code> is rejected without a database lookup.</p>
<p>Verification is one indexed lookup on the SHA-256 of the key. Nothing about the principal is cached, so a revoked key stops working on the next request.</p>
<p>Organization comes from the credential. No route accepts an <code>org_id</code> field.</p>
<p>A key may hold <code>*</code> in place of a scope list. A key can never mint another key with scopes or a trust ceiling it does not itself hold.</p>

</div>

Scope failures are distinguishable from identity failures, but resource ownership is
not: see [Errors](/docs/errors) for why a foreign id returns `404` and never `403`.
The full scope list and trust ceilings are on [Keys & scopes](/docs/keys-scopes).

## Envelopes

Success is `data` plus `meta`. Failure is `error` plus `meta`. Both always carry
`meta.request_id`, repeated in an `x-request-id` response header. Envelope responses
are `content-type: application/json; charset=utf-8`, and every response is
`cache-control: no-store`.

```json
{ "data": { "…": "…" },
  "meta": { "request_id": "req_930dd110186f42ad959858d8745fc0b6" } }
```

```json
{ "error": { "code": "VALIDATION_ERROR", "message": "Field \"kind\" must be one of: …" },
  "meta": { "request_id": "req_71b6d2e2eba54b0298b41de8544dedc6" } }
```

Request ids are `req_` + 32 lowercase hex. Record ids follow the same shape with their
own prefix: `org_`, `key_`, `obs_`, `claim_`, `ctx_`, `fb_`.

Three responses sit outside the JSON envelope. `GET /v1/export` and
`GET /v1/audit/export` stream `application/x-ndjson`; `POST /mcp` returns a bare
JSON-RPC body, the shape an MCP client expects.

## Request limits

<div class="kv">
<strong>REQUEST RULES</strong>

<p><code>MAX_BODY_BYTES</code> 1,048,576. Checked against the declared <code>content-length</code> first, then against the body that actually arrived. Over the limit is <code>413 PAYLOAD_TOO_LARGE</code>.</p>
<p><code>content-type</code> JSON requests are parsed as JSON; an empty body or unparseable body is a <code>400 VALIDATION_ERROR</code>, not a 500.</p>
<p><code>Idempotency-Key</code> Optional, trimmed to 200 characters. Honoured on the three endpoints below.</p>

</div>

## Idempotency and `meta.replayed`

`POST /v1/observations`, `POST /v1/consolidations` and `POST /v1/context/:id/feedback`
accept an `Idempotency-Key` header. The replay record is inserted inside the same
atomic batch as the canonical rows, so a duplicate key makes the whole write fail and
roll back.

<div class="rules">

<p>First call: the normal <code>201</code>, with <code>meta.replayed: false</code>.</p>
<p>Same key, same body: the stored response, with <code>meta.replayed: true</code> and status <code>200</code> rather than <code>201</code>.</p>
<p>Same key, different body: <code>409 CONFLICT</code>, "Idempotency-Key was reused with a different request body." A key identifies one request, not one endpoint.</p>

</div>

`POST /v1/context/:id/feedback` additionally accepts a `client_mutation_id` field in
the body, deduplicated per organization by a unique index.

<div class="callout">
<strong>MUTATIONS WITHOUT A KEY</strong>

Every other mutating route is a plain write. Retrying `POST /v1/leases` or
`POST /v1/channel-releases` without a key does what the underlying rule says: a
second lease acquire by the same holder replaces its own lease, a second release
creates a new version.
</div>

## Health

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `GET` | `/healthz` | — | Liveness, plus the runtime label and build revision. No sensitive detail. |
| `GET` | `/readyz` | — | Storage check, applied-vs-expected migration counts, capability flags. Returns `503 NOT_READY` with the same body when not ready. |

</div>

## The memory loop

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/projects/resolve` | `projects:resolve` | Normalize a project reference to an opaque project id. `create: true` additionally requires `projects:create`. |
| `POST` | `/v1/observations` | `observations:write` | Append content-hashed evidence. `201`. `trust` may not exceed the credential's ceiling. |
| `POST` | `/v1/consolidations` | `claims:write` | Derive claims from observations. Deterministic — `model_used: false`, `meta.model: "disabled"`. |
| `POST` | `/v1/context/compile` | `context:compile` | Rank authorized claims into a token budget and record the selection as a `ctx_` run. |
| `POST` | `/v1/context/:id/feedback` | `feedback:write` | Record `used`, `useful`, `irrelevant`, `incorrect` or `harmful` against a run or one of its items. |
| `GET` | `/v1/claims/:id/evidence` | `evidence:read` | Resolve a claim to its supporting, contradicting and qualifying observations, plus `hidden_source_count`. |

</div>

A walkthrough with real payloads is in the
[Quickstart](/docs/quickstart); the ranking is explained on
[Compile context](/docs/compile).

## Claim lifecycle

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/claims/:id/supersede` | `claims:write` | Replace a claim with an active one named in `superseded_by`. Only `active` or `disputed` claims can be superseded. |
| `POST` | `/v1/claims/:id/revoke` | `claims:write` | Withdraw a claim. Idempotent, reports `already_revoked`. A `superseded` claim cannot be revoked. |
| `POST` | `/v1/claims/:id/expire` | `claims:write` | End validity now by setting `valid_to`. Idempotent, reports `already_expired`. |

</div>

All three stop the claim appearing in compiled context and none of them delete an
observation. Details on [Claim lifecycle](/docs/claim-lifecycle).

## Checkpoints

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/checkpoints` | `checkpoints:write` | Upsert task state on organization + subject + agent + kind. A second write returns `200` with `updated: true` and the same `checkpoint_id`. |
| `GET` | `/v1/checkpoints` | `checkpoints:read` | Read the current checkpoint. Requires `subject_id` and `kind` query parameters; returns `404` once expired. |
| `DELETE` | `/v1/checkpoints/:id` | `checkpoints:write` | Delete a checkpoint row outright. Checkpoints are task state, not evidence. |

</div>

`kind` is one of `task_state`, `conversation`, `workflow`, `cursor`. `ttl_seconds` is
between 60 and 2,592,000, and the serialized `state` is capped at 64,000 characters.

## Keys

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/keys` | `keys:manage` | Mint a key. The raw `titen_sk_…` value is in the response once and never again; storage holds only its hash. |
| `GET` | `/v1/keys` | `keys:manage` | List key metadata with `status`. No hashes, no secrets. |
| `DELETE` | `/v1/keys/:id` | `keys:manage` | Revoke a key. Effective on the next request. |

</div>

## Portability

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `GET` | `/v1/export` | `export:read` | Stream NDJSON: one header line, then one record per line. `type` is `projects`, `observations` or `claims`; `limit` 1–2000, default 500. |
| `POST` | `/v1/import` | `import:write` | Load NDJSON, at most 2000 lines per request, preserving record ids so evidence links survive the move. |

</div>

Order matters across requests: an observation references a project, and a claim source
references an observation. Follow `import_order` from the export header: projects, then
observations, then claims. A reference to a record that does not exist yet is
`400 UNRESOLVED_REFERENCE`, not a conflict; a conflict means the id already belongs to
another organization. See [Backup & export](/docs/backup-export).

## Collaboration

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/workspaces` | `workspaces:write` | Create a workspace. A duplicate name in one organization is `409`. |
| `GET` | `/v1/workspaces` | `workspaces:read` | List workspaces. |
| `POST` | `/v1/memberships` | `memberships:write` | Attach a principal to the organization or a workspace with role `owner`, `admin`, `member` or `reader`. |
| `GET` | `/v1/memberships` | `memberships:read` | List current memberships, optionally filtered by `workspace_id`. |
| `DELETE` | `/v1/memberships/:id` | `memberships:write` | Soft removal: the member stops being listed, prior provenance and audit entries remain. |
| `POST` | `/v1/leases` | `leases:write` | Acquire an exclusive lease on `resource_type` + `resource_id`. A live lease held by another principal is `409`; an expired one or the same holder re-acquires. |
| `DELETE` | `/v1/leases/:id` | `leases:write` | Release a lease before its TTL. |
| `POST` | `/v1/handoffs` | `handoffs:write` | Offer work to another principal, optionally naming a `context_id` and `checkpoint_id`. |
| `POST` | `/v1/handoffs/:id/resolve` | `handoffs:write` | The recipient sets `status` to `accepted` or `rejected`. Nobody else can resolve it. |
| `GET` | `/v1/handoffs` | `handoffs:read` | Pending handoffs addressed to the caller — the pull path for agents that cannot receive webhooks. |

</div>

Lease `ttl_seconds` is between 10 and 86,400. More on
[Leases & handoffs](/docs/leases-handoffs).

## MCP

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/mcp` | `mcp:call` | JSON-RPC 2.0 over Streamable HTTP: `initialize`, `notifications/initialized`, `initialized`, `ping`, `tools/list`, `tools/call`. Seven tools. |

</div>

Protocol versions `2025-06-18`, `2025-03-26` and `2024-11-05` are supported. A
notification returns `202` with an empty body; a batch returns an array. Key
management, membership, retention, webhook, federation, release and Atlas operations
are deliberately absent from the agent tool profile; publisher, approver, operator and
gateway principals use REST for those. See [MCP server](/docs/mcp).

## Events

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `GET` | `/v1/events` | `events:read` | Authorized, metadata-only domain events after an opaque cursor. `after`, `limit` 1–200 default 50, `kind`. |
| `GET` | `/v1/events/:id` | `events:read` | One event by id. |

</div>

Event rows are written in the same atomic batch as the canonical change they describe,
so an event can never exist for a write that rolled back or be missing for one that
committed.

## Memory Atlas

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/memory-views/compile` | `views:compile` | Compile a bounded authorized graph view. `lens` is `evidence_trace`, `neighborhood`, `conflict_freshness` or `scope_preview`; `limit` 1–200, default 50. |

</div>

Read-only despite the `POST`; the request needs a bounded body. `evidence_trace`
requires `focus_id`, `neighborhood` requires `subject_id`. Policy runs before
traversal: both endpoints of every edge must be authorized, and hidden candidates
contribute no edges, labels or counts. An unauthorized focus is a non-disclosing
`404`.

## Governance and channel releases

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/policies` | `policies:write` | Record a `retention`, `approval_required`, `visibility_default` or `trust_ceiling` policy with a JSON `config`. |
| `GET` | `/v1/policies` | `policies:read` | List the organization's policies. |
| `POST` | `/v1/channel-releases` | `releases:write` | Draft a release for one `channel` and `audience`. Only `active` claims with `verified` or `policy_approved` trust are included; up to 200 `claim_ids`. |
| `POST` | `/v1/channel-releases/:id/publish` | `releases:write` | `draft` → `active`. Publishing from any other state is `409`. |
| `POST` | `/v1/channel-releases/:id/revoke` | `releases:write` | `active` → `revoked`, keeping the release record and its audit trail. |
| `GET` | `/v1/channel-releases/:id` | `releases:read` | Read a release with its released items. |
| `POST` | `/v1/channel-context` | `channel:read` | Serve only the content of `active` releases matching a channel and audience, packed under `max_tokens` (128–32,000). |

</div>

`verified` trust describes evidence authority; it does not by itself permit
disclosure. A public-facing chatbot holds a credential pinned to its channel and reads
`/v1/channel-context`, never canonical memory. Reasoning in
[ADR-0002](https://github.com/RamaAditya49/titen/blob/main/docs/decisions/0002-channel-release-not-public-memory.md)
and on [Channel releases](/docs/channel-releases).

## Audit

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `GET` | `/v1/audit` | `audit:read` | Metadata-only audit entries with cursor pagination. `after`, `action`, `limit` 1–500 default 50. |
| `GET` | `/v1/audit/export` | `audit:export` | Stream the log as NDJSON behind a `titen.audit.header` line. `after`, `limit` 1–5000 default 1000. |

</div>

## Federation

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/federation/peers` | `federation:write` | Register a peer with `endpoint`, `shared_secret` (16+ characters) and `direction` `push`, `pull` or `bidirectional`. One endpoint per organization. |
| `GET` | `/v1/federation/peers` | `federation:read` | List peers with status and sync cursor. |
| `POST` | `/v1/federation/peers/:id/suspend` | `federation:write` | Suspend a peer. Its pull and push then return `403`. |
| `POST` | `/v1/federation/peers/:id/filters` | `federation:write` | Add a filter on `resource_type` with optional `include_kinds`, `exclude_subjects`, `min_trust`. Filters bound both directions. |
| `GET` | `/v1/federation/peers/:id/filters` | `federation:read` | List a peer's filters. |
| `POST` | `/v1/federation/pull` | `federation:write` | Pull up to 200 filtered events after the peer's cursor and advance it. |
| `POST` | `/v1/federation/push` | `federation:write` | Accept events from a peer. Requires `x-titen-peer-signature: sha256=HEX` over the raw body; each event reports `success`, `conflict` or `rejected`. |
| `GET` | `/v1/federation/log` | `federation:read` | The exchange log for one `peer_id`. |

</div>

## Webhooks and delivery

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/webhooks` | `webhooks:write` | Register a destination with `secret` (16+ characters) and a non-empty `events` array (up to 50; `*` matches every kind). The response states the signing contract and never echoes the secret. |
| `GET` | `/v1/webhooks` | `webhooks:read` | List webhooks with status, event filter and failure count. |
| `DELETE` | `/v1/webhooks/:id` | `webhooks:write` | Stop future delivery. |
| `POST` | `/v1/webhooks/:id/pause` | `webhooks:write` | Suspend delivery without dropping events. |
| `POST` | `/v1/webhooks/:id/resume` | `webhooks:write` | Resume delivery and clear the failure counter. |
| `GET` | `/v1/webhooks/:id/deliveries` | `webhooks:read` | Metadata-only delivery state: `status`, `attempts`, `last_attempt_at`, `next_retry_at`, `response_status`. |
| `POST` | `/v1/webhooks/deliver` | `webhooks:write` | Drain the delivery outbox. `after`, `limit` 1–200 default 50. |

</div>

The destination must be `https://`, or `http://localhost` / `http://127.0.0.1` for
development; private and link-local ranges are rejected. Deliveries are signed
`X-Titen-Signature: sha256=HEX`, an HMAC-SHA256 of the raw body, alongside
`X-Titen-Event` and `X-Titen-Delivery`. Backoff is 1 minute, 5 minutes, 30 minutes,
2 hours, 12 hours, capped at 5 attempts, and a webhook is disabled after 10
consecutive failures. A unique index on (webhook, event) makes delivery exactly once
per event per webhook even if a drain is replayed. More on
[Webhooks & events](/docs/webhooks).

## Indexing

<div class="table-wrap">

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `POST` | `/v1/index/drain` | `index:write` | Embed queued claims into the vector store. `limit` 1–100, default 50. Returns `drained`, `indexed`, `skipped`, `remaining`, `model`, `dimensions`, `at`. |

</div>

Observation and claim writes queue an outbox row, and until something consumes them the
vector index stays empty. This route is that consumer, pull-driven because an embedding
call crosses the network and a canonical write must not wait on one. With no vector
capability configured it returns `400`; if the model or index is unreachable it returns
`503 UNAVAILABLE` with `meta.pending` and `meta.retryable` and leaves the queue
untouched. `GET /readyz` reports `background_repair` so an operator can tell whether
anything is calling it. See
[Semantic vectors](/docs/vectors).

## Not implemented

Named here because earlier drafts documented them as if they existed: batch
observation append, channel CRUD at `/v1/channels`, a separate approve/activate
release workflow, `/v1/webhook-subscriptions`, `/v1/webhook-deliveries` as a
top-level collection, `/v1/audit/events`, and
`/v1/channels/:id/context/compile`.

## Compatibility

<div class="rules">

<p>The v1 export format is versioned independently from the HTTP API.</p>
<p>Breaking request or response changes require a new API version or a migration path.</p>
<p>External field names are <code>snake_case</code>. Internal names are not part of the contract.</p>

</div>

## Next

<div class="cards">

<a href="/docs/errors"><strong>Errors</strong><span>All eleven codes, their statuses, and what a caller should do about each.</span></a>
<a href="/docs/data-model"><strong>Data model</strong><span>The entities behind these routes and which state transitions are legal.</span></a>
<a href="/docs/sdk"><strong>Agent SDK</strong><span>Seventeen typed methods over the same routes, on plain fetch.</span></a>

</div>