Clients & dashboard
POST/api/ai/improve
AI writing assistant — rewrite a draft message for clarity (any authed user)
Rewrites the draft HTML to read clearly/professionally while preserving meaning. Uses Claude when ANTHROPIC_API_KEY is set, otherwise Cloudflare Workers AI; the result is re-sanitised against the message allow-list. 503 if no AI provider is configured.
Responses
| Status | Description |
|---|
| 200 | { html } |
GET/api/clients/{clientSlug}
Client overview: name + active projects
Each project has { id, name, slug, isRoot }. Exactly one project per client is the root 'General' space (isRoot: true, listed first) — client-level threads/files live there; inbound email and Basecamp migrations land in it.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | Client + projects |
GET/api/clients/{clientSlug}/agreements/{submissionId}/pdf
Stream a signed agreement PDF (proxied from DocuSeal behind portal auth; tenancy-checked)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
submissionId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | PDF bytes |
| 404 | Unknown submission or not in this client's spaces |
| 502 | DocuSeal upstream unavailable |
GET/api/clients/{clientSlug}/dashboard
Everything-in-one-view dashboard payload
Recent messages + files (activity stream), pinned threads, unread thread IDs for the caller, support-hours burn-down per project (hidden unless the caller is a lead on a membership for this client, or staff-and-up; hoursLogged/hours-agreed figures are for that same audience — everyone else should render % spent, never raw hours), and federation summaries. Every project listing here excludes locked projects the caller hasn't been explicitly granted (membership_projects). Each stream message carries an attachments array ({ id, filename, content_type, size_bytes, url, thumbUrl }) for files attached to that message; stream.files is standalone Files-page uploads only — files attached to a message never appear there (no double-counting). federation.sprints has one entry per currently-active Jira sprint (a project can have more than one open at once) — { projectSlug, projectName, sprintId, name, startDate, endDate, counts, recentlyCompleted }. Fetch the full board for a card via GET .../projects/{projectSlug}/sprints/{sprintId}/board. newThreadEmail is the working inbound address for starting a new thread in this client's root "General" space by email — the bare {slug}@ address (routes via the portal.numiko.com catch-all Email Routing rule); null if EMAIL_REPLY_DOMAIN isn't configured.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | Dashboard aggregate |
GET/api/clients/{clientSlug}/documents
Client-visible Confluence pages + DocuSeal agreements (allow-listed, cached)
Doc entries are summaries (no bodyHtml) — fetch a single document for its content.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | { docs: [{ id, title, updatedAt, webUrl, projectName }], signedDocs: [{ id, title, status, signedAt, contractStart, contractEnd, downloadUrl }] } |
GET/api/clients/{clientSlug}/documents/{docId}
One client-visible Confluence page with its sanitized body (tenancy-checked against the client's own visible docs)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
docId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { doc: { id, title, updatedAt, webUrl, bodyHtml, projectName } } |
GET/api/clients/{clientSlug}/favicon
Favicon for the client's website domain (proxied + cached)
Fetches the icon for the client's mapped website domain (derived from domain_mappings), caches it in KV, and streams the bytes so client domains are never leaked to a third-party favicon service. 404 when the client has no mapped domain or none could be fetched. Also surfaced as client.faviconUrl in the client-home and dashboard payloads.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | Image bytes (png/jpeg/gif/webp/x-icon/svg) |
GET/api/clients/{clientSlug}/mentionable
Users who can be @mentioned / added as watchers for this client
Guests are excluded from the base list. Pass ?thread=<threadId> when composing inside an existing thread to also include guests watching that thread (isGuest: true) — guests are only mentionable there; the thread's own project is also used for roster filtering (see ?project). Pass ?project=<projectSlug> so a client/guest caller only sees staff/admin/super_admin rostered on that project (project_team) — a staff+ caller always sees every unrestricted staff account regardless (staff mentioning staff is never filtered; the roster only narrows what CLIENTS see). isNumiko is system_level >= staff.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
thread | query | string | | Thread ID — include that thread's guest watchers, and resolve its project for roster filtering |
project | query | string | | Project slug — roster-filters the staff shown to a non-staff caller |
Responses
| Status | Description |
|---|
| 200 | { users: [{ id, name, isNumiko, isGuest }] } |
GET/api/clients/{clientSlug}/my-threads
The caller's watched threads (the whole world for a single-thread guest)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | { threads } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/participant-candidates
Candidate participants for the participant picker (new-thread composer + ThreadView sidebar)
This client's members plus staff-and-up (same universe as /mentionable, roster-filtered to this project for a non-staff caller) with the extra fields the picker needs: email, avatar colour, and isClientLead/isProjectLead flags for default pre-selection. isClientLead is a client-side membership primary contact; isProjectLead is this project's project_team roster primary contact. Excludes the caller and guest accounts (guests are added by typing their email, which the picker also accepts as free text).
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { users: [{ id, name, email, color, isNumiko, isClientLead, isProjectLead }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/sprints/{sprintId}/board
Full board for one active sprint (client-facing substitute for Jira access)
To do / in progress / done columns of { key, summary, status, statusCategory, type } — no assignee, estimate, cost data or Jira link-out. Excludes internal-labelled and sub-task issues. Reads the same cache as the dashboard sprint cards (15-min cron refresh); 404 if the project or sprintId isn't a currently-active sprint on this client's project.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
sprintId | path | integer | required | Jira sprint id from a dashboard sprint card |
Responses
| Status | Description |
|---|
| 200 | { projectName, sprint: { name, startDate, endDate }, columns: { todo, inProgress, done } } |
GET/api/clients/{clientSlug}/support
Recent JSM support requests per project (cached, link-outs to help centre)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | { sections: [{ projectName, tickets: [{ key, summary, status, updatedAt, helpCentreUrl }] }] } |
GET/api/staff/clients
Staff sidebar client list: every active client (staff-and-up see all of them) with a personal pinned flag and lastActivityAt (for Pinned / Recently active / All clients grouping)
Responses
| Status | Description |
|---|
| 200 | { clients: [{ id, slug, name, pinned, lastActivityAt }] } |
PUT/api/staff/clients/{clientSlug}/pin
Pin a client to the caller's staff sidebar
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
DELETE/api/staff/clients/{clientSlug}/pin
Unpin a client from the caller's staff sidebar
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
GET/api/staff/dashboard
Staff root dashboard: combined activity feed across every active client (staff-and-up see all OPEN clients; locked/Private clients and locked projects excluded unless the caller holds a membership/grant)
Each stream message carries an attachments array ({ id, filename, content_type, size_bytes, url, thumbUrl }) for files attached to that message; stream.files is standalone Files-page uploads only. sidebar feeds the staff dashboard's right-column cards: activeClients (top ~6 by message count, last 14 days), awaitingReply (open threads whose newest message is from the client side, oldest-waiting first; when ANTHROPIC_API_KEY is configured, threads whose last client message is AI-classified as a closing remark such as 'thanks, all sorted' are filtered out — classification is cached per message, runs in the background never synchronously, and fails open so an unclassified message is never hidden), pendingDrafts (weekly AI-summary drafts awaiting PM publish/discard).
Responses
| Status | Description |
|---|
| 200 | { clients, stream: { messages (incl. mentioned flag, attachments), files }, sidebar: { activeClients: [{ id, slug, name, count }], awaitingReply: [{ thread_id, title, project_slug, project_name, client_slug, client_name, last_at }], pendingDrafts: [{ message_id, thread_id, created_at, thread_title, project_slug, project_name, client_slug, client_name }] } } |
Threads & messages
GET/api/clients/{clientSlug}/projects/{projectSlug}/jira/assignable
Assignable Jira users for the project (async assignee picker; staff)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
q | query | string | | |
Responses
| Status | Description |
|---|
| 200 | { users: [{ accountId, displayName }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/jira/fields
Create fields for a project + issue type — drives the dynamic task form (staff)
Normalised Jira createmeta fields: { fieldId, name, required, kind, allowedValues }. kind ∈ string|text|option|user|sprint|labels|array-option|number|date. Summary/description/project/issuetype are handled by the form and omitted.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
issueType | query | string | required | |
Responses
| Status | Description |
|---|
| 200 | { fields: JiraFieldMeta[] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/jira/issue-types
Jira issue types for the project's mapped Jira project (Numiko staff only)
From Jira createmeta. 404 if no jira_project_key mapped or the caller isn't staff.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { projectKey, issueTypes: [{ id, name, iconUrl }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/jira/issues
Search the project's Jira issues (comment-on-existing picker; staff)
Key-shaped queries (CHAT-142 or a bare number) resolve by key; otherwise a summary contains-match, newest first. Scoped to the project's mapped Jira project.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
q | query | string | | |
Responses
| Status | Description |
|---|
| 200 | { issues: [{ key, summary }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/jira/sprints
Active + future sprints on the project's board (for the Sprint field; staff)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { sprints: [{ id, name, state }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/threads
List threads (message counts, unread flag, opening/latest excerpts, participants) — paginated, newest 50 + pinned first
Each thread carries message_count, unread, and preview fields for the two-column card: first_excerpt (opening post), last_excerpt + last_message_id + last_author_name + last_at (latest reply), plus participants: [{ id, name, isNumiko, isGuest, email? }] (thread watchers, for the list's avatar stack). First page returns pinned threads plus the 50 most recently updated; when hasMore is true, pass the last thread's updated_at/id as ?before/?beforeId for the next page. Search covers all threads regardless of paging. newThreadEmail is the working inbound address for starting a thread here by email — non-null only for the client's root "General" project (the bare {slug}@ address), where inbound email actually lands.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
before | query | string | | updated_at cursor from the previous page's last thread |
beforeId | query | string | | id cursor (tie-break) from the previous page's last thread |
Responses
| Status | Description |
|---|
| 200 | { project, threads, newThreadEmail, hasMore } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads
Create a thread with its first message
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Request body required
application/json
title string required bodyHtml string required Sanitized server-side. Mention markup + inline <img> to portal file URLs allowed.attachmentIds string[] IDs from the file upload endpoint
Responses
| Status | Description |
|---|
| 201 | { id: threadId } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}
Thread detail: messages (HTML + plain text + attachments) and watchers. Marks the thread read for the caller.
Returns the newest 100 messages ascending plus hasMoreOlder; pass ?before=<created_at> to page backwards. Each message carries author_color, edited_at and deleted_at (deleted messages return blanked body + no attachments).
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
before | query | string | | ISO created_at cursor; returns the 100 messages older than it. |
Responses
| Status | Description |
|---|
| 200 | { thread, messages, hasMoreOlder, watchers } |
DELETE/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}
Permanently delete a thread and all its content (Numiko staff only)
Irreversibly removes the thread with its messages, attached files (including stored objects), watchers and notifications, and drops it from search. For threads created by mistake — audit-logged. 403 for non-staff callers.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { ok: true, messages: deletedMessageCount, files: deletedFileCount } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/ai/draft-reply
Draft an initial agency reply from the thread + linked Jira issues (staff only)
Sends the thread transcript and the status of any linked/mentioned Jira issues to Claude and returns a sanitised HTML draft of the agency's next reply, to seed the composer. Never posts anything. Gated three ways: Numiko staff only (404 otherwise), the client's per-client AI opt-in (clients.ai_summaries_enabled — 403 with { error: 'ai_disabled' } when off), and ANTHROPIC_API_KEY configured (503 otherwise). Shares the per-user hourly AI cap with /api/ai/improve (429 when exceeded).
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { html } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/issues
Jira/JSM issues referenced in the thread, with live status
Parses issue keys (e.g. SUP-142) from the thread's messages and resolves each to { key, summary, status, statusCategory, type, url }. Client viewers only see issues from the project's own Jira project and never internal-labelled ones; Numiko staff see any referenced key. Best-effort — empty on source failure.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { issues: LinkedIssue[] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/link-previews
oEmbed rich previews for allow-listed links in the thread's messages
Scans the thread's message bodies for allow-listed provider links (Figma, YouTube, Vimeo, Loom) and returns normalised oEmbed metadata { url, provider, type, title, authorName, thumbnailUrl, embedUrl, aspectRatio } keyed by message id. Only structured metadata + a self-constructed embed URL are returned — never the provider's raw oEmbed html. Same visibility gating as /issues; best-effort and KV-cached.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { previews: [{ messageId, preview: LinkPreview }] } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages
Reply to a thread
Fans out notifications: in-app for all watchers, email per prefs (mentions cut through digest mode), Slack tag for mentioned Numiko staff.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Request body required
application/json
bodyHtml string attachmentIds string[]
Responses
| Status | Description |
|---|
| 201 | { id: messageId } |
PATCH/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages/{messageId}
Edit a message (author only, portal-composed messages)
Replaces the body (bodyHtml, sanitized server-side) and stamps edited_at. Only the author may edit, and only 'web'-sourced messages (409 for Slack/email-synced ones). If the message was mirrored to Slack, the Slack copy is chat.update-d in place.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
messageId | path | string | required | |
DELETE/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages/{messageId}
Delete a message (author removes own; Numiko staff moderate any)
Published messages are soft-deleted (row kept, body cleared, removed from search, attachments hidden, tombstone shown). Unpublished AI drafts are hard-deleted (staff only); discarding a thread's only message also deletes the now-empty thread (threadDeleted: true in the response). Staff deleting another user's message is recorded in the audit log.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
messageId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { ok: true, threadDeleted?: true } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages/{messageId}/jira-task
Create a Jira task from a message (Numiko staff only)
Creates an issue via the service account with summary + an ADF description (message text + a link back to the message). fields carries the dynamic form's Jira-native field values (priority/assignee/sprint/labels/…); project/issuetype/summary/description are set server-side. clientVisible=false adds the internal label. Links the created key to the message so it shows on the message + in the thread's Linked issues.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
messageId | path | string | required | |
Request body required
application/json
issueTypeId string required summary string required description string fields object Jira-native extra field values keyed by fieldIdclientVisible boolean
Responses
| Status | Description |
|---|
| 201 | { key, url } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages/{messageId}/publish
Publish a draft AI weekly summary to the client (Numiko staff only)
Drafts are generated by the Friday cron (source=ai_summary, draft=1) and invisible to clients until published. Publishing fans out notifications. DELETE the same path (without /publish) discards a draft.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
messageId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | ok (403 for client users) |
PATCH/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages/{messageId}/task
Toggle a task-list checkbox in a message (persistent shared checklist)
Flips the Nth task item (document order) and re-sanitizes the stored HTML. Any user with access to the thread may tick items (same permission level as reacting). Ticking stamps who completed it and when (data-done-by/-name/-at); unchecking clears the stamp. Does not mark the message edited.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
messageId | path | string | required | |
Request body required
application/json
index integer checked boolean
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/watchers
Add a watcher by email (creates a guest account if the email is unknown)
Client users and Numiko staff only (guests can't manage watchers). Unknown emails become a guest who sees exactly the threads they're added to — guests may be added to threads across projects and clients. Responds { ok: true, added: boolean }; when the email belongs to an existing account that can't be given thread access here (a client-level user of another client — who needs a membership, not a watcher row — or an archived account), a STAFF caller gets added:false with a reason ('other_client' | 'archived'), while a non-staff (client) caller gets added:true so the endpoint can't be used to probe account existence. Rate-limited per caller.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { ok, added, reason? } |
PUT/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/watchers/{userId}
Add a watcher (any user with access to this client)
Guests may only target themselves (self-add to a thread they can already see — the 'join thread' affordance); they can't add anyone else. Broadcasts a live 'thread' event.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
userId | path | string | required | |
DELETE/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/watchers/{userId}
Remove a watcher
Guests may only remove themselves (leave a thread); anyone else who can add participants may also remove them. Broadcasts a live 'thread' event.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
userId | path | string | required | |
GET/api/me/threads
A guest's watched threads across all clients (guests only; 404 for other roles)
Responses
| Status | Description |
|---|
| 200 | { threads: [{ id, title, updated_at, status, project_slug, project_name, client_slug, client_name }] } |
Files & uploads
PATCH/api/clients/{clientSlug}/files/{fileId}
Move a file between folders (folderId: null → root)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
GET/api/clients/{clientSlug}/files/{fileId}/raw
Download/stream file content (tenancy-checked)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
download | query | boolean | | Force Content-Disposition: attachment |
Responses
| Status | Description |
|---|
| 200 | Binary content |
POST/api/clients/{clientSlug}/files/{fileId}/text
Submit extracted PDF text for search indexing (client-side pdfjs extraction)
text/plain body, max 200k chars. PDFs only; first writer wins (409 once set). The text feeds the FTS index so PDF content is searchable.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { ok: true } |
GET/api/clients/{clientSlug}/files/{fileId}/thumb
JPEG thumbnail (max 480px edge) for image files
Generated server-side (eagerly on upload, lazily otherwise). 404 for non-thumbable types — fall back to /raw or a type icon.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | image/jpeg bytes |
DELETE/api/clients/{clientSlug}/folders/{folderId}
Delete an empty folder
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
folderId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | ok (409 if not empty) |
GET/api/clients/{clientSlug}/projects/{projectSlug}/files
Folder-aware file listing
Each file has threadId and, when it was attached to a specific message (thread share / email attachment), messageId — use both to deep-link to /{clientSlug}/{projectSlug}/messages/{threadId}#msg-{messageId}.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
folder | query | string | | Folder ID; omit for project root |
Responses
| Status | Description |
|---|
| 200 | { folderId, breadcrumbs, folders, files } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/files
Upload a file (multipart/form-data: file, optional folderId)
Returns { id, url, isImage }. Reference the url in an <img> for inline embeds, or pass the id in attachmentIds when posting a message.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Request body required
multipart/form-data
file string (binary) folderId string
Responses
| Status | Description |
|---|
| 201 | Uploaded file descriptor |
POST/api/clients/{clientSlug}/projects/{projectSlug}/folders
Create a folder (optionally nested via parentId)
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Request body required
application/json
name string required parentId string nullable
Responses
| Status | Description |
|---|
| 201 | { id, name, parentId } |
POST/api/clients/{clientSlug}/projects/{projectSlug}/uploads
Start a chunked (R2 multipart) upload for a large file
For files bigger than a single request can carry (>50 MB, up to 10 GB). Returns { fileId, partSize }: split the file into partSize-byte parts, PUT each to /uploads/{fileId}/parts/{n}, then POST /uploads/{fileId}/complete with the collected { partNumber, etag } list. Small files should use the direct multipart/form-data upload instead.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Request body required
application/json
filename string required contentType string size integer required folderId string nullable origin string ("files" | "editor")
Responses
| Status | Description |
|---|
| 201 | { fileId, partSize, maxBytes } |
DELETE/api/clients/{clientSlug}/uploads/{fileId}
Abort a half-finished chunked upload
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { ok: true } |
POST/api/clients/{clientSlug}/uploads/{fileId}/complete
Finish a chunked upload — assemble parts into the file
Re-checks live project access before the file becomes real. Returns the same descriptor as the direct upload ({ id, url, isImage }).
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
Request body required
application/json
parts object[] required partNumber integer required etag string required
Responses
| Status | Description |
|---|
| 201 | Uploaded file descriptor |
PUT/api/clients/{clientSlug}/uploads/{fileId}/parts/{partNumber}
Upload one part of a chunked upload (raw body)
1-based partNumber. Each part must be ≤50 MB; all but the final part must be ≥5 MB (R2 rule). Returns { partNumber, etag } — keep the etag for /complete.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
partNumber | path | integer | required | |
Responses
| Status | Description |
|---|
| 200 | { partNumber, etag } |