Clients
Full CRUD over clients. Projects link to a client by name via the project's clientName field.
A client is a lightweight directory entry — a name plus a rich-text descriptionHtml — shared across the organization. Projects link to a client through the project's free-text clientName: when you create or update a project with a clientName, a matching client is found-or-created automatically and the project's clientId is set. Renaming a client updates the linked projects' label; deleting one keeps the projects and just clears their clientId.
Endpoints
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/clients | List. Supports query, offset paging. |
POST | /api/v1/clients | Create. Org owner or admin only. Accepts Idempotency-Key. |
GET | /api/v1/clients/{id} | Returns the client plus its linked projects. |
PATCH | /api/v1/clients/{id} | Update. Org owner or admin only. |
DELETE | /api/v1/clients/{id} | Delete. Org owner or admin only. |
GET | /api/v1/clients/{id}/contacts | List the client's portal contacts. |
POST | /api/v1/clients/{id}/contacts | Invite a contact. Accepts Idempotency-Key. |
GET | /api/v1/clients/{id}/contacts/{contactId} | Retrieve a contact. |
PATCH | /api/v1/clients/{id}/contacts/{contactId} | Set the contact's canOpenRequests permission. |
POST | /api/v1/clients/{id}/contacts/{contactId}/resend | Re-send a pending or expired invite. |
POST | /api/v1/clients/{id}/contacts/{contactId}/revoke | End a contact's access. |
Client model
{
"id": "9b1c…",
"organizationId": "1f3c…",
"name": "Acme Corp",
"phone": null,
"status": "active",
"descriptionHtml": "<p>Primary retainer client.</p>",
"createdAt": "2026-05-01T08:30:00.000Z",
"updatedAt": "2026-05-19T14:22:11.000Z"
}| Field | Type | Notes |
|---|---|---|
name | string | 1–150 chars. |
descriptionHtml | string | Rich-text HTML, ≤ 50,000 chars. Sanitized server-side; defaults to "". |
phone | string | null | Reserved; not editable via this API. |
status | enum | active · inactive. Reserved; defaults to active. |
Listing clients
GET /api/v1/clients?query=acme&limit=25&offset=0| Param | Type | Notes |
|---|---|---|
limit | int 1–100 | Default 50. |
offset | int ≥ 0 | Default 0. |
query | string | Case-insensitive search over name. |
Returns { data, total }, newest first.
Creating a client
POST /api/v1/clients
{
"name": "Acme Corp",
"descriptionHtml": "<p>Primary retainer client.</p>"
}Required:
| Field | Type | Constraint |
|---|---|---|
name | string | 1–150 chars |
Optional:
| Field | Type | Constraint |
|---|---|---|
descriptionHtml | string | Rich-text HTML, ≤ 50,000 chars. Sanitized server-side. |
Response: 201 Created with the new client.
Retrieving a client
GET /api/v1/clients/{id}Returns the client plus projectsCount and a linkedProjects array (id, name, status, createdAt), newest first.
Updating a client
PATCH /api/v1/clients/{id}
{ "name": "Acme Incorporated", "descriptionHtml": "<p>Updated.</p>" }All fields are optional. descriptionHtml is rich-text HTML, sanitized server-side; send an empty string to clear it. Renaming a client also updates the clientName label on every linked project. Org owner or admin only.
Deleting a client
DELETE /api/v1/clients/{id}Returns { "data": { "success": true } }. Linked projects are kept — their clientId is set to null (the clientName text is preserved). Org owner or admin only.
Client portal contacts
A contact is a person invited to follow the client's projects in the client portal. Every contact endpoint is org owner or admin only (403 otherwise). There is no DELETE: revoking is the only way to end access.
{
"id": "4e7a…",
"clientId": "9b1c…",
"email": "[email protected]",
"name": "Maria",
"status": "active",
"accountEmail": "[email protected]",
"accountDeleted": false,
"expiresAt": "2026-10-20T12:00:00.000Z",
"lastAccessAt": "2026-10-13T09:41:00.000Z",
"canOpenRequests": false,
"createdAt": "2026-10-13T12:00:00.000Z"
}| Field | Type | Notes |
|---|---|---|
email | string | The invited email, lowercase. |
status | enum | invited · expired · active · revoked. expired is an invited contact past expiresAt. |
accountEmail | string | null | The current email of the contact's account. It can differ from email. |
accountDeleted | boolean | true when the contact was revoked because the person deleted their account. |
expiresAt | ISO date | The invite link expires 7 days after the last send. |
lastAccessAt | ISO date | null | Last portal visit, updated at most once per hour. Read-only. |
canOpenRequests | boolean | Whether the contact can open client requests in the portal. false by default. |
Inviting a contact
POST /api/v1/clients/{id}/contacts
{ "email": "[email protected]", "name": "Maria" }email is required; name (≤ 150 chars) and canOpenRequests are optional. A new contact without canOpenRequests cannot open requests; a re-invite without it keeps the current value. Returns 201 with the contact, and the person gets the invite email. Inviting a revoked contact invites them again; inviting a pending one re-sends the invite. Sending lastAccessAt returns 422.
| Status | When |
|---|---|
400 | The client is inactive. |
409 | The email belongs to a member of the organization, or is already an active contact. |
429 | More than 100 invites and re-sends in 24 hours for the organization. |
Re-sending and revoking
POST …/resend sends a new link and invalidates the old one. It works on invited and expired contacts only (400 otherwise), at most once a minute and 5 times a day per contact.
POST …/revoke ends access on the contact's next request. Revoking a revoked contact changes nothing. Both return { data } with the updated contact.
Setting the request permission
PATCH /api/v1/clients/{id}/contacts/{contactId}
{ "canOpenRequests": true }canOpenRequests is the only writable field and is required. Returns { data } with the updated contact. Any other body gets 422.