> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fitsociety.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Clients

> Read, create, and update Core CRM clients through the FITsociety Public API.

## List clients

```http theme={null}
GET /public/v1/clients
Authorization: Bearer <access_token>
```

Required scope:

```txt theme={null}
clients:read
```

Query parameters:

| Parameter | Type    | Notes                                     |
| :-------- | :------ | :---------------------------------------- |
| `page`    | integer | 1-based page number                       |
| `limit`   | integer | Defaults to 100, maximum 500              |
| `search`  | string  | Searches first name, last name, and email |
| `status`  | string  | Optional relationship status filter       |

<CodeGroup>
  ```bash cURL theme={null}
  curl -H "Authorization: Bearer $FITSOCIETY_ACCESS_TOKEN" \
    "https://api.fitsociety.io/public/v1/clients?page=1&limit=50&search=jane"
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(
    "https://api.fitsociety.io/public/v1/clients?page=1&limit=50&search=jane",
    {
      headers: {
        Authorization: `Bearer ${process.env.FITSOCIETY_ACCESS_TOKEN}`,
      },
    },
  );

  const body = await response.json();
  console.log(body.data.total, body.data.data);
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.get(
      "https://api.fitsociety.io/public/v1/clients",
      headers={"Authorization": f"Bearer {os.environ['FITSOCIETY_ACCESS_TOKEN']}"},
      params={"page": 1, "limit": 50, "search": "jane"},
  )
  response.raise_for_status()

  body = response.json()
  print(body["data"]["total"], body["data"]["data"])
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "data": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false,
    "data": [
      {
        "clientId": "64b64c0f2f5f4c0012345678",
        "firstName": "Jane",
        "lastName": "Doe",
        "email": "jane@example.com",
        "emailIsPlaceholder": false,
        "relationship": {
          "status": "active",
          "memberStatus": "active",
          "portalAccessStatus": "enabled",
          "assignedCoach": { "name": "John Doe" }
        }
      }
    ]
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": { "limit": 10, "remaining": 9, "resetSeconds": 1 }
  }
}
```

## Get a client

```http theme={null}
GET /public/v1/clients/{clientId}
Authorization: Bearer <access_token>
```

Required scope:

```txt theme={null}
clients:read
```

The response includes safe Core CRM fields such as name, email, phone number,
relationship status, assigned coach, location, language, timezone, and custom
fields. It does not expose passwords, devices, payment data, notes, chats, or
subscriptions.

## Client output fields

List responses return pagination fields plus `data.data[]`. Detail responses
return `data.client`.

| Field                                | List | Detail | Type                  | Nullable | Description                                       |
| :----------------------------------- | :--- | :----- | :-------------------- | :------- | :------------------------------------------------ |
| `clientId`                           | yes  | yes    | string                | no       | Client ID.                                        |
| `firstName`                          | yes  | yes    | string                | no       | First name.                                       |
| `lastName`                           | yes  | yes    | string                | no       | Last name.                                        |
| `email`                              | yes  | yes    | string                | no       | Email, empty for placeholder email accounts.      |
| `emailIsPlaceholder`                 | yes  | yes    | boolean               | no       | Whether FITsociety generated a placeholder email. |
| `relationship.status`                | yes  | yes    | string                | no       | Company relationship status.                      |
| `relationship.memberStatus`          | yes  | yes    | string                | no       | Member status.                                    |
| `relationship.portalAccessStatus`    | yes  | yes    | string                | no       | Portal access status.                             |
| `relationship.assignedCoach.name`    | yes  | yes    | string                | no       | Assigned coach name when available.               |
| `relationship.assignedCoach.coachId` | no   | yes    | string                | no       | Assigned coach ID.                                |
| `relationship.startedAt`             | no   | yes    | string                | yes      | Relationship start date.                          |
| `phoneNumber`                        | no   | yes    | string                | no       | Phone number.                                     |
| `gender`                             | no   | yes    | string                | no       | Gender field.                                     |
| `dateOfBirth`                        | no   | yes    | string                | yes      | Date of birth.                                    |
| `language`                           | no   | yes    | string                | no       | Client language.                                  |
| `lastActiveAt`                       | no   | yes    | string                | yes      | Last activity timestamp.                          |
| `createdAt`                          | no   | yes    | string                | yes      | Client creation timestamp.                        |
| `location.addressLine1`              | no   | yes    | string                | no       | Address line.                                     |
| `location.addressLine2`              | no   | yes    | string                | no       | Address line.                                     |
| `location.zipCode`                   | no   | yes    | string                | no       | ZIP/postal code.                                  |
| `location.city`                      | no   | yes    | string                | no       | City.                                             |
| `location.country`                   | no   | yes    | string                | no       | Country.                                          |
| `height`                             | no   | yes    | string                | no       | Height as stored.                                 |
| `timeZone`                           | no   | yes    | string                | no       | IANA timezone.                                    |
| `profession`                         | no   | yes    | string                | no       | Profession.                                       |
| `companyName`                        | no   | yes    | string                | no       | Client company name.                              |
| `customFields[].key`                 | no   | yes    | string                | no       | Custom field key.                                 |
| `customFields[].label.en`            | no   | yes    | string                | no       | English label snapshot.                           |
| `customFields[].label.nl`            | no   | yes    | string                | no       | Dutch label snapshot.                             |
| `customFields[].type`                | no   | yes    | string                | no       | Custom field type.                                |
| `customFields[].value`               | no   | yes    | string/number/boolean | yes      | Stored value.                                     |
| `customFields[].updatedAt`           | no   | yes    | string                | yes      | Field update timestamp.                           |

## Create a client

```http theme={null}
POST /public/v1/clients
Authorization: Bearer <access_token>
Content-Type: application/json
```

Required scope:

```txt theme={null}
clients:write
```

```json theme={null}
{
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane@example.com",
  "assignedCoach": "66f7b8b1e13c8d25f4d3d90a",
  "tags": ["66f7b8b1e13c8d25f4d3d90b"],
  "inviteByEmail": true
}
```

If `assignedCoach` is omitted, FITsociety uses the OAuth client's
`defaultAssignedCoachId`. If neither is available, the request fails with
`ASSIGNED_COACH_REQUIRED`.

Set `hasNoEmail` to `true` to generate a placeholder email address.

Request validation:

| Field           | Type               | Required    | Rule                                                                                                                  |
| :-------------- | :----------------- | :---------- | :-------------------------------------------------------------------------------------------------------------------- |
| `firstName`     | string             | yes         | Trimmed length must be at least 2 and at most 50 characters.                                                          |
| `lastName`      | string             | no          | Maximum 50 characters when supplied.                                                                                  |
| `email`         | string             | conditional | Required unless `hasNoEmail=true`; normalized and validated as an email address.                                      |
| `hasNoEmail`    | boolean/string     | no          | When true, FITsociety generates a placeholder email and `email` is ignored.                                           |
| `assignedCoach` | ObjectId string    | conditional | Defaults to the OAuth client's `defaultAssignedCoachId`; must reference an active coach in the authenticated company. |
| `language`      | string             | no          | Defaults to the company primary language, then `nl`.                                                                  |
| `timeZone`      | string             | no          | Defaults to `Europe/Amsterdam`.                                                                                       |
| `tags[]`        | ObjectId string\[] | no          | All tags must exist in the authenticated company and must not be deleted.                                             |
| `inviteByEmail` | boolean/string     | no          | Sends a portal invite only when the client has a real email address.                                                  |

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.fitsociety.io/public/v1/clients" \
    -H "Authorization: Bearer $FITSOCIETY_ACCESS_TOKEN" \
    -H "Idempotency-Key: client-create-jane-doe-20260823" \
    -H "Content-Type: application/json" \
    -d '{
      "firstName": "Jane",
      "lastName": "Doe",
      "email": "jane@example.com",
      "assignedCoach": "64b64c0f2f5f4c0012345671",
      "tags": ["64b64c0f2f5f4c0012345672"],
      "inviteByEmail": true
    }'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch("https://api.fitsociety.io/public/v1/clients", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FITSOCIETY_ACCESS_TOKEN}`,
      "Idempotency-Key": "client-create-jane-doe-20260823",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      firstName: "Jane",
      lastName: "Doe",
      email: "jane@example.com",
      assignedCoach: "64b64c0f2f5f4c0012345671",
      tags: ["64b64c0f2f5f4c0012345672"],
      inviteByEmail: true,
    }),
  });

  const body = await response.json();
  if (response.status === 201) {
    console.log("Created client", body.data.client._id);
  }
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.post(
      "https://api.fitsociety.io/public/v1/clients",
      headers={
          "Authorization": f"Bearer {os.environ['FITSOCIETY_ACCESS_TOKEN']}",
          "Idempotency-Key": "client-create-jane-doe-20260823",
      },
      json={
          "firstName": "Jane",
          "lastName": "Doe",
          "email": "jane@example.com",
          "assignedCoach": "64b64c0f2f5f4c0012345671",
          "tags": ["64b64c0f2f5f4c0012345672"],
          "inviteByEmail": True,
      },
  )
  response.raise_for_status()

  body = response.json()
  if response.status_code == 201:
      print("Created client", body["data"]["client"]["_id"])
  ```
</CodeGroup>

Response (`201 Created`):

```json theme={null}
{
  "data": {
    "client": {
      "_id": "64b64c0f2f5f4c0012345678",
      "firstName": "Jane",
      "lastName": "Doe",
      "email": "jane@example.com",
      "emailIsPlaceholder": false,
      "timeZone": "Europe/Amsterdam",
      "language": "nl"
    },
    "companyClientData": {
      "_id": "64b64c0f2f5f4c0012345679",
      "assignedCoach": "64b64c0f2f5f4c0012345671",
      "createdBy": "64b64c0f2f5f4c0012345671",
      "tags": ["64b64c0f2f5f4c0012345672"]
    },
    "inviteSent": true
  },
  "meta": {
    "requestId": "req_0123456789abcdef",
    "rateLimit": { "limit": 10, "remaining": 8, "resetSeconds": 1 }
  }
}
```

New-client responses return:

| Field                                  | Type      | Nullable | Description                                  |
| :------------------------------------- | :-------- | :------- | :------------------------------------------- |
| `data.client._id`                      | string    | no       | Created client ID.                           |
| `data.client.firstName`                | string    | no       | First name.                                  |
| `data.client.lastName`                 | string    | no       | Last name.                                   |
| `data.client.email`                    | string    | no       | Email, empty for placeholder email accounts. |
| `data.client.emailIsPlaceholder`       | boolean   | no       | Placeholder email flag.                      |
| `data.client.timeZone`                 | string    | no       | Stored IANA timezone.                        |
| `data.client.language`                 | string    | no       | Stored language code.                        |
| `data.companyClientData._id`           | string    | no       | Company-client relation ID.                  |
| `data.companyClientData.assignedCoach` | string    | no       | Assigned coach ID.                           |
| `data.companyClientData.createdBy`     | string    | no       | Coach ID used for creation attribution.      |
| `data.companyClientData.tags[]`        | string\[] | no       | Assigned company tag IDs.                    |
| `data.inviteSent`                      | boolean   | no       | Whether the invite email flow was triggered. |

Existing-account responses return:

| Field           | Type   | Nullable | Description                                                |
| :-------------- | :----- | :------- | :--------------------------------------------------------- |
| `data.clientId` | string | no       | Existing client ID for which an approval request was sent. |

If the existing client is already connected to the company, the endpoint returns
`409 CLIENT_ALREADY_EXISTS_IN_COMPANY`.

## Update a client

```http theme={null}
PATCH /public/v1/clients/{clientId}
Authorization: Bearer <access_token>
Content-Type: application/json
```

Required scope:

```txt theme={null}
clients:write
```

```json theme={null}
{
  "phoneNumber": "+31612345678",
  "assignedCoach": "66f7b8b1e13c8d25f4d3d90a",
  "assignTagIds": ["66f7b8b1e13c8d25f4d3d90b"]
}
```

Supported update fields:

| Field                                                         | Notes                                                                    |
| :------------------------------------------------------------ | :----------------------------------------------------------------------- |
| `firstName`, `lastName`, `email`                              | Basic profile fields                                                     |
| `phoneNumber`, `phoneCode`, `gender`, `dateOfBirth`           | Contact/profile fields                                                   |
| `language`, `timeZone`, `height`, `profession`, `companyName` | CRM profile fields                                                       |
| `location`                                                    | Object with `addressLine1`, `addressLine2`, `zipCode`, `city`, `country` |
| `assignedCoach` or `coachId`                                  | Assigns the client to a coach                                            |
| `tagIds`                                                      | Replaces all assigned tag IDs                                            |
| `assignTagIds`, `removeTagIds`                                | Adds or removes tag IDs incrementally                                    |

## Archive a client

```http theme={null}
POST /public/v1/clients/{clientId}/archive
Authorization: Bearer <access_token>
Content-Type: application/json
```

Required scope:

```txt theme={null}
clients:write
```

```json theme={null}
{
  "reason": "Duplicate profile"
}
```

Archive is a lifecycle transition, not a hard delete. FITsociety sets the
company relationship to `archived`, member status to `past`, and portal access
status to `disabled`.

Response fields:

| Field                               | Type   | Nullable | Description                                      |
| :---------------------------------- | :----- | :------- | :----------------------------------------------- |
| `data.lifecycle.clientId`           | string | no       | Client ID.                                       |
| `data.lifecycle.relationshipStatus` | string | no       | Lifecycle relationship status.                   |
| `data.lifecycle.memberStatus`       | string | no       | Member status.                                   |
| `data.lifecycle.portalAccessStatus` | string | no       | Portal access status.                            |
| `data.lifecycle.archivedAt`         | string | yes      | Archive timestamp.                               |
| `data.lifecycle.archiveReason`      | string | no       | Public archive reason, capped at 500 characters. |

Validation:

| Input                       | Rule                                                              |
| :-------------------------- | :---------------------------------------------------------------- |
| `clientId`                  | Must be a valid client ID connected to the authenticated company. |
| `reason` or `archiveReason` | Optional string, trimmed and capped at 500 characters.            |

The response does not expose `archivedBy`, removal outcomes, deletion flags, or
internal retention fields.

## Portal access and onboarding

```http theme={null}
GET  /public/v1/clients/{clientId}/portal-access
POST /public/v1/clients/{clientId}/invite
POST /public/v1/clients/{clientId}/invite/resend
POST /public/v1/clients/{clientId}/relationship/approve
POST /public/v1/clients/{clientId}/relationship/reject
```

Required scopes: `clients:read` for `portal-access`,
`client_onboarding:write` for invite and relationship decisions.

`GET /portal-access` returns the current company-client portal access status
without exposing auth state, sessions, devices, password status, or identity
tokens.

Portal access response fields:

| Field                                  | Type    | Nullable | Description                                       |
| :------------------------------------- | :------ | :------- | :------------------------------------------------ |
| `data.portalAccess.clientId`           | string  | no       | Client ID.                                        |
| `data.portalAccess.relationshipStatus` | string  | no       | Company relationship status.                      |
| `data.portalAccess.memberStatus`       | string  | no       | Company member status.                            |
| `data.portalAccess.portalAccessStatus` | string  | no       | Portal access status.                             |
| `data.portalAccess.invitationStatus`   | string  | yes      | Invitation status when stored.                    |
| `data.portalAccess.emailIsPlaceholder` | boolean | no       | Placeholder email flag.                           |
| `data.portalAccess.pendingApproval`    | boolean | no       | Whether the relationship is waiting for approval. |
| `data.portalAccess.reviewedAt`         | string  | yes      | Registration review timestamp when stored.        |

Invite endpoints validate that the client belongs to the authenticated company
and has a real email address. Placeholder email accounts cannot be invited. The
endpoints use the existing coach invite flow and return:

| Field                                  | Type    | Nullable | Description                                       |
| :------------------------------------- | :------ | :------- | :------------------------------------------------ |
| `data.invite.clientId`                 | string  | no       | Client ID.                                        |
| `data.invite.inviteSent`               | boolean | no       | Whether the invite flow accepted the request.     |
| `data.invite.sentAt`                   | string  | no       | Invite request timestamp.                         |
| `data.portalAccess.clientId`           | string  | no       | Client ID.                                        |
| `data.portalAccess.relationshipStatus` | string  | no       | Company relationship status.                      |
| `data.portalAccess.memberStatus`       | string  | no       | Company member status.                            |
| `data.portalAccess.portalAccessStatus` | string  | no       | Portal access status.                             |
| `data.portalAccess.invitationStatus`   | string  | yes      | Invitation status when stored.                    |
| `data.portalAccess.emailIsPlaceholder` | boolean | no       | Placeholder email flag.                           |
| `data.portalAccess.pendingApproval`    | boolean | no       | Whether the relationship is waiting for approval. |
| `data.portalAccess.reviewedAt`         | string  | yes      | Registration review timestamp when stored.        |

Relationship decision endpoints are for clients whose company relation is
`pending_approval`.

Approve request body:

| Field           | Type   | Required | Rule                                                |
| :-------------- | :----- | :------- | :-------------------------------------------------- |
| `reviewMessage` | string | no       | Optional review message, capped at 1000 characters. |
| `reason`        | string | no       | Optional reason, capped at 1000 characters.         |

Reject request body:

| Field    | Type   | Required | Rule                                           |
| :------- | :----- | :------- | :--------------------------------------------- |
| `reason` | string | no       | Optional rejection reason, max 500 characters. |

Relationship decision response fields:

| Field                                                        | Type   | Nullable | Description                             |
| :----------------------------------------------------------- | :----- | :------- | :-------------------------------------- |
| `data.clientRegistrationRequest.clientRegistrationRequestId` | string | no       | Registration request ID when available. |
| `data.clientRegistrationRequest.clientId`                    | string | no       | Client ID.                              |
| `data.clientRegistrationRequest.status`                      | string | no       | `approved` or `rejected`.               |
| `data.clientRegistrationRequest.relationshipStatus`          | string | no       | Updated relationship status.            |
| `data.clientRegistrationRequest.memberStatus`                | string | no       | Updated member status.                  |
| `data.clientRegistrationRequest.requestedAt`                 | string | yes      | Original request timestamp.             |
| `data.clientRegistrationRequest.reviewedAt`                  | string | yes      | Decision timestamp.                     |
| `data.clientRegistrationRequest.reviewMessage`               | string | no       | Public review message.                  |

Not exposed: reviewed-by coach ID, raw approval helper payloads, auth tokens,
password state, registration source internals, private notes, or deletion
metadata.

## Custom field definitions

```http theme={null}
GET /public/v1/client-custom-fields
Authorization: Bearer <access_token>
```

Required scope:

```txt theme={null}
client_custom_fields:read
```

Returns active custom fields that are visible on the client page. Hidden fields,
inactive fields, usage flags, internal update metadata, and validation regex
patterns are not exposed.

Definition fields:

| Field                                      | Type   | Nullable | Description                                                    |
| :----------------------------------------- | :----- | :------- | :------------------------------------------------------------- |
| `data.customFields[].key`                  | string | no       | Stable custom field key.                                       |
| `data.customFields[].label.en`             | string | no       | English label.                                                 |
| `data.customFields[].label.nl`             | string | no       | Dutch label.                                                   |
| `data.customFields[].type`                 | string | no       | `text`, `textarea`, `number`, `date`, `select`, or `checkbox`. |
| `data.customFields[].mode`                 | string | no       | `optional` or `required`.                                      |
| `data.customFields[].order`                | number | no       | Display order.                                                 |
| `data.customFields[].placeholder.en`       | string | no       | English placeholder.                                           |
| `data.customFields[].placeholder.nl`       | string | no       | Dutch placeholder.                                             |
| `data.customFields[].helpText.en`          | string | no       | English help text.                                             |
| `data.customFields[].helpText.nl`          | string | no       | Dutch help text.                                               |
| `data.customFields[].validation.minLength` | number | yes      | Minimum text length.                                           |
| `data.customFields[].validation.maxLength` | number | yes      | Maximum text length.                                           |
| `data.customFields[].validation.min`       | number | yes      | Minimum numeric value.                                         |
| `data.customFields[].validation.max`       | number | yes      | Maximum numeric value.                                         |
| `data.customFields[].options[].value`      | string | no       | Select option value.                                           |
| `data.customFields[].options[].label.en`   | string | no       | English option label.                                          |
| `data.customFields[].options[].label.nl`   | string | no       | Dutch option label.                                            |
| `data.customFields[].options[].order`      | number | no       | Option order.                                                  |

## Client custom field values

```http theme={null}
GET /public/v1/clients/{clientId}/custom-fields
PATCH /public/v1/clients/{clientId}/custom-fields
```

Required scopes: `client_custom_fields:read` for reads,
`client_custom_fields:write` for writes.

Patch body:

```json theme={null}
{
  "customFieldValues": [
    { "key": "shirt_size", "value": "m" }
  ]
}
```

Value response fields:

| Field                           | Type                  | Nullable | Description                                                      |
| :------------------------------ | :-------------------- | :------- | :--------------------------------------------------------------- |
| `data.clientId`                 | string                | no       | Client ID.                                                       |
| `data.customFields[].key`       | string                | no       | Custom field key.                                                |
| `data.customFields[].label.en`  | string                | no       | English label snapshot.                                          |
| `data.customFields[].label.nl`  | string                | no       | Dutch label snapshot.                                            |
| `data.customFields[].type`      | string                | no       | Field type.                                                      |
| `data.customFields[].value`     | string/number/boolean | yes      | Stored scalar value. Non-scalar values are JSON encoded strings. |
| `data.customFields[].updatedAt` | string                | yes      | Value update timestamp.                                          |

Validation:

| Input                                 | Rule                                                                                             |
| :------------------------------------ | :----------------------------------------------------------------------------------------------- |
| `clientId`                            | Must be connected to the authenticated company.                                                  |
| `customFieldValues` or `customFields` | Required array.                                                                                  |
| `customFieldValues[].key`             | Must match a configured, active, client-page custom field.                                       |
| `customFieldValues[].value`           | Validated against the stored field type, select options, required mode, and min/max constraints. |

## Assigned coaches

```http theme={null}
GET /public/v1/clients/{clientId}/assigned-coaches
PATCH /public/v1/clients/{clientId}/assigned-coaches
```

Required scopes: `clients:read` for reads, `clients:write` for writes.

Patch body:

```json theme={null}
{
  "coachId": "66f7b8b1e13c8d25f4d3d90a"
}
```

Set `coachId` to `null` or an empty value to clear the assignment.

Response fields:

| Field                             | Type   | Nullable | Description                                               |
| :-------------------------------- | :----- | :------- | :-------------------------------------------------------- |
| `data.clientId`                   | string | no       | Client ID.                                                |
| `data.primaryCoach.coachId`       | string | yes      | Assigned coach ID.                                        |
| `data.primaryCoach.name`          | string | yes      | Assigned coach name.                                      |
| `data.primaryCoach.imageUrl`      | string | no       | Coach image URL when configured.                          |
| `data.assignedCoaches[]`          | array  | no       | Array containing the current primary coach when assigned. |
| `data.assignedCoaches[].coachId`  | string | no       | Assigned coach ID.                                        |
| `data.assignedCoaches[].name`     | string | no       | Assigned coach name.                                      |
| `data.assignedCoaches[].imageUrl` | string | no       | Coach image URL when configured.                          |

Writes validate that the coach exists and has active access to the company.
Coach email, phone number, last active data, roles, and auth/device fields are
not returned.

## Client timeline

```http theme={null}
GET /public/v1/clients/{clientId}/timeline?page=1&limit=50
Authorization: Bearer <access_token>
```

Required scope:

```txt theme={null}
client_timeline:read
```

Timeline is intentionally limited to sanitized lifecycle events. It excludes
notes, provider payloads, workout/nutrition events, raw audit logs, payment
provider data, deletion internals, and retention details.

Response fields:

| Field                         | Type    | Nullable | Description                                                                                 |
| :---------------------------- | :------ | :------- | :------------------------------------------------------------------------------------------ |
| `data.page`                   | integer | no       | Current page.                                                                               |
| `data.limit`                  | integer | no       | Page size after cap, default `50`, maximum `100`.                                           |
| `data.total`                  | integer | no       | Total timeline events.                                                                      |
| `data.totalPages`             | integer | no       | Total page count.                                                                           |
| `data.hasNextPage`            | boolean | no       | Whether a next page exists.                                                                 |
| `data.hasPrevPage`            | boolean | no       | Whether a previous page exists.                                                             |
| `data.timeline[].type`        | string  | no       | Event type, such as `client_created`, `relationship_started`, `archived`, or `reactivated`. |
| `data.timeline[].occurredAt`  | string  | yes      | Event timestamp.                                                                            |
| `data.timeline[].title`       | string  | no       | Human-readable title.                                                                       |
| `data.timeline[].description` | string  | no       | Public description when configured.                                                         |

Validation:

| Input      | Rule                                            |
| :--------- | :---------------------------------------------- |
| `clientId` | Must be connected to the authenticated company. |
| `page`     | Integer, minimum `1`.                           |
| `limit`    | Integer, default `50`, maximum `100`.           |

<Warning>
  `DELETE /public/v1/clients/{clientId}` is not exposed in v1. Client removal
  touches identity, memberships, payments, appointments, and connected company
  state, so it needs an explicit archive/removal contract rather than generic
  CRUD delete semantics.
</Warning>

## Client tags

```http theme={null}
GET /public/v1/client-tags
Authorization: Bearer <access_token>
```

Required scope:

```txt theme={null}
clients:read
```

Use this endpoint to fetch tag IDs before creating or updating clients.

Response fields:

| Field                      | Type    | Nullable | Description                                                          |
| :------------------------- | :------ | :------- | :------------------------------------------------------------------- |
| `data.data[].tagId`        | string  | no       | Company tag ID.                                                      |
| `data.data[].name`         | string  | no       | Tag name.                                                            |
| `data.data[].color`        | string  | no       | Tag color.                                                           |
| `data.data[].totalClients` | integer | no       | Number of visible company-client relations currently using this tag. |

Deleted tags, hidden relationship statuses, and removed client outcomes are not
included in the counts.
