Skip to content

REST API#

The REST API lives under /api/v1. It is the primary integration surface for humans (the Next.js UI) and for bots that prefer plain HTTP over MCP. FastAPI generates an OpenAPI 3 document describing every endpoint; this page describes the conventions that document encodes.

For per-endpoint request and response schemas, the OpenAPI document is the authoritative source. See OpenAPI spec for where it lives at runtime.

Base path and versioning#

  • Base path: /api/v1.
  • Versioning policy: v1 receives additive changes only — new endpoints, new optional fields, new enum values for open enums. Anything that would break an existing client moves to /api/v2. There is no v2 in scope.
  • Deprecation: v1 does not deprecate anything at launch. A formal deprecation mechanism (e.g. RFC 9745 Sunset headers) is not wired up in v1; if a field is ever slated for removal it will be called out in release notes first.
  • Media type: application/json; charset=utf-8 for request and response bodies.

Authentication#

Two mechanisms, one resolved actor.

Mechanism Used by Carrier
Session cookie Humans (browser) HttpOnly, Secure (prod), SameSite=Lax cookie named tb_session
API key header Bots (curl, SDK, anything HTTP) X-API-Key: <plaintext bot token>

The MCP transport authenticates differently — see MCP — but both transports resolve to the same current_actor object on the request.

Resolution order#

  1. X-API-Key header present → resolve as bot. Header takes precedence over cookie so a bot SDK running in a browser-ish context cannot accidentally pick up a session cookie.
  2. Else tb_session cookie present → resolve as human.
  3. Else → unauthenticated.

If both are present, the request is rejected with 401 auth.ambiguous_credentials. This is explicit defense against credential confusion bugs in client SDKs.

Authentication outcomes#

Outcome HTTP Error code
No credential and the endpoint requires one 401 auth.unauthenticated
Credential present but invalid/expired/revoked 401 auth.invalid_credentials
Credential ambiguous (cookie + header) 401 auth.ambiguous_credentials
Credential valid but the actor lacks permission 403 auth.forbidden (with a subcode in details.reason)

Authorization#

The full identity model lives in Auth and identity. For the REST surface specifically:

  • Workspace scope is implicit. Every request resolves to exactly one workspace (the actor's). No workspace id appears in URLs.
  • Humans have full read/write within their workspace. Admin-only endpoints (GET /users, GET /audit, all bot and webhook management) are humans-only.
  • Bots are constrained by two scope dimensions enforced in the service layer:
    1. bot_project_allowlist — a list of project ids the bot may access. An empty list grants access to all projects (global-access mode). A non-empty list restricts the bot to those project ids; read endpoints filter implicitly, and write endpoints reject out-of-scope projects with auth.forbidden (details.reason = "project_not_in_allowlist").
    2. bot_mode ∈ {read, write} — write endpoints reject read-only bots with auth.read_only_bot.
  • Global wiki pages (no project_id) are visible to every authenticated actor in the workspace.
  • Personal sticky notes are visible only to their owner. Bots cannot list or read another user's personal notes.

Error envelope#

Every error response — 4xx and 5xx — uses the same shape:

{
  "error": {
    "code": "string.dotted.identifier",
    "message": "Human-readable, safe to display.",
    "details": { "field": "...", "reason": "..." }
  }
}
  • code is a stable, machine-parseable identifier. Clients switch on code, not on message or HTTP status alone.
  • message is for humans. It MAY be shown verbatim in the UI; it MUST NOT contain stack traces, raw SQL, or secrets.
  • details is optional and event-specific. Its schema varies by code.
  • request_id (UUID) is echoed back as the X-Request-Id response header (not inside the envelope) so logs and bug reports can correlate.

Canonical error codes#

Code HTTP Meaning details shape
auth.unauthenticated 401 No credential on a protected endpoint. none
auth.invalid_credentials 401 Credential present but invalid, expired, or revoked. none (does not leak which)
auth.ambiguous_credentials 401 Both session cookie and X-API-Key present. none
auth.forbidden 403 Authenticated but not allowed. { "reason": "project_not_in_allowlist" \| "humans_only" \| ... }
auth.read_only_bot 403 Write requested by a bot_mode=read bot. none
auth.current_password_invalid 400 Self-service password change supplied the wrong current password. none (does not leak account existence)
validation.password_too_short 400 New password is shorter than the 12-character minimum. none
validation.password_unchanged 400 New password is identical to the current password. none
request.validation_error 422 Pydantic request-body or query-param validation failure. { "errors": [{ "loc": [...], "msg": "...", "type": "..." }] }
validation.invalid_field 400 / 422 Domain-level validation failure (invalid cursor, hierarchy violation, ambiguous slug, etc.). { "field": "...", "reason": "..." }
resource.not_found 404 Target does not exist or is invisible to the actor. { "resource": "...", "id": "..." }
resource.conflict 409 Optimistic concurrency, unique constraint, invariant violation, or idempotency-key fingerprint mismatch. { "reason": "etag_mismatch" \| "duplicate_key" \| "invariant_violation" \| "idempotency_mismatch", ... }
resource.gone 410 Resource was soft-deleted and the endpoint does not opt in to deleted views. { "resource": "...", "id": "..." }
rate_limit.exceeded 429 Per-actor token bucket empty. { "retry_after_seconds": 12 }
server.internal 500 Unhandled exception. none in production; { "trace_id": "..." } in dev
server.unavailable 503 Dependency (DB) unreachable. none

Codes never include the HTTP number. HTTP status and code are two independent signals.

Status mapping#

HTTP Used for
200 Successful read or update with body.
201 Successful create. Response body is the created resource; Location header points to its canonical URL.
204 Successful delete or other no-body success.
400 Malformed request (bad JSON, missing required path param).
401 Auth-related (see codes).
403 Authorization failure (see codes).
404 resource.not_found.
409 resource.conflict.
410 Soft-deleted resource read without ?include_deleted=true.
422 validation.invalid_field.
429 rate_limit.exceeded.
500 Unhandled exception.
503 Dependency outage.

Pagination#

All list endpoints use opaque cursor pagination. No offset, no page numbers.

Request#

GET /api/v1/tickets?project_id=...&limit=50&cursor=eyJ2IjoxLCJsYXN0X2lkIjoiMDE5OC4uLiIsLi4ufQ
  • limit — integer 1..200, default 50. Out of range produces validation.invalid_field.
  • cursor — opaque base64url string. Omitted on the first page.

Response envelope#

{
  "data": [ /* page items */ ],
  "page": {
    "next_cursor": "eyJ2IjoxLCJsYXN0X2lkIjoiMDE5OC4uLiIsLi4ufQ",
    "has_more": true
  }
}
  • next_cursor is null when has_more is false. Clients stop paginating when has_more is false.
  • Single-resource GETs do not wrap in data — they return the resource object directly. Only collection endpoints use the envelope.

Cursor encoding#

Clients treat the cursor as opaque. For reference, two shapes are in use today:

# Generic list cursors (tickets, comments, audit, users, etc.):
cursor = base64( json({ "id": "<UUIDv7>", "created_at": "<ISO-8601>" }) )

# /search cursor adds a filter-hash guard:
cursor = base64( json({ "id": "...", "created_at": "...", "filter_hash": "<sha256(canonicalized filter params)[0:16]>" }) )
  • The generic list cursor carries the last row's id and created_at and is parsed back into the query as a keyset offset. A malformed cursor returns 400 validation.invalid_field (details.field = "cursor").
  • The /search cursor additionally embeds a filter_hash. Mixing a /search cursor with mismatched filter params returns 400 validation.invalid_field (details.reason = "cursor_filter_mismatch"). No other list endpoint enforces this guard today.
  • Cursors are verified, not signed. Tampering produces 400, not 401.

Filtering and sorting#

Filters are single-value equality only on REST list endpoints in v1. There is no multi-value IN syntax, no sort param, and no free-text q on the REST list handlers — for full-text search use GET /search. The per-endpoint accepted params are spelled out in the endpoint catalog below.

Style Example Where it works
Equality filter ?state_id=<uuid> Every list endpoint that documents the param.
Include soft-deleted ?include_deleted=true (humans only; bots get auth.forbidden) Only on endpoints whose entities are soft-deletable.

Unknown query params are ignored silently; cursor is validated.

MCP filters are richer than REST filters

The MCP list_tickets tool accepts updated_since and q (FTS) in addition to the single-value filters above. The REST list does not. The imbalance is intentional for v1 — LLM agents lean on updated_since polling; the UI does not.

Idempotency#

POST endpoints that create resources accept an optional Idempotency-Key header.

  • Scope: ticket create, comment create, wiki create, sticky-note create, bot create, webhook create. Other POSTs (transitions, login, logout, token rotation) do not accept the header.
  • Key format: client-chosen string up to 128 chars. UUIDv4 is recommended.
  • TTL: configurable via IDEMPOTENCY_KEY_TTL_HOURS (default 24 hours).
  • Lookup: by (workspace_id, actor_user_id, key). The stored row carries the request fingerprint, status code, response body, and replayable headers (including Location).

Semantics#

  • New key → process normally; record fingerprint + response.
  • Existing key, same request fingerprint → replay the stored response verbatim (status code, body, and Location header).
  • Existing key, different request fingerprint → 409 resource.conflict (details.reason = "idempotency_mismatch"). The server never replays a different request under the same key; that is a client bug.

request_fingerprint = sha256(method + path + canonicalized_json(body)). Headers are excluded.

Pair retries with an idempotency key

Retrying a POST /projects/{id}/tickets without an idempotency key can produce a duplicate ticket. Setting Idempotency-Key: <uuid> makes the retry deterministic: same response, no second ticket.

Optimistic concurrency#

ETags + If-Match headers are emitted on tickets and wiki pages — the entities most likely to be edited by both a human and a bot at once.

  • GET responses for tickets and wiki pages include an ETag header. The value is a weak ETag of the form W/"<updated_at_unix_ms>-<short_id>".
  • PATCH and DELETE requests may include If-Match: <etag>.
    • If-Match present and matches → proceed.
    • If-Match present and does not match → 409 resource.conflict (details.reason = "etag_mismatch").
    • If-Match absent → proceed (no concurrency check).

v1 stance#

ETags are emitted on every GET and honored when sent, but not required on PATCH/DELETE in v1. The frontend ticket-detail editor sends If-Match; quick toggles (state changes, assignment) do not. Bots that care can opt in and get the protection.

If experience shows the check should be required, that becomes a v1.1 change documented in an ADR.

Rate limiting#

Only the login endpoint is rate-limited in v1. Per-actor REST/MCP limiting is planned — the user.rate_limit_per_minute column and DEFAULT_RATE_LIMIT_PER_MINUTE setting are reserved for that future middleware — but no other endpoint enforces a quota today.

Login limits#

Bucket Window
Per IP 5 attempts / 15 min, sliding window
Per username 5 attempts / 15 min, sliding window

A login that exceeds either bucket returns 429 rate_limit.exceeded with Retry-After: <seconds> and details.retry_after_seconds. No other endpoint emits Retry-After today, and the server does not currently emit X-RateLimit-Limit / Remaining / Reset headers on any response.

When per-actor REST/MCP limits ship, the plan is one bucket per actor shared across both transports; this page will be updated when it lands.

Resource identifiers#

  • Tickets: path params accept either a UUIDv7 (01HXKZ...) or a display id (PROJ-123). The router detects which by regex: a segment matching ^[A-Z][A-Z0-9]{1,9}-\d+$ is a display id; anything else is a UUID. Display ids are workspace-unique.
  • Wiki pages: the detail endpoint accepts the page's slug or its UUID. Slug resolution is scope-aware (pass ?project_id= to disambiguate).
  • Everything else: UUIDv7 only.
  • Generation: the server generates every id. Clients may supply a client_request_id field on creates for echo-back debugging; it is never written to the database.

Soft-delete semantics over HTTP#

For the soft-delete-aware entities (Ticket, WikiPage, Project, Comment — see ADR-0005):

  • DELETE /api/v1/.../{id} performs a soft delete and returns 204. Body is empty.
  • GET on a soft-deleted resource returns 410 resource.gone by default.
  • ?include_deleted=true on read endpoints surfaces soft-deleted rows (humans only).
  • There is no public restore endpoint and no public hard-purge endpoint in v1. Restoration is a CLI-only operation; purge is out of scope.

For hard-delete entities (StickyNote, Label, WorkflowState, TicketRelation, Watcher, Webhook, BotToken), DELETE is permanent and returns 204.

Endpoint catalog#

The tables below summarize the surface by resource group. The OpenAPI document carries the request and response schemas; this page is meant for scanning, not for code generation.

Access legend:

  • H — humans only.
  • H, B/R — humans and bots with read mode (or higher).
  • H, B/W — humans and bots with write mode.
  • anon — no auth required.

Auth#

Method Path Purpose Access
POST /auth/login Exchange username + password for a session cookie. anon
POST /auth/logout Invalidate the current session. H
GET /auth/me Return the current actor (human or bot). H, B/R
POST /auth/change-password Self-service password change (keeps the current session, revokes the others). Requires X-Requested-By: web. H

Users#

Method Path Purpose Access
GET /users/me Alias of /auth/me. H, B/R
GET /users List users in the workspace. H

Bots#

Method Path Purpose Access
GET /bots List bots with summary + active token metadata (no plaintext). H
POST /bots Create a bot. Returns the bot and its initial plaintext token, shown once. H
GET /bots/{id} Bot detail. H
PATCH /bots/{id} Update display name, mode, project allowlist, is_active. H
DELETE /bots/{id} Deactivate (sets is_active=false). No hard delete; tokens and audit history survive. H
POST /bots/{id}/tokens/rotate Issue a new token, deactivate the previous one. Plaintext returned once. H
POST /bots/{id}/tokens/revoke Revoke the active token without issuing a new one. H

Projects#

Method Path Purpose Access
GET /projects List projects visible to the actor. Bots see only allowlisted projects. H, B/R
POST /projects Create a project. Seeds default workflow states. H
GET /projects/{id} Project detail. H, B/R
PATCH /projects/{id} Update name, description, owner. key is immutable. H
DELETE /projects/{id} Soft-delete. Children remain queryable via ?include_deleted=true. H
GET /projects/{id}/workflow-states List workflow states ordered by position. H, B/R
POST /projects/{id}/workflow-states Create a new state at the end of the list. H
PATCH /projects/{id}/workflow-states/{state_id} Rename or recategorize a state. H
DELETE /projects/{id}/workflow-states/{state_id} Delete a state. Rejected if any non-deleted ticket references it. H
POST /projects/{id}/workflow-states/reorder Renumber all states atomically. H
GET /projects/{id}/labels List labels. H, B/R
POST /projects/{id}/labels Create a label. H
PATCH /projects/{id}/labels/{label_id} Rename or recolor. H
DELETE /projects/{id}/labels/{label_id} Hard-delete (removes from all tickets). H

Tickets#

Method Path Purpose Access
GET /tickets Cross-project listing with filters. H, B/R
GET /projects/{id}/tickets Same filters, project pre-scoped. H, B/R
POST /projects/{id}/tickets Create a ticket. Allocates ticket_number and display_id atomically. H, B/W
GET /tickets/{id_or_display_id} Fetch by UUID or PROJ-123. Returns ETag. H, B/R
PATCH /tickets/{id_or_display_id} Partial update. Excludes type, display_id, ticket_number, state_id. H, B/W
POST /tickets/{id_or_display_id}/transition Move to another workflow state, optionally with a comment. H, B/W
DELETE /tickets/{id_or_display_id} Soft-delete. Children are not cascaded. H, B/W
GET /tickets/{id_or_display_id}/relations List incoming + outgoing relations. H, B/R
POST /tickets/{id_or_display_id}/relations Create a typed edge. H, B/W
DELETE /tickets/{id_or_display_id}/relations/{relation_id} Remove an edge. H, B/W
GET /tickets/{id_or_display_id}/watchers List watchers. H, B/R
POST /tickets/{id_or_display_id}/watchers Add watcher. Defaults to the current actor; humans may add others. H, B/W
DELETE /tickets/{id_or_display_id}/watchers/{user_id} Remove a watcher. H, B/W

Ticket filters supported on GET /tickets and GET /projects/{id}/tickets (single-value, equality only): project_id (cross-project listing only), state_id, state_category, ticket_type (note: the query param is ticket_type, not type), assignee_user_id, reporter_user_id, label_id, priority, parent_ticket_id. There is no REST-side q, updated_since, due_before, due_after, or sort param in v1 — those live on the MCP list_tickets tool and on GET /search.

Comments#

Method Path Purpose Access
GET /tickets/{id_or_display_id}/comments List comments on a ticket, oldest first. H, B/R
POST /tickets/{id_or_display_id}/comments Add a comment. H, B/W
PATCH /comments/{id} Edit own comment. Appends previous body to edit_history. H, B/W
DELETE /comments/{id} Soft-delete; audit event captures full body + edit history. H, B/W

Wiki#

Method Path Purpose Access
GET /wiki/pages List wiki pages. project_id=null returns global pages. H, B/R
POST /wiki/pages Create a page. Parses wikilinks. H, B/W
GET /wiki/pages/{slug_or_id} Fetch by slug (preferred) or id. H, B/R
PATCH /wiki/pages/{id} Edit. Creates a wiki_revision snapshot. H, B/W
DELETE /wiki/pages/{id} Soft-delete. Revisions preserved. H, B/W
GET /wiki/pages/{id}/revisions List revisions newest first. H, B/R
GET /wiki/pages/{id}/revisions/{revision_id} Single revision detail. H, B/R
GET /wiki/pages/{id}/backlinks Pages whose body links to this page. H, B/R
GET /wiki/broken-links Workspace-wide list of unresolved [[wikilink]] targets. H, B/R

Sticky notes#

Method Path Purpose Access
GET /sticky-notes List the current actor's personal notes. Bots return an empty list. H, B/R
POST /sticky-notes Create a personal note. Bots get auth.forbidden. H
GET /projects/{id}/sticky-notes List project-scoped notes. H, B/R
POST /projects/{id}/sticky-notes Create a project-scoped note. H, B/W
PATCH /sticky-notes/{id} Update. Personal notes editable only by owner. H, B/W (project scope)
DELETE /sticky-notes/{id} Hard-delete. H, B/W (project scope)
POST /sticky-notes/{id}/convert-to-ticket Create a ticket from the note's body. H, B/W
Method Path Purpose Access
GET /search FTS across tickets and wiki pages. Mixed, ranked results. H, B/R

SearchHit is a discriminated union with type: "ticket" \| "wiki_page" for clean TypeScript narrowing.

Audit#

Method Path Purpose Access
GET /audit List audit events newest first (cursor-paginated). H
GET /audit/{id} Single event detail. H
GET /audit/tools List tool-invocation log rows for the workspace, newest first (cursor-paginated). H

GET /audit/tools is the source for the Audit log → Tools UI. The status filter accepts ok or error; the detailed status string is in the response rows. Pair with GET /audit?event_type=bot.tool_invoked&request_id=... to correlate against the audit stream.

Webhooks#

Method Path Purpose Access
GET /webhooks List webhooks. Secret never returned. H
POST /webhooks Create. Secret echoed once in the create response. H
GET /webhooks/{id} Detail. Secret not included. H
PATCH /webhooks/{id} Update url / event_types / is_active. ?rotate_secret=true rotates and returns the new secret once. H
DELETE /webhooks/{id} Hard delete. Past deliveries remain. H
GET /webhooks/{id}/deliveries Delivery log newest first. H
POST /webhooks/{id}/deliveries/{delivery_id}/retry Force an immediate retry. H

Worked examples#

Ticket creation (human)#

sequenceDiagram
    autonumber
    participant UI as Next.js UI
    participant R as FastAPI router<br/>POST /projects/{id}/tickets
    participant A as auth/dependencies
    participant S as ticket_service.create_ticket
    participant P as project_repo
    participant T as ticket_repo
    participant E as events.emitter

    UI->>R: POST /api/v1/projects/.../tickets<br/>cookie tb_session
    R->>A: resolve current_actor
    A-->>R: current_actor=human
    R->>S: create_ticket(actor, project_id, payload)
    S->>P: get + lock project FOR UPDATE
    P-->>S: project (next_ticket_number=42)
    S->>P: bump next_ticket_number → 43
    S->>T: insert ticket (number=42, display_id=PROJ-42)
    T-->>S: ticket
    S->>E: emit(ticket.created)
    E-->>S: queued (audit + webhook fan-out post-commit)
    S-->>R: ticket
    R-->>UI: 201 Created + Location: /api/v1/tickets/PROJ-42

Ticket creation (bot, project not in allowlist)#

sequenceDiagram
    autonumber
    participant B as Bot SDK
    participant R as FastAPI router
    participant A as auth/dependencies
    participant S as ticket_service.create_ticket

    B->>R: POST /api/v1/projects/.../tickets<br/>X-API-Key: tkb_...
    R->>A: resolve current_actor
    A-->>R: current_actor=bot (allowlist=[other_project])
    R->>S: create_ticket(actor, project_id, payload)
    S-->>R: raise Forbidden(reason=project_not_in_allowlist)
    R-->>B: 403 { error: { code: "auth.forbidden", details: { reason: "project_not_in_allowlist" } } }

Cross-references#

  • MCP server — the second transport over the same service layer.
  • OpenAPI spec — where the schema is served and how to point a code generator at it.
  • Auth and identity — sessions, bot tokens, CSRF, middleware ordering.
  • Audit log — the operator-facing UI for the audit_event stream.
  • Glossary — domain term definitions.