GET
/api/clients
Unpaged list of the workspace's non-archived clients (id, name, email, company, status, assigned team) for the assign-clients and invite-team pickers.
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin, member
Query parameters
| Name | Type | Required | Details |
|---|
| org_id | string (NOT uuid-validated) | Yes | |
Responses
- 200 {clients:[{id,name,email,company,status,created_at,client_assignments:[{user_id,profiles:{full_name}}]}]} ordered created_at desc; archived excluded
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 500 Something went wrong; safe to retry.
POST
/api/clients
Admin creates a client with 1-100 contacts (one or more primary), optional photo and team assignees; every contact is invited (inline, or deferred to the per-contact invite route). Invitations are sent afterwards, one request per contact (never inside this request).
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin
- Rate limit
- 30/min
JSON body
| Name | Type | Required | Details |
|---|
| org_id | uuid | Yes | |
| name | string trim | Yes | min 1; max 120 |
| company | string trim (alias of name) | No | min 1; max 120 |
| contacts | array<{email: trimmed lower-cased email, name?: string trim max 120 nullable}> | Yes | min 1; max 100 |
| primaryIndex | int | No | min 0; default 0 |
| primaryIndexes | int[] | No | min 1; max 100; wins over primaryIndex; each must be < contacts.length |
| assignedTeamUserIds | uuid[] | No | NO max length |
| deferInvites | boolean | No | |
| avatarKey | string | No | max 500; must equal pending-avatars/<orgId>/<digits>-<12hex>.(png|jpg|webp) |
Responses
- 201 {client:{...clients row}, inviteFailures:string[] emails, invitesSkipped:number}; with deferInvites: {client, contacts:[{id,email}], inviteFailures:[], invitesSkipped:0, invitesDeferred:true}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 409 Conflict with the current state of the resource.
- 422 Invalid input.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
- 503 Temporarily unavailable.
POST
/api/clients/bulk/archive
Admin archives up to 25 clients at once and signs every linked portal contact out globally.
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin
- Rate limit
- 10/min
JSON body
| Name | Type | Required | Details |
|---|
| clientIds | uuid[] | Yes | min 1; max 25 |
| org_id | uuid | Yes | |
Responses
- 200 {succeeded:string[], failed:[{id, reason:'not_found'|a short reason}]}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
POST
/api/clients/bulk/delete
Admin hard-deletes up to 25 clients (cascade to contacts, documents rows) after signing their linked portal users out globally.
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin
- Rate limit
- 10/min
JSON body
| Name | Type | Required | Details |
|---|
| clientIds | uuid[] | Yes | min 1; max 25 |
| org_id | uuid | Yes | |
Responses
- 200 {succeeded:string[], failed:[{id, reason:'not_found'|a short reason}]}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
POST
/api/clients/bulk/unarchive
Admin restores up to 25 archived clients at once.
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin
- Rate limit
- 10/min
JSON body
| Name | Type | Required | Details |
|---|
| clientIds | uuid[] | Yes | min 1; max 25 |
| org_id | uuid | Yes | |
Responses
- 200 {succeeded:string[], failed:[{id, reason:'not_found'|a short reason}]}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
POST
/api/clients/contacts/check
Admin pre-check of up to 100 candidate contact emails: ok | invalid | team | pending_team | existing contact (with its client name).
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin
- Rate limit
- 60/min
JSON body
| Name | Type | Required | Details |
|---|
| org_id | uuid | Yes | |
| emails | string[] (items have NO max length, not email-validated here) | Yes | min 1; max 100 |
Responses
- 200 {results:[{email,status:'ok'|'invalid'|'team'|'pending_team'|'contact', clientId?, clientName?}]} same order as input
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
- 503 Temporarily unavailable.
GET
/api/clients/list
Paginated, searchable, sortable, filterable enriched Clients table (contacts, team, doc/category counts, derived status, signed avatars); ids_only=true returns the Select-all id set (admin only, max 100).
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin, member
Query parameters
| Name | Type | Required | Details |
|---|
| org_id | string (NOT uuid) | Yes | min 1 |
| page | int | No | min 1; default 1 |
| pageSize | int | No | min 1; max 100; default 25 |
| search | string | No | no max length; escapeIlike then or() with ,()" stripped; matches client name/company and contact name/email |
| status | enum | No | one of archived, active, invited, inactive |
| sort | enum | No | one of recently_added, oldest_added, name_asc, name_desc, company_asc, company_desc, last_active, last_active_desc, last_active_asc; default recently_added |
| assignedUserIds | comma list of uuid | No | no max count |
| clientIds | comma list of uuid | No | max 100 |
| categoryIds | comma list of uuid or '__uncategorized__' | No | max 100 |
| ids_only | enum | No | one of true, false |
Responses
- 200 {rows:[EnrichedClient{id,name,email,company,status,rawStatus,created_at,last_active,avatarUrl(signed),primaryContactUserId,contacts[top4],totalContacts,assignedTeam[],pendingTeamCount,orgAdminCount,teamCount,docCount,categoryCount}], total, page, pageSize}; ids_only: {ids:[{id,name,status}], total, capped}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 500 Something went wrong; safe to retry.
GET
/api/clients/picker
Lightweight name-ordered, offset-paged {id,name} client list for pickers and filter drawers; scope=mine limits a member to assigned clients.
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin, member
Query parameters
| Name | Type | Required | Details |
|---|
| org_id | uuid | Yes | |
| scope | enum | Yes | one of all, mine |
| search | string | No | max 200; ilike name with escapeLike |
| limit | int | No | min 1; max 200; default 50 |
| offset | int | No | min 0; max 100000; default 0 |
Responses
- 200 {clients:[{id,name}], hasMore:boolean}, ordered by name
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 500 Something went wrong; safe to retry.
DELETE
/api/clients/{clientId}
Hard-delete a client (typed name confirmation), signing out its linked portal users
- Authentication
- Signed-in session
- Who can call it
- admin
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
JSON body
| Name | Type | Required | Details |
|---|
| confirmName | string | Yes | trimmed, exact match; empty/invalid JSON body tolerated -> 400 NAME_MISMATCH; no Zod |
Responses
- 200 {ok:true}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 500 Something went wrong; safe to retry.
PATCH
/api/clients/{clientId}
Edit client name/company/photo and replace its assigned team (admin only)
- Authentication
- Signed-in session
- Who can call it
- admin
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
JSON body
| Name | Type | Required | Details |
|---|
| name | string | No | min 1; max 120; trimmed |
| company | string|'' | No | max 120; '' clears |
| assignedTeamUserIds | uuid[] | No | NO max length |
| avatarR2Key | string|null | No | max 500; null/'' clears; else must be pending-avatars/<orgId>/<ms>-<12hex>.(png|jpg|webp) |
Responses
- 200 {client:<clients row>}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 409 Conflict with the current state of the resource.
- 422 Invalid input.
- 500 Something went wrong; safe to retry.
POST
/api/clients/{clientId}/archive
Archive a client (status archived) and sign out all linked portal users
- Authentication
- Signed-in session
- Who can call it
- admin
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
Responses
- 200 {client:<clients.* + client_contacts[{user_id}]>}
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 500 Something went wrong; safe to retry.
GET
/api/clients/{clientId}/contacts
List non-archived contacts of a client with derived status and primary flag
- Authentication
- Signed-in session
- Who can call it
- admin, member (assigned only)
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
Responses
- 200 {contacts:[{id,email,name,userId,avatarUrl,lastActiveAt,invited_at,portal_enabled,createdAt,isPrimary,status}]}
- 401 Not signed in.
- 404 Not found.
- 500 Something went wrong; safe to retry.
POST
/api/clients/{clientId}/contacts
Add a contact to a client and send the portal invite (admin, or a PRIMARY portal contact for their own client)
- Authentication
- Signed-in session
- Who can call it
- admin, portal contact (primary only)
- Rate limit
- 30/min
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
JSON body
| Name | Type | Required | Details |
|---|
| email | string | Yes | trim+lowercase+email |
| name | string|null | No | max 120 |
| isPrimary | boolean | No | |
Responses
- 201 {contact:{id,email,name,user_id,invited_at,portal_enabled}} plus inviteFailed:true when no mail went out (no reason given)
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 409 Conflict with the current state of the resource.
- 422 Invalid input.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
- 503 Temporarily unavailable.
DELETE
/api/clients/{clientId}/contacts/{contactId}
Remove a contact (admin), remove a colleague (primary portal contact) or leave (any portal contact)
- Authentication
- Signed-in session
- Who can call it
- admin, portal contact (primary for colleagues; any for self/leave)
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
| contactId | string | Yes | |
Responses
- 200 {ok:true} (admin) | {ok:true,selfLeft:true} (self leave)
- 204 empty (primary removed a colleague)
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 409 Conflict with the current state of the resource.
- 500 Something went wrong; safe to retry.
- 503 Temporarily unavailable.
PATCH
/api/clients/{clientId}/contacts/{contactId}
Change a contact's email, make/unmake primary, or archive/restore it (name is never editable)
- Authentication
- Signed-in session
- Who can call it
- admin, portal contact (own row; primary for colleagues/primary flag)
- Rate limit
- 10/h
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
| contactId | string | Yes | |
JSON body
| Name | Type | Required | Details |
|---|
| email | string | No | trim+lowercase+email |
| isPrimary | boolean | No | alone |
| archived | boolean | No | alone; admin only |
| name | never | No | refine: any non-undefined name (incl null) => 400 |
Responses
- 200 {ok:true,contact:{id,name,email[,isPrimary|archived]}}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 409 Conflict with the current state of the resource.
- 422 Invalid input.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
- 503 Temporarily unavailable.
POST
/api/clients/{clientId}/contacts/{contactId}/invite
Send the FIRST invite to a just-added contact (deferInvites flow)
- Authentication
- Signed-in session
- Who can call it
- admin
- Rate limit
- 1200/h
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
| contactId | string | Yes | |
Responses
- 200 {ok:true} | {ok:true,already:true}
- 401 Not signed in.
- 404 Not found.
- 409 Conflict with the current state of the resource.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
- 502 Temporarily unavailable.
- 503 Temporarily unavailable.
POST
/api/clients/{clientId}/contacts/{contactId}/resend-invite
Resend a pending portal invite (admin, or a primary portal contact for a colleague)
- Authentication
- Signed-in session
- Who can call it
- admin, portal contact (primary for colleagues)
- Rate limit
- 1/min and 5/24h
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
| contactId | string | Yes | |
Responses
- 200 {ok:true,email}
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 409 Conflict with the current state of the resource.
- 429 Too many requests; retry after the delay in the response.
- 500 Something went wrong; safe to retry.
- 502 Temporarily unavailable.
- 503 Temporarily unavailable.
POST
/api/clients/{clientId}/unarchive
Restore an archived client to active
- Authentication
- Signed-in session
- Who can call it
- admin
Path parameters
| Name | Type | Required | Details |
|---|
| clientId | string | Yes | |
Responses
- 200 {client:<clients.* + client_contacts[{user_id}]>}
- 401 Not signed in.
- 403 Not allowed.
- 404 Not found.
- 500 Something went wrong; safe to retry.
GET
/api/members/contacts
Org-wide client-contacts directory (one row per contact with client name/status, derived portal status, last active, primary flags), paged/sorted/filtered; ids_only returns the select-all set.
- Authentication
- Signed-in session with two-factor verified
- Who can call it
- admin, member
Query parameters
| Name | Type | Required | Details |
|---|
| org_id | uuid | Yes | |
| ids_only | enum | No | one of true, 1 |
| search | string (no max) | No | |
| page | int | No | min 1; default 1 |
| pageSize | int | No | min 1; max 100; default 25 |
| status | enum | No | one of active, inactive, archived, invited |
| clientIds | comma list of uuid (no max count) | No | |
| includeArchived | enum | No | one of true, false |
| sortBy | enum | No | one of name_asc, name_desc, status_asc, status_desc, invited_desc, invited_asc, added_desc, added_asc; default name_asc |
| contactType | enum | No | one of all, primary, regular; default all |
Responses
- 200 {rows:[ContactRow{id,client_id,client_name,client_status,name,email,user_id,invited_at,portal_enabled,created_at,avatar_url,pending_avatar_url,avatar_updated_at,last_active_at,status,is_self,is_primary,client_primary_count,contact_archived}], total, totalPages}; ids_only: {ids:[{id,client_id}], totalSelectable}
- 400 Invalid input.
- 401 Not signed in.
- 403 Not allowed.
- 500 Something went wrong; safe to retry.