Developers

API reference

92 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 and nothing more, and it 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, so 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, and every call is permission-checked against the calling token either way. 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/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

StatusDescription
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

StatusDescription
200The 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).

Request body required

image/png
  • string (binary)
image/jpeg
  • string (binary)
image/webp
  • string (binary)

Responses

StatusDescription
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

StatusDescription
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

StatusDescription
200{ avatarUrl, avatarSource: 'slack' }
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, 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 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 (staff and above — client/guest accounts have no API access)

Responses

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

Create an API token (plaintext returned once; staff and above)

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/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

StatusDescription
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

NameInTypeDescription
userIdpathstringrequired

Responses

StatusDescription
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.

Request body required

application/json
  • html string required

Responses

StatusDescription
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

StatusDescription
200The icon image bytes (image/png)
404not_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

StatusDescription
200The favicon (image/svg+xml)
404not_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

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

Responses

StatusDescription
200Client + 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
monthquerystringrequiredThe month to count, YYYY-MM. Anything else is a 400 rather than a guessed month.
projectquerystringProject slug within this client, or 'all' (the default). Content in the root 'General' space shows under 'all'.
filterquerystring ("mentions")'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely)

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
submissionIdpathstringrequired
downloadquerystringPresent = Content-Disposition attachment

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

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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
cursorquerystringnextCursor from the previous page; omit for the first page
fromquerystringJump 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.
projectquerystringProject slug within this client, or 'all' (the default). Content in the root 'General' space shows under 'all'.
filterquerystring ("mentions")'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely)

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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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, isStaff, 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. 'acme-council'. 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, 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

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

Responses

StatusDescription
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

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

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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}/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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
cursorquerystringnextCursor from the previous page; omit for the first page
fromquerystringJump 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.
projectquerystringProject slug within this client, or 'all' (the default). Content in the root 'General' space shows under 'all'.
filterquerystring ("mentions")'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely)

Responses

StatusDescription
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

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

Responses

StatusDescription
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

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

Responses

StatusDescription
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

StatusDescription
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

NameInTypeDescription
clientIdpathstringrequired

Request body required

application/json
  • topicIds string[] required

Responses

StatusDescription
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

NameInTypeDescription
monthquerystringrequiredThe month to count, YYYY-MM. Anything else is a 400 rather than a guessed month.
clientquerystringClient display slug, or 'all' (the default)
filterquerystring ("mentions")'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely)
leadquerystring ("1")'1' = only clients/projects where the caller holds a LEAD grant
jiraquerystring ("0")'0' = exclude Jira/JSM ticket activity
reactionsquerystring ("0")'0' = exclude message reactions

Responses

StatusDescription
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

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. 'acme-council'. 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. 'acme-council'. Access is enforced from the caller's identity.

Responses

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

StatusDescription
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

NameInTypeDescription
cursorquerystringnextCursor from the previous page; omit for the first page
fromquerystringJump 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.
clientquerystringClient display slug, or 'all' (the default)
filterquerystring ("mentions")'mentions' = only messages that @mention the caller (files, documents and ticket activity are excluded entirely)
leadquerystring ("1")'1' = only clients/projects where the caller holds a LEAD grant (a client-scope lead grant covers every project of that client)
jiraquerystring ("0")'0' = exclude Jira/JSM ticket activity (default: included)
reactionsquerystring ("0")'0' = exclude message reactions (default: included)

Responses

StatusDescription
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

StatusDescription
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

NameInTypeDescription
userIdpathstringrequired
vqueryintegerCache buster; each replacement increments it

Responses

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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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. 'acme-council'. 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 (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. 'acme-council'. 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 (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

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

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
issueKeypathstringrequired

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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, 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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
afterquerystringupdated_at cursor to read FORWARD from (scrolling back up out of a jump); pairs with afterId
afterIdquerystringid cursor (tie-break) for the forward read
fromquerystringJump 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

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

Create a thread with its first message

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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/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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
monthquerystringrequiredThe month to count, YYYY-MM. Anything else is a 400 rather than a guessed month.

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
beforequerystringISO created_at cursor; returns the 100 messages older than it.
fromquerystringJump to a day: the 100 messages ending on it. Malformed = 400.

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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: 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. 'acme-council'. 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, 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

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

Responses

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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
monthquerystringrequiredThe month to count, YYYY-MM. Anything else is a 400 rather than a guessed month.

Responses

StatusDescription
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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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; 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

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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; 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. 'acme-council'. 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 (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. 'acme-council'. Access is enforced from the caller's identity.
projectSlugpathstringrequired
threadIdpathstringrequired
messageIdpathstringrequired

Request body required

application/json
  • issueKey string required Target issue key, e.g. PROJ-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 (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. 'acme-council'. 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 (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. 'acme-council'. 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. 'acme-council'. 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 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

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

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

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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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 (a no-op 200 once set, not an error). The text feeds the FTS index so PDF content is searchable.

Parameters

NameInTypeDescription
clientSlugpathstringrequiredClient tenant slug, e.g. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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. 'acme-council'. 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 a team grant is 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 (staff only)

Responses

StatusDescription
200ok