Developers

API reference

70 endpoints across the integration surface — auth and API tokens, clients and dashboards, threads and messages, files, search and notifications. Generated straight from the same OpenAPI document the portal serves at /api/openapi.json, so it never drifts from what's actually deployed.

API reference

Everything the app can do, the API can do

Team Portal doesn't keep a second, cut-down API around for integrators — the endpoints below are the same ones the web app itself calls. If a feature exists in the portal, it exists here too.

Authentication

Create a personal API token from the portal's Account page, then send it as Authorization: Bearer nkp_… on every request. A token carries exactly its creating user's own permissions — nothing more — and is organisation-scoped in precisely the same way a browser session is. There's no separate service-account tier: an integration sees what the person who made the token can see, and nothing else.

Machine-readable spec & MCP

The full OpenAPI document behind this page is served live at GET /api/openapi.json — point tooling at it directly rather than parsing this HTML. This page documents the integration surface: workspace administration (billing, user and organisation management) is driven from the app itself and deliberately not part of the public reference, though the live spec describes those operations too — every call is permission-checked against the calling token regardless. For AI agents and assistants, an MCP server is available at /mcp (OAuth-authenticated): it exposes the same operations as tools, re-checking every permission gate on each call, so an agent can never do more than the person who authorised it.

Rate limits

Magic-link sign-in requests are rate-limited (currently five per hour per email) to stop them being used to spam an inbox. Nothing else on this API enforces a hard quota today, but please be a good citizen: cache what you can, avoid polling in a tight loop, and prefer the live/notification surfaces over repeated full refetches where one exists.

Authentication & accounts

GET/api/auth/me

Current identity (user, role, client slug)

Responses

StatusDescription
200User object
GET/api/auth/profile

The caller's own editable profile

Responses

StatusDescription
200{ name, color, jobTitle, bio, location, company }
PATCH/api/auth/profile

Update the caller's own profile (presentation + contact fields only)

Self-service editing. Accepts any subset of name, color (#rrggbb), jobTitle, bio, location, company; an empty string clears a field (name must stay non-empty). Never touches system level, status, email or client — those are admin-only.

Request body

application/json
  • name string
  • color string nullable Hex #rrggbb; null/empty clears the override
  • jobTitle string nullable
  • bio string nullable
  • location string nullable
  • company string nullable Free-text company/organisation label

Responses

StatusDescription
200Updated profile
GET/api/auth/push/public-key

VAPID public key for browser Web Push subscription

Pass as applicationServerKey to PushManager.subscribe(). Safe to expose — it's the public half of the server's VAPID keypair.

Responses

StatusDescription
200{ publicKey }
POST/api/auth/push/subscribe

Register (or refresh) a browser push subscription for the caller

Body is the raw PushSubscription JSON from the browser's Push API (endpoint + keys.p256dh + keys.auth). Upserted by endpoint, so re-subscribing the same device is idempotent.

Request body required

application/json
  • endpoint string required
  • keys object required
    • p256dh string required
    • auth string required

Responses

StatusDescription
201{ ok: true }
DELETE/api/auth/push/subscribe

Remove a browser push subscription for the caller (e.g. when disabling notifications on a device)

Request body required

application/json
  • endpoint string required

Responses

StatusDescription
200{ ok: true }
GET/api/auth/tokens

List the caller's active API tokens

Responses

StatusDescription
200Token metadata (no secrets)
POST/api/auth/tokens

Create an API token (plaintext returned once)

Request body required

application/json
  • name string required

Responses

StatusDescription
201{ id, name, token }
DELETE/api/auth/tokens/{tokenId}

Revoke an API token

Parameters

NameInTypeDescription
tokenIdpathstringrequired

Responses

StatusDescription
200Revoked
GET/api/users/{userId}/card

Public-facing profile card for the hover popover on a user's initials

Any signed-in user may read any user's card. availability is non-null only when the user is on Float-synced leave today ({ status: 'out', until, type }).

Parameters

NameInTypeDescription
userIdpathstringrequired

Responses

StatusDescription
200{ id, name, systemLevel, color, jobTitle, company, bio, location, isNumiko, availability }

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.

Request body required

application/json
  • html string required

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200Client + projects
GET/api/clients/{clientSlug}/agreements/{submissionId}/pdf

Stream a signed agreement PDF (proxied from DocuSeal behind portal auth; tenancy-checked)

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
submissionIdpathstringrequired

Responses

StatusDescription
200PDF bytes
404Unknown submission or not in this client's spaces
502DocuSeal 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200Dashboard 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
docIdpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200Image 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
threadquerystringThread ID — include that thread's guest watchers, and resolve its project for roster filtering
projectquerystringProject slug — roster-filters the staff shown to a non-staff caller

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
sprintIdpathintegerrequiredJira sprint id from a dashboard sprint card

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
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

StatusDescription
200{ clients: [{ id, slug, name, pinned, lastActivityAt }] }
PUT/api/staff/clients/{clientSlug}/pin

Pin a client to the caller's staff sidebar

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200ok
DELETE/api/staff/clients/{clientSlug}/pin

Unpin a client from the caller's staff sidebar

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200ok
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

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
qquerystring

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
issueTypequerystringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
qquerystring

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
beforequerystringupdated_at cursor from the previous page's last thread
beforeIdquerystringid cursor (tie-break) from the previous page's last thread

Responses

StatusDescription
200{ project, threads, newThreadEmail, hasMore }
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads

Create a thread with its first message

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

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

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
beforequerystringISO created_at cursor; returns the 100 messages older than it.

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired

Responses

StatusDescription
200{ issues: LinkedIssue[] }
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired

Request body required

application/json
  • bodyHtml string
  • attachmentIds string[]

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Request body required

application/json
  • bodyHtml string required

Responses

StatusDescription
200ok
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Responses

StatusDescription
200{ ok: true, threadDeleted?: true }
POST/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/messages/{messageId}/jira-comment

Add a message to an existing Jira issue as a comment (Numiko staff only)

Posts the message (or edited comment HTML) as an ADF comment on issueKey via the service account, with a link back to the message. The issue must belong to the project's mapped Jira project. Links the key to the message like jira-task does.

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Request body required

application/json
  • issueKey string required Target issue key, e.g. CHAT-142
  • comment string Comment HTML; defaults to the message body

Responses

StatusDescription
201{ key, url }
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Request body required

application/json
  • issueTypeId string required
  • summary string required
  • description string
  • fields object Jira-native extra field values keyed by fieldId
  • clientVisible boolean

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Responses

StatusDescription
200ok (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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Request body required

application/json
  • index integer
  • checked boolean

Responses

StatusDescription
200ok
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired

Request body required

application/json
  • email string (email)

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
userIdpathstringrequired

Responses

StatusDescription
200ok
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
userIdpathstringrequired

Responses

StatusDescription
200ok
GET/api/me/threads

A guest's watched threads across all clients (guests only; 404 for other roles)

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired

Request body required

application/json
  • folderId string nullable

Responses

StatusDescription
200ok
GET/api/clients/{clientSlug}/files/{fileId}/raw

Download/stream file content (tenancy-checked)

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired
downloadquerybooleanForce Content-Disposition: attachment

Responses

StatusDescription
200Binary 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired

Request body required

text/plain
  • string

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired

Responses

StatusDescription
200image/jpeg bytes
DELETE/api/clients/{clientSlug}/folders/{folderId}

Delete an empty folder

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
folderIdpathstringrequired

Responses

StatusDescription
200ok (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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
folderquerystringFolder ID; omit for project root

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

Request body required

multipart/form-data
  • file string (binary)
  • folderId string

Responses

StatusDescription
201Uploaded file descriptor
POST/api/clients/{clientSlug}/projects/{projectSlug}/folders

Create a folder (optionally nested via parentId)

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

Request body required

application/json
  • name string required
  • parentId string nullable

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
projectSlugpathstringrequired

Request body required

application/json
  • filename string required
  • contentType string
  • size integer required
  • folderId string nullable
  • origin string ("files" | "editor")

Responses

StatusDescription
201{ fileId, partSize, maxBytes }
DELETE/api/clients/{clientSlug}/uploads/{fileId}

Abort a half-finished chunked upload

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired

Request body required

application/json
  • parts object[] required
    • partNumber integer required
    • etag string required

Responses

StatusDescription
201Uploaded 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.
fileIdpathstringrequired
partNumberpathintegerrequired

Request body required

application/octet-stream
  • string (binary)

Responses

StatusDescription
200{ partNumber, etag }

Notifications

GET/api/clients/{clientSlug}/notifications

Caller's in-app notifications for this client (latest 30 + unread count)

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200Notifications
POST/api/clients/{clientSlug}/notifications/read

Mark all of the caller's notifications for this client read

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'chatham-house'. Access is enforced from the caller's identity.

Responses

StatusDescription
200ok
GET/api/me/notifications

A guest's in-app notifications across all clients (guests only; 404 for other roles)

Responses

StatusDescription
200{ notifications: [...with client_slug], unreadCount }
POST/api/me/notifications/read

Mark all of a guest's notifications read, across all clients (guests only; 404 for other roles)

Responses

StatusDescription
200ok
GET/api/staff/notifications

Cross-client in-app notifications for staff (every active client; locked/Private-client and locked-project threads excluded unless membership/grant held) — staff-and-up only

Responses

StatusDescription
200{ notifications: [...with client_slug], unreadCount }
POST/api/staff/notifications/read

Mark all of the caller's notifications read across accessible clients (Numiko staff only)

Responses

StatusDescription
200ok