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
Sunsetheaders) 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-8for 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#
X-API-Keyheader 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.- Else
tb_sessioncookie present → resolve as human. - 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:
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 withauth.forbidden(details.reason = "project_not_in_allowlist").bot_mode ∈ {read, write}— write endpoints reject read-only bots withauth.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": "..." }
}
}
codeis a stable, machine-parseable identifier. Clients switch oncode, not onmessageor HTTP status alone.messageis for humans. It MAY be shown verbatim in the UI; it MUST NOT contain stack traces, raw SQL, or secrets.detailsis optional and event-specific. Its schema varies bycode.request_id(UUID) is echoed back as theX-Request-Idresponse 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#
limit— integer 1..200, default 50. Out of range producesvalidation.invalid_field.cursor— opaque base64url string. Omitted on the first page.
Response envelope#
{
"data": [ /* page items */ ],
"page": {
"next_cursor": "eyJ2IjoxLCJsYXN0X2lkIjoiMDE5OC4uLiIsLi4ufQ",
"has_more": true
}
}
next_cursorisnullwhenhas_moreisfalse. Clients stop paginating whenhas_moreisfalse.- 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_atand is parsed back into the query as a keyset offset. A malformed cursor returns400 validation.invalid_field(details.field = "cursor"). - The
/searchcursor additionally embeds afilter_hash. Mixing a/searchcursor with mismatched filter params returns400 validation.invalid_field(details.reason = "cursor_filter_mismatch"). No other list endpoint enforces this guard today. - Cursors are verified, not signed. Tampering produces
400, not401.
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 (includingLocation).
Semantics#
- New key → process normally; record fingerprint + response.
- Existing key, same request fingerprint → replay the stored response verbatim (status code, body, and
Locationheader). - 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
ETagheader. The value is a weak ETag of the formW/"<updated_at_unix_ms>-<short_id>". - PATCH and DELETE requests may include
If-Match: <etag>.If-Matchpresent and matches → proceed.If-Matchpresent and does not match →409 resource.conflict(details.reason = "etag_mismatch").If-Matchabsent → 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
slugor 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_idfield 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 returns204. Body is empty.- GET on a soft-deleted resource returns
410 resource.goneby default. ?include_deleted=trueon 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
readmode (or higher). - H, B/W — humans and bots with
writemode. - 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 |
Search#
| 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_eventstream. - Glossary — domain term definitions.