---
title: "Client Portals"
description: "Create and administer branded client portals over the ThunderPhone API, including agent scope, viewers, logos, custom domains, and DNS verification."
---

Client portals give external viewers a branded, read-only call-history
experience for a selected set of agents. The management API is organization
scoped and requires an **admin or owner** authenticated with an `sk_live_` key,
except for viewer changes: adding, re-enabling, or removing a viewer grants or
withdraws a login to your call history, so it needs a signed-in admin session
(`Authorization: Token <session-token>` plus `X-ThunderPhone-Org`) and returns
`403` to an API key. Listing viewers still works with a key. See
[What API keys can't do](/api-reference/introduction#what-api-keys-cant-do).

Viewer login, sessions, call browsing, transcripts, and audio use the hosted
portal's separate viewer authentication. They are not `sk_live_` integration
endpoints.

## Endpoints

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/client-portals` | List portals. |
| `POST` | `/v1/client-portals` | Create a portal. |
| `GET` | `/v1/client-portals/{portal_id}` | Retrieve a portal. |
| `PATCH` | `/v1/client-portals/{portal_id}` | Update details, active state, branding color, or agents. |
| `DELETE` | `/v1/client-portals/{portal_id}` | Permanently delete a portal. |
| `POST` | `/v1/client-portals/{portal_id}/logo` | Upload or replace a logo. |
| `DELETE` | `/v1/client-portals/{portal_id}/logo` | Remove the logo. |
| `GET` / `POST` | `/v1/client-portals/{portal_id}/viewers` | List or add viewers. Adding needs a signed-in session. |
| `PATCH` / `DELETE` | `/v1/client-portals/{portal_id}/viewers/{viewer_id}` | Activate/deactivate or delete a viewer. Needs a signed-in session. |
| `GET` / `POST` | `/v1/client-portals/{portal_id}/domains` | List or add custom domains. |
| `DELETE` | `/v1/client-portals/{portal_id}/domains/{domain_id}` | Remove a custom domain. |
| `POST` | `/v1/client-portals/{portal_id}/domains/{domain_id}/verify` | Check the ownership TXT record. |

## Portal object

```json
{
  "id": 18,
  "name": "Acme Dental",
  "slug": "acme-dental",
  "accent_color": "#2e8fff",
  "is_active": true,
  "logo_url": "https://…",
  "agent_ids": [12, 19],
  "agent_count": 2,
  "viewer_count": 3,
  "calls_last_30d": 147,
  "created_at": "2026-07-16T18:24:10.113Z",
  "updated_at": "2026-08-05T09:40:12.551Z"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | Portal id used by management endpoints. |
| `name` | string | Client-facing name, maximum 255 characters. |
| `slug` | string | Globally unique hosted URL slug, maximum 100 characters. |
| `accent_color` | string | Empty for the default, or a full six-digit hex color. |
| `is_active` | boolean | Inactive portals cannot be opened by viewers. |
| `logo_url` | string \| null | Temporary or hosted URL for the current logo. |
| `agent_ids` | array of integer | Agents whose calls are in the portal scope. |
| `agent_count`, `viewer_count` | integer | Current related-resource counts. |
| `calls_last_30d` | integer | Calls from assigned agents in the preceding 30 days. |
| `created_at`, `updated_at` | timestamp | ISO 8601 timestamps. |

## List and create portals

```bash
curl https://api.thunderphone.com/v1/client-portals \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

The list is sorted by name, then id.

```bash
curl -X POST https://api.thunderphone.com/v1/client-portals \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Dental","slug":"acme-dental"}'
```

| Field | Required | Validation |
| --- | --- | --- |
| `name` | yes | Non-empty after trimming; maximum 255 characters. |
| `slug` | no | Maximum 100 characters. Lowercase letters, numbers, and single hyphens only; must be unique and not reserved. A unique slug is generated from `name` when omitted. |

Returns `201 Created` with the portal object.

## Retrieve, update, and delete

`PATCH /v1/client-portals/{portal_id}` accepts any subset of:

| Field | Type | Behavior |
| --- | --- | --- |
| `name` | string | Replaces the client-facing name. |
| `slug` | string | Replaces the hosted URL slug after uniqueness validation. |
| `is_active` | boolean | Enables or disables all viewer access. |
| `accent_color` | string | Six-digit hex such as `#2e8fff`, or `""` for the default. |
| `agent_ids` | array of integer | Replaces the complete assigned-agent set. Duplicates are removed; every id must belong to the organization. Use `[]` for no visible calls. |

```bash
curl -X PATCH https://api.thunderphone.com/v1/client-portals/18 \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_ids":[12,19],"accent_color":"#2e8fff"}'
```

`DELETE` returns `204 No Content` and permanently removes the portal, its
viewers, domain records, and logo reference. It does not delete agents or calls.

## Upload or remove a logo

Upload multipart form field `file`. The accepted formats are **PNG**, **JPEG**,
and **WebP**, with a maximum size of **2 MB**. The file contents must match the
declared MIME type.

```bash
curl -X POST https://api.thunderphone.com/v1/client-portals/18/logo \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -F "file=@acme-logo.png"
```

The response is `{"logo_url":"https://…"}`. Uploading a new logo replaces
the old one. `DELETE` removes it and returns `204`.

## Viewers

The viewer object is:

```json
{
  "id": 31,
  "email": "client@example.com",
  "is_active": true,
  "has_password": false,
  "created_at": "2026-08-05T09:40:12.551Z",
  "updated_at": "2026-08-05T09:40:12.551Z"
}
```

Add a unique email address from a signed-in admin session:

```bash
curl -X POST https://api.thunderphone.com/v1/client-portals/18/viewers \
  -H "Authorization: Token YOUR_SESSION_TOKEN" \
  -H "X-ThunderPhone-Org: 42" \
  -H "Content-Type: application/json" \
  -d '{"email":"client@example.com"}'
```

Returns `201`. `PATCH` accepts only `is_active`; setting it to `false` blocks
login without deleting the viewer. `DELETE` returns `204`.

## Custom domains

Create a domain with its hostname only:

```bash
curl -X POST https://api.thunderphone.com/v1/client-portals/18/domains \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"hostname":"calls.client.com"}'
```

Returns `201`:

```json
{
  "id": 7,
  "hostname": "calls.client.com",
  "verification_token": "tp_verify_…",
  "status": "pending",
  "verified_at": null,
  "cname_record": {
    "name": "calls.client.com",
    "value": "CNAME_TARGET_FROM_RESPONSE"
  }
}
```

The hostname is normalized, can be at most 253 characters, and must be unique
across portals. Add the returned CNAME, then add this TXT record:

| Type | Name | Value |
| --- | --- | --- |
| TXT | `_tp-portal-verify.calls.client.com` | The returned `verification_token`. |

Check it with:

```bash
curl -X POST https://api.thunderphone.com/v1/client-portals/18/domains/7/verify \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

On success, the domain object has `status: "active"` and `verified_at`. While
the TXT record is not visible, the response remains pending and repeats the
expected TXT and CNAME records. DNS propagation can take several minutes.

`DELETE /domains/{domain_id}` returns `204` and releases the hostname for reuse.

## Errors and authorization

| Status | Meaning |
| --- | --- |
| `400` | Invalid slug, color, agent list, upload, email, or hostname. |
| `401` | Missing or invalid API key. |
| `403` | The authenticated user or key is not authorized for admin management, or an API key tried to change a viewer. |
| `404` | Portal or nested resource is outside the current organization or absent. |
| `204` | Successful deletion. |

For the dashboard workflow and viewer experience, see
[Client portals](/guides/client-portals).
