Authentication & accounts
GET/api/auth/availability
The caller's self-service availability (away periods, non-working days, working hours, timezone)
away lists the caller's current-and-upcoming manual away periods (start-date order; lapsed ones drop off); nonWorkingDays are JS weekday numbers (0=Sunday..6=Saturday) the caller doesn't work. workingHours (24h HH:MM, the caller's own local time) and timezone (IANA) are presentation-only — shown on the profile card, never part of the OUT computation. This is how client users get an OUT status — staff leave also arrives from the Float sync, which is read-only here.
Responses
| Status | Description |
|---|
| 200 | { away: [{ startDate, endDate, type }], nonWorkingDays: [0..6], workingHours: { start, end } | null, timezone } |
PUT/api/auth/availability
Update the caller's availability (applies across all their workspaces)
Send any subset of keys; each provided key replaces that setting wholesale. away replaces the manual away-period list (max 20; dates YYYY-MM-DD inclusive, endDate >= startDate per period). nonWorkingDays replaces the weekly pattern (unique JS weekday numbers; at most 6 — at least one working day required). workingHours is both HH:MM times or null; timezone is a valid IANA zone name or null. Everything validates before anything is written. Float-synced leave is unaffected. Availability describes the person, not the role: the update is applied to every workspace account the caller's address holds (multi-workspace identities), not just the one making the call.
Request body required
application/json
away object[] startDate string required endDate string required type string,null
nonWorkingDays integer[] workingHours object,null timezone string,null
Responses
| Status | Description |
|---|
| 200 | The updated availability (same shape as GET) |
PUT/api/auth/avatar
Set the caller's own profile image
RAW image bytes as the request body (not multipart), with Content-Type image/png, image/jpeg or image/webp. Max 512 KB and 1024x1024; the bytes must really be the declared format (the header is parsed) and a square, downscaled image is expected — the SPA crops/resizes to 256px before uploading. Errors: unsupported_type (415), empty_body / unreadable_image (400), too_large / too_large_dimensions (413).
Responses
| Status | Description |
|---|
| 200 | { avatarUrl, avatarSource: 'upload' } |
DELETE/api/auth/avatar
Remove the caller's own profile image
Reverts them to coloured initials. Removal is a permanent OVERRIDE: the automatic Slack pass never repopulates this account afterwards (it records the removal, separately from the last-checked stamp). Uploading a photo or calling POST /api/auth/avatar/slack opts back in.
Responses
| Status | Description |
|---|
| 200 | { avatarUrl: null, avatarSource: null } |
POST/api/auth/avatar/slack
Set the caller's profile image from their Slack profile photo
Looks the caller up in their organisation's connected Slack workspace (by stored member id, else by email) and copies their CUSTOM profile photo to our own storage — a Slack-generated default avatar counts as no photo. This is an OVERRIDE of the automatic pass, which already does the same thing for every account on a weekly cycle: it re-fetches over an existing image, works after a removal, and doesn't wait for the cycle. 404 no_slack_photo when there's no connected workspace, no matching member, or no custom photo. The profile payload's canUseSlackPhoto says whether the workspace is connected at all.
Responses
| Status | Description |
|---|
| 200 | { avatarUrl, avatarSource: 'slack' } |
GET/api/auth/me
Current identity (user, role, client slug)
Responses
| Status | Description |
|---|
| 200 | User object |
GET/api/auth/profile
The caller's own editable profile
Responses
| Status | Description |
|---|
| 200 | { name, color, jobTitle, bio, location, company, avatarUrl, avatarSource, canUseSlackPhoto } |
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 overridejobTitle string nullable bio string nullable location string nullable company string nullable Free-text company/organisation label
Responses
| Status | Description |
|---|
| 200 | Updated 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
| Status | Description |
|---|
| 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
| Status | Description |
|---|
| 201 | { ok: true } |
DELETE/api/auth/push/subscribe
Remove a browser push subscription for the caller (e.g. when disabling notifications on a device)
Responses
| Status | Description |
|---|
| 200 | { ok: true } |
GET/api/auth/tokens
List the caller's active API tokens (staff and above — client/guest accounts have no API access)
Responses
| Status | Description |
|---|
| 200 | Token metadata (no secrets) |
POST/api/auth/tokens
Create an API token (plaintext returned once; staff and above)
Responses
| Status | Description |
|---|
| 201 | { id, name, token } |
DELETE/api/auth/tokens/{tokenId}
Revoke an API token
Parameters
| Name | In | Type | | Description |
|---|
tokenId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | Revoked |
GET/api/auth/workspaces
List the caller's workspaces (multi-workspace identities)
One entry per organisation where the caller's verified email address holds an active account, current workspace first. Most addresses have exactly one. Switching between them is a browser session flow (the sign-in email offers a link per workspace; a signed-in session can also switch in-app) — API tokens are always pinned to the single account that minted them.
Responses
| Status | Description |
|---|
| 200 | { workspaces: [{ organisationId, name, slug, current (boolean) }] } |
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, back }) — back is the day they return, see /api/users/out-today.
Parameters
| Name | In | Type | | Description |
|---|
userId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { id, name, systemLevel, color, jobTitle, company, bio, location, isStaff, avatarUrl, availability, workingHours: { start, end } | null, timezone } |
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/branding/icon
The signed-in user's organisation square icon image (favicon/apple-touch)
Streams the custom icon uploaded on /api/admin/branding/icon for the caller's own organisation. 404 when the organisation has no custom icon. Always image/png.
Responses
| Status | Description |
|---|
| 200 | The icon image bytes (image/png) |
| 404 | not_found |
GET/api/branding/icon.svg
The signed-in user's organisation adaptive SVG browser-tab favicon
Streams the dedicated tab favicon uploaded on /api/admin/branding/icon.svg for the caller's own organisation — an adaptive SVG that is transparent in dark mode and gains a dark background in light mode (matching the root favicon). 404 when unset. Served image/svg+xml with a sandboxing CSP.
Responses
| Status | Description |
|---|
| 200 | The favicon (image/svg+xml) |
| 404 | not_found |
GET/api/branding/logo
The signed-in user's organisation logo image
Streams the custom logo uploaded on /api/admin/branding/logo for the caller's own organisation. 404 when the organisation has no custom logo.
Responses
| Status | Description |
|---|
| 200 | The logo image bytes |
| 404 | not_found |
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. 'acme-council'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | Client + projects |
GET/api/clients/{clientSlug}/activity-days
Per-day activity counts for one month of this client's feed, for the jump-to-date calendar
The client-scoped twin of GET /api/staff/activity-days: same shape, same rule that the counts match the feed (so a client-tier caller's counts exclude internal threads, files and ticket activity exactly as their feed does). Takes the same project and filter=mentions narrowing as GET /api/clients/{clientSlug}/stream.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
month | query | string | required | The month to count, YYYY-MM. Anything else is a 400 rather than a guessed month. |
project | query | string | | Project slug within this client, or 'all' (the default). Content in the root 'General' space shows under 'all'. |
filter | query | string ("mentions") | | 'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely) |
Responses
| Status | Description |
|---|
| 200 | { days: { [day: string]: number }, earliest: string | null } |
GET/api/clients/{clientSlug}/agreements/{submissionId}/pdf
Stream a signed agreement PDF (proxied from DocuSeal behind portal auth; tenancy-checked)
Served inline (the SPA previews it in the lightbox); add ?download for an attachment disposition. Same contract as the file raw/thumb routes.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
submissionId | path | string | required | |
download | query | string | | Present = Content-Disposition attachment |
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
Page 1 of the activity feed, pinned threads, unread thread IDs for the caller, support-hours burn-down per project (hidden unless the caller holds a lead-role team grant for this client, or is staff-and-up; hoursLogged/hours-agreed figures are for that same audience — everyone else should render % spent, never raw hours), and federation summaries. A burn-down's phases are only the ones running TODAY; its history carries the wider position — { overspentPhases, endedBudgetFee/SpentFee/PctSpent, totalBudgetFee/SpentFee/PctSpent } — and is NULL unless there is something to flag (an ended phase over its own budget, or the project past its total). A null history therefore means "on budget so far", not "unknown"; it is not configurable per project. Every project listing here excludes locked projects the caller hasn't been explicitly given a project-scope team grant for. stream is page 1 of GET /api/clients/{clientSlug}/stream - same query parameters (project, filter=mentions), same { items, nextCursor } shape; pass nextCursor to that endpoint for page 2 onwards rather than refetching this payload. 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 teamportal.site catch-all Email Routing rule; a project's own address is {slug}+{project.slug}@, see the per-project thread list); null if EMAIL_REPLY_DOMAIN isn't configured.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
cursor | query | string | | nextCursor from the previous page; omit for the first page |
from | query | string | | Jump to a date, YYYY-MM-DD: start the page at the END of that day and read backwards from there. Seeds page 1 only (a request carrying cursor is already past it), so it composes with normal pagination. Malformed = 400. |
project | query | string | | Project slug within this client, or 'all' (the default). Content in the root 'General' space shows under 'all'. |
filter | query | string ("mentions") | | 'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely) |
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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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 holding a project-scope team grant on that project — a staff+ caller always sees every unrestricted staff account regardless (staff mentioning staff is never filtered; the roster only narrows what CLIENTS see). isStaff is system_level >= staff.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. 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, isStaff, 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. 'acme-council'. 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, avatarUrl (the profile photo, since every row here names its person), the lead badges and isDefaultParticipant. isClientLead / isProjectLead are the lead role per SIDE (client-side vs staff-side) and count a lead grant at EITHER scope — the person's own project-scope grant on this project, or a client-scope one cascading down. isDefaultParticipant is what a NEW thread would actually default to, decided by the same rule thread-create uses to add watchers (most specific lead wins per side, so a project's own lead overrides the client-wide one for that side) — clients pre-select on this flag and must never re-derive it from the badges. 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. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { users: [{ id, name, email, color, isStaff, isClientLead, isProjectLead, isDefaultParticipant }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/phase-timeline
Float phase timeline for one project (the burn-down card's click-through gantt)
Every phase of every Float project linked to this portal project — past, current and future, so overlapping phases are visible (the dashboard burn-down only carries the phases running today). Returns { projectName, phases: [{ name, startDate, endDate, budgetFee, sourceName }] } ordered by start date; sourceName is the owning Float project's name (a portal project may link several ids, comma-separated in projects.float_project_id). Fee-side only, same financials gate as the burn-down itself — staff-and-up always; on the client side the PROJECT must have budget_visible_to_leads set AND the caller must be one of its primary contacts (a client-scope lead grant covers every project of the client, a project-scope one covers that project). 404 otherwise, and likewise for a project with no Float project linked or an unknown/foreign project slug.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { projectName, phases } |
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, priority } - 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. 'acme-council'. 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}/stream
One page of this client's activity feed: messages, shared files, updated documents, Jira/JSM ticket activity and message reactions, newest first
The same keyset-paginated helper as GET /api/staff/stream (see there for the cursor contract and the item shapes), pinned to this one client and with the CLIENT-VISIBLE rail applied: a client-tier caller never sees an internal thread's messages or reactions, an internal file, or internal ticket activity - a staff caller sees all of it. Locked projects still need an explicit project-scope grant. One extra item kind here: { kind: 'doc', at, doc: { id, title, updatedAt, projectSlug, projectName } } for a client-visible Confluence page updated (federated, portal-labelled pages only). Items carry no client_slug/client_name - the payload is already one client. Both filters are applied server-side, so a page is always full until the stream genuinely ends.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
cursor | query | string | | nextCursor from the previous page; omit for the first page |
from | query | string | | Jump to a date, YYYY-MM-DD: start the page at the END of that day and read backwards from there. Seeds page 1 only (a request carrying cursor is already past it), so it composes with normal pagination. Malformed = 400. |
project | query | string | | Project slug within this client, or 'all' (the default). Content in the root 'General' space shows under 'all'. |
filter | query | string ("mentions") | | 'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely) |
Responses
| Status | Description |
|---|
| 200 | { items: [{ kind, at, message|file|doc|activity|reaction }], nextCursor: string | null } |
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. 'acme-council'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | { sections: [{ projectName, tickets: [{ key, summary, status, updatedAt, helpCentreUrl }] }] } |
GET/api/clients/{clientSlug}/team
The people on this client's account (dashboard team strip)
Everyone holding a team grant on the client (client-scope, or a project-scope grant on any of its active projects) merged in. isClientLead = a client-scope grant with role 'lead', whichever side the holder is on (staff hold these too — the client's lead contact at the agency); projects[].isLead = that project's grant role 'lead'. out mirrors /api/users/out-today for the same person, back included (null unless out today). Rosters of locked projects the caller can't access are omitted. Not available to thread guests.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
Responses
| Status | Description |
|---|
| 200 | { members: [{ id, name, color, avatarUrl, company, jobTitle, isStaff, isClientLead, projects: [{ slug, name, isLead }], out }] } |
GET/api/me/topic-subscriptions
The caller's broadcast topics per client, and which they have opted into
Client-tier callers only (staff and guests get an empty list). One entry per active client the caller holds a grant at: the client's topics as a tree (ancestors included so headings render; offered marks the ones the client actually has), isLead when the caller is a primary contact there (a lead grant — they then receive every topic and the opt-ins are moot), and subscribedTopicIds otherwise.
Responses
| Status | Description |
|---|
| 200 | { clients: [{ id, slug, name, isLead, topics: [{ id, parentId, name, description, offered }], subscribedTopicIds }] } |
PUT/api/me/topic-subscriptions/{clientId}
Replace the caller's broadcast opt-ins at one client
Only topics the client has (or an ancestor of one — an opt-in covers descendants) are accepted; anything else is 400 unknown_topic. A client the caller cannot open, or is not granted at, is 404.
Parameters
| Name | In | Type | | Description |
|---|
clientId | path | string | required | |
Request body required
application/json
topicIds string[] required
Responses
| Status | Description |
|---|
| 200 | { ok: true, topicIds } |
GET/api/staff/activity-days
Per-day activity counts for one month of the staff feed, for the jump-to-date calendar (staff-and-up)
Returns { days: { 'YYYY-MM-DD': count }, earliest } for the month asked for: which days have anything on them, and the oldest day the caller can see at all (so a date picker knows where the history starts). Days with nothing are omitted rather than sent as zero. Takes the SAME filters as GET /api/staff/stream and applies the same organisation/locked-project gates, so the counts always match what a jump to that day would show.
Parameters
| Name | In | Type | | Description |
|---|
month | query | string | required | The month to count, YYYY-MM. Anything else is a 400 rather than a guessed month. |
client | query | string | | Client display slug, or 'all' (the default) |
filter | query | string ("mentions") | | 'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely) |
lead | query | string ("1") | | '1' = only clients/projects where the caller holds a LEAD grant |
jira | query | string ("0") | | '0' = exclude Jira/JSM ticket activity |
reactions | query | string ("0") | | '0' = exclude message reactions |
Responses
| Status | Description |
|---|
| 200 | { days: { [day: string]: number }, earliest: string | null } |
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. 'acme-council'. 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. 'acme-council'. Access is enforced from the caller's identity. |
GET/api/staff/dashboard
Staff root dashboard: client list, the FIRST PAGE of the combined activity feed across every active client, and the sidebar cards (staff-and-up see all OPEN clients; locked/Private clients and locked projects excluded unless the caller holds a team grant)
stream is page 1 of GET /api/staff/stream — same query parameters (client, filter=mentions, lead=1, jira=0, reactions=0), same { items, nextCursor } shape; pass nextCursor to /api/staff/stream for page 2 onwards rather than re-reading this route (which also recomputes the sidebar). Each stream message carries an attachments array ({ id, filename, content_type, size_bytes, url, thumbUrl }) for files attached to that message; file items are 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: { items, nextCursor }, 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 }] } } |
GET/api/staff/stream
One page of the staff activity feed: messages, shared files, Jira/JSM ticket activity and message reactions across every accessible client, newest first (staff-and-up; same locked-client/locked-project gates as the dashboard)
Keyset-paginated: pass the previous response's nextCursor back as cursor for the next page, and stop when nextCursor is null. Cursors are opaque and position-only — every row is still gated by the caller's organisation and project access — and a malformed one is a 400, never a silent page 1. Items are { kind: 'message'|'file'|'ticket'|'reaction', at, message|file|activity|reaction }, at being the canonical sortable UTC timestamp ('YYYY-MM-DD HH:MM:SS') the feed is ordered by (created_at itself is stored ISO for Basecamp-imported rows and SQLite-format for native writes). Message items carry the mentioned flag, the author's author_avatar_url (null when they have no photo) and an attachments array ({ id, filename, content_type, size_bytes, url, thumbUrl }); file items are standalone Files-page uploads only (message attachments fold into their message). Ticket items are Jira/JSM created/status-changed events (15-min warm-project cron; see the ticket_activity table) — { id, issue_key, action: 'created'|'updated', summary, status, status_category, from_status, url, source: 'jira'|'jsm', created_at, project_slug, project_name, client_slug, client_name }; a plain comment or non-status field edit is NOT included (only a real status transition is, with from_status the prior status name); click through via GET .../jira/issues/{issueKey}/detail for the full ticket. Reaction items are one row per emoji per person (message_reactions) — { id, emoji, created_at, message_id, thread_id, thread_title, reactor_id, reactor_name, author_id, author_name, project_slug, project_name, client_slug, client_name }; id is the composite 'messageId|userId|emoji' (that table has no id column), reactor_* is who reacted and author_* is whose message it was. All five filters are applied server-side, so a page is always full until the stream genuinely ends.
Parameters
| Name | In | Type | | Description |
|---|
cursor | query | string | | nextCursor from the previous page; omit for the first page |
from | query | string | | Jump to a date, YYYY-MM-DD: start the page at the END of that day and read backwards from there. Seeds page 1 only (a request carrying cursor is already past it), so it composes with normal pagination. Malformed = 400. |
client | query | string | | Client display slug, or 'all' (the default) |
filter | query | string ("mentions") | | 'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely) |
lead | query | string ("1") | | '1' = only clients/projects where the caller holds a LEAD grant (a client-scope lead grant covers every project of that client) |
jira | query | string ("0") | | '0' = exclude Jira/JSM ticket activity (default: included) |
reactions | query | string ("0") | | '0' = exclude message reactions (default: included) |
Responses
| Status | Description |
|---|
| 200 | { items: [{ kind, at, message|file|activity|reaction }], nextCursor: string | null } |
GET/api/users/out-today
Everyone in the caller's organisation out today (leave and/or a weekly non-working day)
Batch form of the card route's availability field — one row per person currently out. Sources: Float-synced or self-service leave (soonest end date wins when bookings overlap) and each user's weekly non-working days; a leave end date extends through immediately-following non-working days. until is the last day out, inclusive. back is the day they are next actually WORKING and is what every 'back on…' label shows: it is NOT until + 1 day, because it also skips the weekend (assumed Mon–Fri for anyone who hasn't set a pattern; a stated pattern is taken at its word), the organisation's company/bank holidays, and any follow-on booking that starts before they'd have returned. type is the leave label, or 'Non-working day' for a pure pattern day. Drives the OUT indicator shown on avatars portal-wide.
Responses
| Status | Description |
|---|
| 200 | { out: [{ id, until, type }] } |
GET/api/users/{userId}/avatar
A user's profile image bytes
A user's profile photo. Every payload that renders someone's NAME beside their avatar carries an avatarUrl (or author_avatar_url / avatar_url) pointing here, with a ?v= version; where an avatar circle stands alone — an avatar stack, a reaction pill, a thread card's author — the portal shows coloured initials and the photo arrives on the hover profile card instead. Any signed-in user may read any user's image WITHIN THEIR OWN ORGANISATION; another organisation's user id 404s, as does a user with no image.
Parameters
| Name | In | Type | | Description |
|---|
userId | path | string | required | |
v | query | integer | | Cache buster; each replacement increments it |
Responses
| Status | Description |
|---|
| 200 | Image bytes (image/png, image/jpeg or image/webp) |
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. 'acme-council'. 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. 'acme-council'. 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 (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. 'acme-council'. 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 (PROJ-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. 'acme-council'. 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/issues/{issueKey}/detail
Full detail (description + comment thread) for one Jira/JSM issue
Powers the ticket detail modal wherever a ticket appears (Support page, dashboard side-card, sprint board, thread Jira chips/linked issues). Available to clients as well as staff - they already see this ticket's summary elsewhere. The issue must belong to the project's own mapped Jira project and/or JSM service desk; any other issue 404s, as does an internal-labelled issue for a non-staff caller. description/comment bodies are pre-rendered HTML (Jira's renderedFields/renderedBody), sanitised server-side. priority is { name, rank } where rank is one of highest|high|medium|low|lowest, normalised from whatever the site calls its levels (normalisePriority in integrations/atlassian.ts); null when the project sets no priority or uses a scheme we can't rank.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
issueKey | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { key, summary, status, statusCategory, type, url, helpCentreUrl: string|null, descriptionHtml: string|null, comments: [{ id, authorName, createdAt, bodyHtml }] } |
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. 'acme-council'. 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, isStaff, 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. ?from=YYYY-MM-DD jumps into the list at the end of that day (the date picker), and ?after/?afterId reads FORWARD from a position for scrolling back up out of a jump - hasNewer says whether there is anything above the page returned. Pinned threads head the first page only, so they can't repeat on a later or jumped-to one. Search covers all threads regardless of paging. newThreadEmail is the working inbound address for starting a thread in THIS project by email: the bare {client.slug}@ for the client's root "General" space, {client.slug}+{project.slug}@ for any other project (both route via the teamportal.site catch-all Email Routing rule). Always the STORAGE slug, never display_slug. Null if EMAIL_REPLY_DOMAIN isn't configured.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. 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 |
after | query | string | | updated_at cursor to read FORWARD from (scrolling back up out of a jump); pairs with afterId |
afterId | query | string | | id cursor (tie-break) for the forward read |
from | query | string | | Jump to a date, YYYY-MM-DD: start the page at the END of that day and read backwards from there. Seeds page 1 only (a request carrying cursor is already past it), so it composes with normal pagination. Malformed = 400. |
Responses
| Status | Description |
|---|
| 200 | { project, threads, newThreadEmail, hasMore, hasNewer } |
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. 'acme-council'. 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/activity-days
Per-day thread activity for one month of a project's Messages list, for its jump-to-date picker
{ days: { 'YYYY-MM-DD': count }, earliest } counting threads by the day they were last active (updated_at), which is what the list is ordered by, so the marks and a jump agree. Client-visible threads only for a client-tier caller, same as the list itself.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
month | query | string | required | The month to count, YYYY-MM. Anything else is a 400 rather than a guessed month. |
Responses
| Status | Description |
|---|
| 200 | { days: { [day: string]: number }, earliest: string | null } |
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, or ?from=YYYY-MM-DD for the 100 messages ENDING on that day (the jump-to-date picker; ignored when before is given). 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. 'acme-council'. 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. |
from | query | string | | Jump to a day: the 100 messages ending on it. Malformed = 400. |
Responses
| Status | Description |
|---|
| 200 | { thread, messages (each with author_id/author_name/author_color/author_avatar_url), hasMoreOlder, watchers: [{ id, name, avatarUrl, isStaff, isGuest, email?, company?, isInactive, isWatcher }] } |
DELETE/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}
Permanently delete a thread and all its content (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. 'acme-council'. 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: 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. 'acme-council'. 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, updated }, MOST RECENTLY UPDATED FIRST. Client viewers only see issues from the project's own Jira project and never internal-labelled ones; 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. 'acme-council'. 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. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Responses
| Status | Description |
|---|
| 200 | { previews: [{ messageId, preview: LinkPreview }] } |
GET/api/clients/{clientSlug}/projects/{projectSlug}/threads/{threadId}/message-days
Per-day message counts for one month of a thread, for its jump-to-date picker
{ days: { 'YYYY-MM-DD': count }, earliest } for the month asked for, over the messages this caller can see (drafts are staff-only, deleted messages excluded), so a long imported history is navigable without paging back through it 100 at a time. Counted server-side because the client only ever holds one window of the thread. Ordered on the same canonical timestamp expression as every other date read (imported messages store ISO in the same column). Same 404 rules as the thread itself.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
month | query | string | required | The month to count, YYYY-MM. Anything else is a 400 rather than a guessed month. |
Responses
| Status | Description |
|---|
| 200 | { days: { [day: string]: number }, earliest: string | null } |
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 staff.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. 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; staff may edit an unpublished draft)
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. Exception: an unpublished AI draft (draft=1) may be edited by ANY staff member, on the same gate as publish/discard — no edited_at is stamped and it stays out of search until published.
Parameters
| Name | In | Type | | Description |
|---|
clientSlug | path | string | required | Client tenant slug, e.g. 'acme-council'. 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; 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. 'acme-council'. 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 (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. 'acme-council'. 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 (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. 'acme-council'. 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. 'acme-council'. 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 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 team grant, 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. 'acme-council'. Access is enforced from the caller's identity. |
projectSlug | path | string | required | |
threadId | path | string | required | |
Request body required
application/json
email string (email) welcomeNote string Optional plain-text welcome note (max 2000 chars). Sent as a branded welcome email ONLY when the invite mints a brand-new guest account — existing accounts get ordinary thread notifications instead.
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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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 (a no-op 200 once set, not an error). 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. Access is enforced from the caller's identity. |
fileId | path | string | required | |
partNumber | path | integer | required | |
Responses
| Status | Description |
|---|
| 200 | { partNumber, etag } |