# Berlay Complete API Documentation

> Source: https://docs.berlay.io | Generated for LLM consumption

---

# Quick Start

> **Base URL:** `https://api.berlay.app`

Berlay provides two ways to integrate with external systems:

1. **Custom Webhooks** — push events into Berlay from your app using a per-channel API key
2. **REST API v1** — read and write conversations, messages, contacts, and ratings programmatically

---

## Overview

Each Berlay workspace has an independent data model. All API access is scoped to a single workspace identified by its `orgSlug`. Conversations, contacts, and messages live within that workspace and are not shared across workspaces.

---

## Authentication

### Custom webhook API key

Obtained from **Admin → Integrations → Custom / API → Connect**. Pass it as a request header:

```http
POST /api/webhooks/custom/{channelId}
Content-Type: application/json
x-berlay-api-key: brly_your_api_key_here
```

API keys are stored encrypted. If a key is compromised, regenerate it from the integration manage page — the previous key is immediately invalidated.

### REST API Bearer token

Created under **Admin → API** in your organization settings. Pass it as an Authorization header:

```http
Authorization: Bearer brly_sk_your_key_here
```

API keys are prefixed with `brly_sk_` and are 48 characters long. The full key is shown **only once** at creation time — only a SHA-256 hash is stored. Store keys in a secrets manager, not in source code.

---

## Your first request

**Step 1:** Create a Custom / API integration
Go to **Admin → Integrations → Custom / API → Connect**, enter a name, and click Connect.

**Step 2:** Copy your credentials
After connecting you will see your API key (shown once — copy it immediately) and your inbound webhook URL.

**Step 3:** Send a test event

```http
POST https://api.berlay.app/api/webhooks/custom/123
Content-Type: application/json
x-berlay-api-key: brly_your_api_key_here

{
  "messageId": "msg_001",
  "conversationId": "session_abc",
  "from": {
    "externalId": "user_42",
    "name": "Jane Smith",
    "email": "jane@example.com"
  },
  "subject": "Help with my order",
  "content": "Hi, I placed an order but haven't received a confirmation."
}
```

The conversation appears in your Berlay inbox within seconds. The endpoint always returns `200 OK` — check the channel event log for delivery status.

---

## Rate limits

| Endpoint | Limit |
|----------|-------|
| `POST /api/auth/magic-link` | 5 requests / minute per IP |
| All other endpoints | 60 requests / second per IP |

Exceeding the rate limit returns `429 Too Many Requests`.

---

## Next steps

- [Custom Webhooks](/webhooks) — full inbound/outbound webhook reference
- [REST API v1](/api) — complete endpoint reference


---

# Custom Webhook Integration

Berlay's custom integration lets you push events from any external system into your workspace using a secure API key. No OAuth flow required — just a single HTTP endpoint and a header.

---

## Setup

1. Go to **Admin → Integrations → Custom / API → Connect**
2. Enter a name for the integration (e.g. "Discord Tickets")
3. Optionally enter an **outbound webhook URL** — where Berlay should POST agent replies back to your app
4. Click **Connect** — your API key is shown immediately. Copy it. You can always reveal it again from the manage page.

Your integration is live. Use the **inbound webhook URL** and **API key** shown on the manage page.

---

## Inbound webhook

Send a `POST` request to push messages into Berlay. Each unique `conversationId` becomes a separate conversation thread. Reusing a `conversationId` adds to an existing thread.

### Endpoint

```
POST https://api.berlay.app/api/webhooks/custom/{channelId}
```

### Headers

| Header | Required | Value |
|--------|----------|-------|
| `Content-Type` | Yes | `application/json` |
| `x-berlay-api-key` | Yes | Your channel API key (`brly_...`) |

### Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | **Yes** | Message body text. |
| `messageId` | string | Recommended | Unique ID for this message. Defaults to a random UUID — always send explicitly to prevent duplicates. |
| `conversationId` | string | Recommended | Groups messages into a thread. Reuse to add messages to an existing conversation. Defaults to a random UUID per message (i.e. every message creates a new thread). |
| `from.externalId` | string | Recommended | Stable identifier for the sender in your system. Defaults to `from.email` or `"api-user"`. |
| `from.name` | string | No | Display name of the sender. |
| `from.email` | string | No | Email address — used to match or create a contact. |
| `from.type` | string | No | `'customer'` (default), `'staff'`, or `'bot'`. Bot messages show with an indigo avatar and 'bot' badge in the agent inbox. |
| `contentType` | string | No | `text` (default) or `html`. |
| `subject` | string | No | Conversation subject. Only used on the first message of a thread. |
| `sentAt` | string | No | ISO-8601 timestamp. Defaults to time of receipt. |
| `participants` | array | No | Additional participants to add to the conversation. Each: `{ name?, email?, externalId?, role: 'cc'\|'observer' }`. Max 20. Only processed on conversation creation. |

### Example request

```http
POST https://api.berlay.app/api/webhooks/custom/123
Content-Type: application/json
x-berlay-api-key: brly_a1b2c3d4...

{
  "messageId": "discord_msg_1234567",
  "conversationId": "discord_thread_987654",
  "from": {
    "externalId": "discord_user_111222333",
    "name": "Jane Smith",
    "email": "jane@example.com"
  },
  "subject": "Need help with my order",
  "content": "Hi, I placed an order yesterday but haven't received a confirmation email."
}
```

### Response

```
HTTP/1.1 200 OK

ok
```

The endpoint always returns `200 OK` — even on auth failures or routing errors — to prevent webhook retry storms. Check the **channel event log** (Admin → Integrations → [your integration] → Recent Events) for per-event delivery status.

---

## Outbound events

When an agent replies or a conversation changes state, Berlay POSTs a JSON event to your configured outbound webhook URL.

### No auth header

Berlay does not send an authentication header to your outbound URL. To verify the source, use a secret in your URL (e.g. `?secret=xyz`) or validate by source IP. Outbound signature headers are planned for a future release.

### Event envelope

Every outbound request uses the same structure:

```json
{
  "event": "<event type>",
  "conversationId": "<your external conversation ID>",
  "timestamp": "2026-03-20T10:00:00.000Z",
  "data": { ... }
}
```

The `conversationId` field echoes back exactly what you sent in the inbound payload, so you can map it directly to your own thread/ticket ID.

### Event types

| Event | When fired | `data` fields |
|-------|-----------|-------------|
| `message.created` | Agent sends a reply | `content`, `authorName` |
| `conversation.resolved` | Conversation marked resolved | `status: "resolved"`, `resolvedAt` |
| `conversation.closed` | Conversation closed | `status: "closed"` |
| `conversation.reopened` | Conversation reopened | `status: "open"` |
| `conversation.assigned` | Agent assigned or unassigned | `assignedTo` (Berlay user ID or `null`) |
| `rating.submitted` | CSAT rating submitted | `rating` (1–5), `comment`, `ratedBy` |
| `rating.requested` | Berlay requests a CSAT rating | `reason` (`'auto'` or `'requested'`) |

Each event type can be individually toggled on/off from the integration manage page under **Permissions → Outbound events**.

### Examples

**Agent reply:**
```json
{
  "event": "message.created",
  "conversationId": "discord_thread_987654",
  "timestamp": "2026-03-20T10:05:00.000Z",
  "data": {
    "content": "Hello Jane! Your order is on its way.",
    "authorName": "Support Team"
  }
}
```

**Conversation resolved:**
```json
{
  "event": "conversation.resolved",
  "conversationId": "discord_thread_987654",
  "timestamp": "2026-03-20T10:10:00.000Z",
  "data": {
    "status": "resolved",
    "resolvedAt": "2026-03-20T10:10:00.000Z"
  }
}
```

**Rating submitted:**
```json
{
  "event": "rating.submitted",
  "conversationId": "discord_thread_987654",
  "timestamp": "2026-03-20T10:15:00.000Z",
  "data": {
    "rating": 5,
    "comment": "Super helpful, resolved in minutes!",
    "ratedBy": "jane@example.com"
  }
}
```

**Rating requested:**
```json
{
  "event": "rating.requested",
  "conversationId": "discord_thread_987654",
  "timestamp": "2026-03-20T10:12:00.000Z",
  "data": {
    "reason": "auto"
  }
}
```

---

## Inbound actions

In addition to sending messages, your backend can send **action requests** to the same inbound webhook URL. Actions change conversation state without posting a visible message.

Berlay detects actions by the presence of an `action` field. A request with `action` is never treated as a message.

### Endpoint

```
POST https://api.berlay.app/api/webhooks/custom/{channelId}
Content-Type: application/json
x-berlay-api-key: brly_your_key_here
```

### Action types

| `action` | Description | Extra fields |
|-----------|-------------|-------------|
| `conversation.resolve` | Mark conversation resolved | — |
| `conversation.close` | Close the conversation | — |
| `conversation.reopen` | Reopen a resolved or closed conversation | — |
| `conversation.assign` | Assign to an agent | `assignedTo`: Berlay user ID (integer) or `null` to unassign |
| `rating.submit` | Submit a CSAT rating | `rating`: 1–5 (required), `comment?`, `ratedBy?` |

Each action type can be individually toggled on/off from the integration manage page under **Permissions → Inbound actions**.

### Examples

**Resolve from your Discord bot when a thread is marked done:**
```json
{
  "action": "conversation.resolve",
  "conversationId": "discord_thread_987654"
}
```

**Submit a rating from a post-resolution survey:**
```json
{
  "action": "rating.submit",
  "conversationId": "discord_thread_987654",
  "rating": 5,
  "comment": "Issue resolved fast!",
  "ratedBy": "jane@example.com"
}
```

**Assign to a specific agent (Berlay user ID):**
```json
{
  "action": "conversation.assign",
  "conversationId": "discord_thread_987654",
  "assignedTo": 7
}
```

All actions are logged to the channel event log with status and any error reason. The webhook always returns `200 OK`.

---

## Inbound intents

Intents are requests from your integration asking Berlay to perform an action on its behalf. Unlike inbound actions (which directly change state), intents trigger Berlay-side workflows that result in outbound events.

Berlay detects intents by the presence of an `intent` field. A request with `intent` is never treated as a message or an action.

### Endpoint

Same as inbound webhook:
```
POST https://api.berlay.app/api/webhooks/custom/{channelId}
```

### Intent types

| `intent` | Description | Response |
|----------|-------------|----------|
| `rating.request` | Ask Berlay to send a `rating.requested` event back to your webhook | Fires `rating.requested` with `reason: 'requested'` |
| `updateinfo` | Push contact and conversation updates to Berlay | Updates name, email, avatarUrl, tags on contact; subject, priority on conversation |

### Example

```json
{
  "intent": "rating.request",
  "conversationId": "discord_thread_987654"
}
```

Berlay responds with `200 OK` and dispatches the `rating.requested` outbound event to your webhook URL.

> **Note:** `ratingEnabled` must be `true` in the integration config for rating intents to be processed.

### updateinfo example

```json
{
  "intent": "updateinfo",
  "conversationId": "discord_thread_987654",
  "data": {
    "name": "John Doe",
    "email": "john@example.com",
    "avatarUrl": "https://cdn.example.com/avatar.png",
    "subject": "Server performance issue"
  }
}
```

---

## Permissions

Each integration has two sets of granular toggles, configurable from **Admin → Integrations → [your integration] → Permissions**:

- **Inbound actions** — which action types the external app is permitted to trigger
- **Outbound events** — which event types Berlay will POST to your outbound URL

If both lists are left fully enabled (the default), all actions and events are permitted. Disabling an outbound event silently skips the dispatch — no error is returned. Disabling an inbound action causes the request to be rejected with an error logged to the event log.

---

## Intent confirmation

When `intentConfirmationEnabled` is `true` and an `aiVerificationUrl` is configured, Berlay calls your verification endpoint **before** executing agent-side status changes (resolve, close, reopen). This lets your integration veto actions with a reason shown to the agent.

### Flow

1. Agent clicks Resolve/Close/Reopen in the Berlay inbox
2. Berlay POSTs to your `aiVerificationUrl`:

```json
{
  "type": "intent.confirm",
  "intent": "conversation.resolve",
  "conversationId": "discord_thread_987654",
  "timestamp": "2026-03-20T10:15:00.000Z",
  "data": {}
}
```

3. Your endpoint responds:

```json
{ "allowed": true }
```
or
```json
{ "allowed": false, "reason": "Customer has an open billing dispute — resolve after dispute is settled." }
```

4. If `allowed: false`, Berlay returns HTTP 422 and shows the reason to the agent in the UI.
5. If your endpoint is unreachable, times out, or returns an error, **Berlay allows the action** (fail-open — agents are never blocked by integration downtime).

### Configuration

| Field | Default | Description |
|-------|---------|-------------|
| `intentConfirmationEnabled` | `false` | Enable intent confirmation |
| `aiVerificationUrl` | — | HTTPS URL Berlay calls before status changes |
| `aiVerificationTimeout` | `10` | Timeout in seconds (1–30) |

---

## Sync & monitoring

### Heartbeat

Your integration can periodically POST a heartbeat to tell Berlay it's alive. Connection health is shown on the integration manage page.

```
POST https://api.berlay.app/api/webhooks/custom/{channelId}/heartbeat
X-Berlay-Key: brly_your_key_here
```

**Response:**
```json
{ "ok": true, "receivedAt": "2026-03-20T10:00:00.000Z" }
```

Health status thresholds:
- **Healthy** — heartbeat within last 10 minutes
- **Degraded** — heartbeat within last 60 minutes
- **No heartbeat** — no heartbeat recorded, or older than 60 minutes

### Event replay

Fetch recent events your integration may have missed (e.g. after a restart):

```
GET https://api.berlay.app/api/webhooks/custom/{channelId}/events?since=2026-03-20T09:00:00Z&limit=50
X-Berlay-Key: brly_your_key_here
```

| Param | Default | Description |
|-------|---------|-------------|
| `since` | 24 hours ago | ISO-8601 timestamp — return events after this time |
| `limit` | 50 | Max events to return (1–100) |

**Response:**
```json
{
  "data": [
    { "id": 1, "eventType": "message.created", "status": "ok", "responseStatus": 200, "responseMs": 142, "createdAt": "..." },
    ...
  ],
  "since": "2026-03-20T09:00:00.000Z",
  "limit": 50
}
```

Heartbeats and monitor pings are excluded from results.

### Periodic monitoring

When `monitoringEnabled` is `true` (default), Berlay pings your webhook URL every 5 minutes with:

```json
{
  "type": "ping",
  "timestamp": "2026-03-20T10:05:00.000Z",
  "checkId": "550e8400-e29b-41d4-a716-446655440000"
}
```

Your endpoint should return any `2xx` response. Failures are logged in the event log and reflected in the connection health status on the manage page.

---

## Testing

From the integration manage page, use the **Test** section to:

- **Send test message** — creates a real test conversation in your inbox to verify inbound is working
- **Ping outbound webhook** — POSTs a test event to your outbound URL and shows the HTTP response code

---

## Event log

Every inbound request and outbound dispatch is logged. Access logs at **Admin → Integrations → [your integration] → Recent Events**.

| Field | Values |
|-------|--------|
| `status` | `ok` \| `error` \| `skipped` |
| `eventType` | `inbound_webhook`, `action:<type>`, or outbound event type |
| `error` | Error reason if status is `error` |
| `createdAt` | Timestamp |

---

## Security

### Outbound webhook validation

Berlay validates the outbound webhook URL before storing or sending to it:

- **HTTPS required** — `http://` URLs are rejected at configuration time with a `422` error
- **Private IP block** — The URL hostname is DNS-resolved (IPv4 only) and checked against all RFC private ranges: loopback (`127.x`, `::1`), RFC 1918 (`10.x`, `172.16–31.x`, `192.168.x`), link-local (`169.254.x` — AWS/GCP metadata), CGNAT (`100.64–127.x`), and IPv6 equivalents
- **No redirects** — Outbound requests use `redirect: error`; a redirect to an internal address is dropped
- **10 second timeout** — Outbound requests are abandoned after 10 seconds

Blocked deliveries are visible in the channel event log.

### Inbound request security

- Every inbound request is verified against the `x-berlay-api-key` header
- The key is compared directly against the stored value using constant-length string comparison
- Failed auth is logged as a `signature_mismatch` error in the event log
- The endpoint always returns `200 OK` — check the event log to diagnose auth failures

### API key storage

Channel API keys (`brly_` prefix) are stored **encrypted at rest** using a per-workspace data encryption key (DEK), wrapped with a master key. They are decryptable by the server, which is what allows the Reveal feature on the manage page. The plaintext key is accessible at any time by workspace admins and can be rotated on demand.

This is different from REST API keys (`brly_sk_` prefix) — those are stored as SHA-256 hashes and are not recoverable after creation.

---

## Categories and custom fields

Berlay supports custom conversation categories and fields, configurable per workspace. When sending inbound messages, you can assign a category and set field values directly in the payload.

### Discover available categories and fields

Before sending, query the fields discovery endpoint to see what categories and fields are configured for this workspace:

```http
GET https://api.berlay.app/api/webhooks/custom/{channelId}/fields
x-berlay-api-key: brly_your_key_here
```

**Response:**

```json
{
  "categories": [
    { "id": 1, "name": "Billing", "color": "blue", "description": "Billing and payment issues", "position": 0 },
    { "id": 2, "name": "Technical", "color": "red", "description": null, "position": 1 }
  ],
  "fields": [
    { "id": 1, "key": "order_id", "name": "Order ID", "type": "text", "categoryId": null, "required": false, "options": null },
    { "id": 2, "key": "priority", "name": "Priority", "type": "select", "categoryId": 1, "required": true, "options": ["Low", "Medium", "High"] }
  ]
}
```

`categoryId: null` on a field means it is **global** — it appears on all conversations regardless of category.

### Setting category and fields on inbound messages

Include `categoryId` and/or `fields` in the inbound payload when creating a conversation:

```json
{
  "messageId": "discord_msg_1234567",
  "conversationId": "discord_thread_987654",
  "from": { "externalId": "discord_user_111", "name": "Jane" },
  "content": "My order hasn't arrived",
  "categoryId": 1,
  "fields": {
    "order_id": "ORD-8842",
    "priority": "High"
  }
}
```

- `categoryId` is only applied when the conversation is first created. Follow-up messages on the same `conversationId` do not change the category.
- `fields` values are **upserted** — applied on every message. Useful for updating field values from later messages in the thread.
- Unknown field keys are silently ignored (forward-compatibility for fields added later).
- Field values in the payload take precedence over the integration's configured defaults.

### Integration defaults

On the integration manage page (**Admin → Integrations → [your integration] → Defaults**), you can configure:

- **Default category** — automatically assigned to every new conversation from this integration
- **Default field values** — pre-filled on every new conversation

Defaults are applied first; values in the inbound payload override them.

---

## Ephemeral content

When `ephemeralContent` is `true` in your integration config, Berlay treats media URLs in messages as temporary references that may expire. Instead of caching content, Berlay resolves fresh URLs on demand by calling your webhook.

### Content resolution flow

1. Agent opens a conversation containing media (images, files)
2. Berlay POSTs to your webhook URL:

```json
{
  "type": "content.resolve",
  "refs": ["https://cdn.example.com/img/abc123.png?token=expired"],
  "conversationId": "discord_thread_987654"
}
```

3. Your integration returns fresh URLs:

```json
{
  "urls": [
    {
      "ref": "https://cdn.example.com/img/abc123.png?token=expired",
      "url": "https://cdn.example.com/img/abc123.png?token=fresh&expires=3600",
      "expiresAt": "2026-03-21T11:00:00.000Z"
    }
  ]
}
```

4. Berlay renders the resolved URL in the inbox

### Configuration

| Field | Default | Description |
|-------|---------|-------------|
| `ephemeralContent` | `false` | Enable on-demand content resolution |

> **Use case:** Discord CDN links expire after a few hours. With ephemeral content enabled, your Discord bot can serve fresh signed URLs when agents view the conversation.

---

## Application settings reference

All configuration fields available per custom integration, managed from the integration manage page:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `webhookUrl` | string | — | Outbound webhook URL (HTTPS required) |
| `ratingEnabled` | boolean | `true` | Enable CSAT rating events |
| `ratingAutoRequest` | boolean | `false` | Auto-send `rating.requested` on conversation resolve |
| `relaySupportsAttachments` | boolean | `true` | Include attachment data in outbound events |
| `monitoringEnabled` | boolean | `true` | Berlay pings your webhook every 5 minutes |
| `intentConfirmationEnabled` | boolean | `false` | Call `aiVerificationUrl` before agent status changes |
| `aiVerificationUrl` | string | — | HTTPS endpoint for intent confirmation |
| `aiVerificationTimeout` | number | `10` | Confirmation timeout in seconds (1–30) |
| `typingIndicatorsEnabled` | boolean | `false` | Enable typing indicator API |
| `messageReadStatusEnabled` | boolean | `false` | Enable message read status API |
| `messageExportEnabled` | boolean | `false` | Include message history in outbound events |
| `messageExportDepth` | number | `100` | Number of messages to include (10–1000) |
| `platformConstraints.canReopen` | boolean | `true` | Platform supports reopening conversations |
| `platformConstraints.canReceiveFiles` | boolean | `true` | Platform supports file attachments |
| `platformConstraints.maxMessageLength` | number | — | Max message length in characters (null = unlimited) |
| `ephemeralContent` | boolean | `false` | Treat media URLs as temporary; resolve via webhook at view time |
| `backRequestsEnabled` | boolean | `false` | Allow Berlay to query your webhook for missing data (avatars, contact info) |


---

# REST API v1

> **Base URL:** `https://api.berlay.app/api/v1/{orgSlug}`

The Berlay REST API provides programmatic access to conversations, messages, contacts, ratings, and API key management within a workspace. All endpoints require a Bearer token and are scoped to a single organization.

---

## Authentication

Include your API key as a Bearer token on every request:

```http
Authorization: Bearer brly_sk_your_key_here
```

**Key format:** Keys are prefixed with `brly_sk_` followed by 40 hex characters (48 chars total). Generated with cryptographically secure randomness.

**Storage:** Only a SHA-256 hash of the key is stored server-side. The plaintext is shown once at creation time. If lost, revoke and create a new key.

**503 when API disabled:** If the REST API feature is disabled for your organization, all v1 endpoints return:

```json
{ "error": { "code": "UNAUTHORIZED", "message": "API access is not enabled for this organization." } }
```

---

## Scopes

Each API key is created with a set of scopes controlling which endpoints it may call. The org admin can further restrict which scopes any key in the org may hold.

| Scope | Description |
|-------|-------------|
| `conversations:read` | List and get conversations |
| `conversations:write` | Create and update conversations |
| `conversations:resolve` | Resolve, close, and reopen conversations |
| `messages:read` | Read messages within conversations |
| `messages:write` | Send messages to conversations |
| `contacts:read` | List and get contact records |
| `contacts:write` | Create and update contacts |
| `ratings:read` | Read conversation ratings |
| `ratings:write` | Submit conversation ratings |
| `secrets:read` | Read workspace secrets |

> **API key management** (`api_keys:read` / `api_keys:write`) requires an admin session cookie and is only accessible from the Admin UI — these operations cannot be performed by an API key.

---

## Response format

### Single resource

```json
{
  "data": { ... }
}
```

### List

```json
{
  "data": [ ... ],
  "total": 142,
  "limit": 25,
  "offset": 0
}
```

---

## Errors

All errors follow a consistent shape:

```json
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable description."
  }
}
```

| HTTP Status | Code | When |
|-------------|------|------|
| 401 | `UNAUTHORIZED` | Missing, malformed, or revoked Bearer token |
| 403 | `FORBIDDEN` | Token lacks the required scope for this endpoint |
| 404 | `NOT_FOUND` | Resource does not exist or belongs to a different org |
| 400 | `VALIDATION` | Request body failed validation; `message` describes the field |
| 500 | `INTERNAL` | Unexpected server error |
| 503 | `UNAUTHORIZED` | API feature is not enabled for this organization |

---

## Conversations

### List conversations

```http
GET /api/v1/{orgSlug}/conversations
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:read`

| Parameter | Type | Description |
|-----------|------|-------------|
| `status` | string | Filter by status: `open`, `resolved`, `closed`. Omit for all. |
| `assignedTo` | integer | Filter by agent user ID. |
| `limit` | integer | Max results to return. Default: `25`, max: `100`. |
| `offset` | integer | Pagination offset. Default: `0`. |

**Example response:**

```json
{
  "data": [
    {
      "id": 12,
      "subject": "Help with my order",
      "status": "open",
      "assignedTo": null,
      "contactId": 4,
      "channelId": 3,
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:35:00.000Z"
    }
  ],
  "total": 84,
  "limit": 25,
  "offset": 0
}
```

---

### Get conversation

```http
GET /api/v1/{orgSlug}/conversations/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:read`

---

### Create conversation

```http
POST /api/v1/{orgSlug}/conversations
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `conversations:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subject` | string | Yes | Conversation subject line. |
| `contactId` | integer | No | ID of an existing contact to associate. |
| `channelId` | integer | No | Channel to assign this conversation to. |
| `assignedTo` | integer | No | Agent user ID to assign immediately. |

---

### Resolve conversation

```http
POST /api/v1/{orgSlug}/conversations/{id}/resolve
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:resolve`

Fires a `conversation.resolved` outbound event to any connected custom integration.

---

### Close conversation

```http
POST /api/v1/{orgSlug}/conversations/{id}/close
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:resolve`

---

### Reopen conversation

```http
POST /api/v1/{orgSlug}/conversations/{id}/reopen
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:resolve`

---

## Messages

### List messages

```http
GET /api/v1/{orgSlug}/conversations/{id}/messages
Authorization: Bearer brly_sk_...
```

**Required scope:** `messages:read`

| Parameter | Type | Description |
|-----------|------|-------------|
| `limit` | integer | Max results. Default: `50`, max: `200`. |
| `offset` | integer | Pagination offset. Default: `0`. |

**Example response:**

```json
{
  "data": [
    {
      "id": 4,
      "conversationId": 12,
      "authorType": "customer",
      "content": "Hi, I placed an order but haven't received a confirmation.",
      "contentType": "text",
      "createdAt": "2026-01-15T10:30:00.000Z"
    },
    {
      "id": 5,
      "conversationId": 12,
      "authorType": "agent",
      "authorName": "Support Team",
      "content": "Hello! Let me look into that for you.",
      "contentType": "text",
      "createdAt": "2026-01-15T10:32:00.000Z"
    }
  ],
  "limit": 50,
  "offset": 0
}
```

### Pagination

Messages support cursor-based pagination:

| Param | Default | Description |
|-------|---------|-------------|
| `limit` | 30 | Max messages to return (1–100) |
| `before` | — | Message ID — return messages older than this (cursor) |

Messages are returned in chronological order (oldest first). To load earlier messages, pass the ID of the oldest message you have as `before`.

---

### Send message

```http
POST /api/v1/{orgSlug}/conversations/{id}/messages
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `messages:write`

Sending a message also fires a `message.created` outbound event to any connected custom integration on that conversation.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | **Yes** | Message body. |
| `contentType` | string | No | `text` (default) or `html`. |
| `author.name` | string | No | Display name shown in the inbox instead of "Agent". |
| `author.email` | string | No | Email of the external staff member. |
| `author.externalId` | string | No | Stable ID for this staff member in your system. |
| `author.type` | string | No | `'staff'` (default) or `'bot'`. Bot messages display with an indigo avatar and 'bot' badge in the inbox. |
| `ai` | boolean | No | When `true`, message content is rendered as GitHub Flavored Markdown in the inbox. Intended for AI-generated formatted responses. Default: `false`. |

**Example request:**

```json
{
  "content": "Your order has shipped! Tracking: 1Z999AA1234567890",
  "author": {
    "name": "Fulfillment Bot",
    "externalId": "bot_fulfillment"
  }
}
```

When `author` is omitted entirely, the message shows as "Agent". When `author: {}` is sent (empty object), it shows as "Staff". When `author.name` is provided, that name is shown.

---

## Typing indicators

Signal that someone is currently typing in a conversation. The indicator appears in the agent inbox in real time and auto-expires after 5 seconds.

### Set typing status

```http
POST /api/v1/{orgSlug}/conversations/{id}/typing
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `messages:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `typing` | boolean | **Yes** | `true` to show indicator, `false` to clear it. |
| `name` | string | No | Display name for the typing indicator. Default: `'Staff'` or `'Bot'` based on role. |
| `role` | string | No | `'customer'` (default), `'bot'`, or `'staff'`. |

**Example:**
```json
{
  "typing": true,
  "name": "Support Bot",
  "role": "bot"
}
```

The indicator auto-expires after 5 seconds. Send `{ "typing": true }` periodically to keep it alive, or `{ "typing": false }` to clear it immediately.

> **Note:** `typingIndicatorsEnabled` must be `true` in the integration's platform constraints for this endpoint to be available.

---

## Read status

Mark messages as read by the customer or external party. Read receipts appear as checkmarks on outbound messages in the agent inbox.

### Mark conversation as read

```http
POST /api/v1/{orgSlug}/conversations/{id}/read
Authorization: Bearer brly_sk_...
```

**Required scope:** `messages:write`

Marks all unread messages in the conversation as read. Idempotent — already-read messages are not affected.

**Response:**
```json
{ "ok": true, "readAt": "2026-03-20T10:15:00.000Z" }
```

### Mark single message as read

```http
POST /api/v1/{orgSlug}/conversations/{id}/messages/{msgId}/read
Authorization: Bearer brly_sk_...
```

**Required scope:** `messages:write`

**Response:**
```json
{ "data": { "id": 42, "readAt": "2026-03-20T10:15:00.000Z" } }
```

> **Note:** `messageReadStatusEnabled` must be `true` in the integration's platform constraints for read status to appear in the UI.

---

## Secrets

Workspace-scoped secrets stored encrypted at rest. Useful for storing API keys, tokens, or credentials that your integration needs.

### List secrets

```http
GET /api/v1/{orgSlug}/secrets
Authorization: Bearer brly_sk_...
```

**Required scope:** `secrets:read`

Returns secret metadata only (name, key, description). Values are never returned in list responses.

### Get secret value

```http
GET /api/v1/{orgSlug}/secrets/{key}
Authorization: Bearer brly_sk_...
```

**Required scope:** `secrets:read`

**Response:**
```json
{ "data": { "key": "STRIPE_API_KEY", "value": "sk_live_..." } }
```

> Secrets are decrypted on-demand using the workspace's data encryption key. Access is logged.

---

## Contacts

### List contacts

```http
GET /api/v1/{orgSlug}/contacts
Authorization: Bearer brly_sk_...
```

**Required scope:** `contacts:read`

| Parameter | Type | Description |
|-----------|------|-------------|
| `search` | string | Full-text search across name, email, and externalId. |
| `limit` | integer | Max results. Default: `25`, max: `100`. |
| `offset` | integer | Pagination offset. Default: `0`. |

---

### Get contact

```http
GET /api/v1/{orgSlug}/contacts/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `contacts:read`

---

### Create contact

```http
POST /api/v1/{orgSlug}/contacts
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `contacts:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `externalId` | string | No | Stable ID from your system. Must be unique per org. |
| `name` | string | No | Display name. |
| `email` | string | No | Email address. Must be unique per org if provided. |

---

### Update contact

```http
PATCH /api/v1/{orgSlug}/contacts/{id}
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `contacts:write`

---

## Ratings

### Submit rating

```http
POST /api/v1/{orgSlug}/conversations/{id}/rate
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `ratings:write`

If a rating already exists for the conversation, it is overwritten (upsert).

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `rating` | integer | **Yes** | Score from `1` (worst) to `5` (best). |
| `comment` | string | No | Optional free-text feedback. |
| `ratedBy` | string | No | Identifier of the person submitting — customer email or externalId. |

**Example request:**

```json
{
  "rating": 5,
  "comment": "Very helpful, resolved quickly!",
  "ratedBy": "jane@example.com"
}
```

---

## API Keys

> **Admin session required.** Key management uses your Berlay admin session cookie, not a Bearer token. These endpoints are intended for the Admin UI — not for programmatic use by integrations.

### List API keys

```http
GET /api/v1/{orgSlug}/keys
```

Returns all active (non-revoked) keys for the org. The full key value is never returned — only the prefix (first 16 characters).

---

### Create API key

```http
POST /api/v1/{orgSlug}/keys
Content-Type: application/json
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Human-readable label for this key. |
| `scopes` | string[] | **Yes** | Array of scope strings. Must be non-empty and valid. |

**Response:** The full plaintext key (`key` field) is included **only in this response** and is not stored. Copy it immediately.

---

### Revoke API key

```http
DELETE /api/v1/{orgSlug}/keys/{id}
```

Immediately invalidates the key. Any in-flight requests using it will receive `401`.

---

## Conversation field values

Read and update the custom field values attached to a conversation.

### Get field values

```http
GET /api/v1/{orgSlug}/conversations/{id}/fields
Authorization: Bearer brly_sk_...
```

**Required scope:** `messages:read`

**Example response:**
```json
{
  "data": [
    { "fieldId": 1, "key": "order_id", "name": "Order ID", "type": "text", "value": "ORD-8842" },
    { "fieldId": 2, "key": "priority", "name": "Priority", "type": "select", "value": "High" }
  ]
}
```

### Set field values

```http
PATCH /api/v1/{orgSlug}/conversations/{id}/fields
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `conversations:write`

Body: an object mapping field keys to values. Unknown keys are silently ignored.

```json
{
  "fields": {
    "order_id": "ORD-9001",
    "priority": "Low"
  }
}
```

Returns the updated field values in the same shape as GET.

---

## Categories

Conversation categories let you tag conversations with a type (e.g. Billing, Technical). Categories are org-scoped and managed in **Admin → Categories**.

### List categories

```http
GET /api/v1/{orgSlug}/categories
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:read`

**Example response:**
```json
{
  "data": [
    { "id": 1, "name": "Billing", "color": "blue", "description": "Billing and payment issues", "position": 0 },
    { "id": 2, "name": "Technical", "color": "red", "description": null, "position": 1 }
  ]
}
```

### Create category

```http
POST /api/v1/{orgSlug}/categories
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `conversations:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Category name. Must be unique per org. |
| `color` | string | No | Color token. One of: `slate`, `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`. Default: `slate`. |
| `description` | string | No | Optional description. |

### Update category

```http
PATCH /api/v1/{orgSlug}/categories/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:write`

Accepts any subset of `name`, `color`, `description`, `position`.

### Delete category

```http
DELETE /api/v1/{orgSlug}/categories/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:write`

Deleting a category also deletes all fields scoped to it and clears `category_id` on any conversations that used it.

---

## Custom fields

Custom fields add structured data to conversations. Fields are defined per org and optionally scoped to a category.

### List fields for a category

```http
GET /api/v1/{orgSlug}/categories/{id}/fields
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:read`

Returns fields scoped to this category **and** global fields (`categoryId: null`), ordered by position.

**Example response:**
```json
{
  "data": [
    { "id": 1, "key": "order_id", "name": "Order ID", "type": "text", "categoryId": null, "required": false, "options": null },
    { "id": 2, "key": "priority", "name": "Priority", "type": "select", "categoryId": 3, "required": true, "options": ["Low", "Medium", "High"] }
  ]
}
```

### Create field

```http
POST /api/v1/{orgSlug}/categories/{id}/fields
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `conversations:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Display name shown in the UI. |
| `key` | string | **Yes** | Machine-readable key used in API payloads. Must match `/^[a-z][a-z0-9_]*$/`, unique per org. Immutable after creation. |
| `type` | string | No | `text` (default), `number`, `select`, `boolean`, or `date`. Immutable after creation. |
| `options` | string[] | Required if type=select | Option labels for select fields. |
| `required` | boolean | No | Whether a value is expected. Default: `false`. |

### Update field

```http
PATCH /api/v1/{orgSlug}/fields/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:write`

Accepts `name`, `options`, `required`, `position`. `key` and `type` are immutable.

### Delete field

```http
DELETE /api/v1/{orgSlug}/fields/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:write`

Deletes the field definition and all stored values for it across every conversation.

---

## Message editing

Edit a message's content. The original content is preserved in version history.

### Edit a message

```http
PATCH /api/v1/{orgSlug}/conversations/{id}/messages/{msgId}
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `messages:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | string | **Yes** | New message content (1–50,000 chars). |
| `reason` | string | No | Optional reason for the edit (max 255 chars). Stored in version history. |

**Response:**
```json
{
  "data": {
    "id": 42,
    "content": "Updated message text",
    "editedAt": "2026-03-20T10:30:00.000Z",
    "editCount": 2
  }
}
```

Edited messages show an "(edited)" indicator in the inbox. Clicking it reveals the full version history. Berlay retains up to 50 previous versions per message.

---

## Conversation participants

Add additional participants (CC or observer) to a conversation beyond the primary contact.

### List participants

```http
GET /api/v1/{orgSlug}/conversations/{id}/participants
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:read`

### Add participant

```http
POST /api/v1/{orgSlug}/conversations/{id}/participants
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `conversations:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | Display name |
| `email` | string | No | Email address |
| `externalId` | string | No | External identifier |
| `role` | string | **Yes** | `'cc'` or `'observer'` |

Max 20 participants per conversation. The `'primary'` role is reserved for the main contact.

**Response:**
```json
{
  "data": {
    "id": 7,
    "conversationId": 123,
    "name": "Jane Support",
    "email": "jane@company.com",
    "role": "cc",
    "addedAt": "2026-03-20T10:00:00.000Z"
  }
}
```

Participants can also be added via the inbound webhook by including a `participants` array in the message payload.

---

## Ephemeral content resolution

For integrations with expiring media URLs (e.g. Discord CDN), the content resolver endpoint allows the inbox to fetch fresh URLs on demand.

### Resolve content references

```http
POST /api/v1/{orgSlug}/conversations/{id}/content
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `messages:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `urls` | array | **Yes** | Array of `{ ref, url, expiresAt? }` — max 50. `ref` is the original URL reference, `url` is the fresh resolved URL. |

When `ephemeralContent: true` is configured, Berlay will call your webhook with `type: 'content.resolve'` to request fresh URLs at view time.

---

## Contact avatars

Set or clear a contact's profile picture URL.

### Set avatar

```http
PATCH /api/v1/{orgSlug}/contacts/{id}/avatar
Authorization: Bearer brly_sk_...
Content-Type: application/json
```

**Required scope:** `contacts:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | **Yes** | HTTPS image URL. SVG, data: URLs, and private IPs are rejected. |

### Clear avatar

```http
DELETE /api/v1/{orgSlug}/contacts/{id}/avatar
Authorization: Bearer brly_sk_...
```

The avatar appears on customer messages in the inbox.

---

## Plan tiers

Some API features are gated by plan:

| Plan | Price | AI features |
|------|-------|-------------|
| Free | $0/mo | Basic inbox, no AI |
| Pro | $9/mo | AI reply polish, suggestions |
| Business | $69/mo | All AI features: cleanup, translate, trends, handbrake, custom automations |

Plan-gated endpoints return `403` with an error message when accessed on an insufficient plan.

---

## AI operations

Berlay provides AI-powered operations for conversations. All endpoints are session-authenticated (agent role) and plan-gated.

### Polish reply

```http
POST /api/{orgSlug}/conversations/{id}/ai/polish
```

**Plan:** Pro+ | **Rate limit:** 30/hour

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `draft` | string | **Yes** | The draft reply text to improve. |

**Response:** `{ data: { content: string, tokensUsed: number } }`

Improves grammar, clarity, and tone of the agent's draft. Conversation context is included automatically.

### Suggest reply

```http
POST /api/{orgSlug}/conversations/{id}/ai/suggest
```

**Plan:** Pro+ | **Rate limit:** 30/hour

No request body needed. AI generates a suggested reply based on conversation history (last 20 messages).

**Response:** `{ data: { content: string, tokensUsed: number } }`

### Rename conversation

```http
POST /api/{orgSlug}/conversations/{id}/ai/rename
```

**Plan:** Pro+ | **Rate limit:** 10/hour | **Cached:** 1 hour

Generates a concise subject line and updates the conversation. Also fires automatically on resolve if enabled in AI settings.

### Summarize conversation

```http
POST /api/{orgSlug}/conversations/{id}/ai/summarize
```

**Plan:** Business+ | **Rate limit:** 10/hour | **Cached:** 4 hours

Generates a structured summary. Also fires automatically on resolve if enabled in AI settings.

### Cleanup text

```http
POST /api/{orgSlug}/conversations/{id}/ai/cleanup
```

**Plan:** Business+ | **Rate limit:** 20/hour

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `text` | string | **Yes** | Customer message text to clean up. |

Fixes grammar, spelling, and punctuation while preserving meaning. Useful for tidying poorly written customer messages.

### Translate text

```http
POST /api/{orgSlug}/conversations/{id}/ai/translate
```

**Plan:** Business+ | **Rate limit:** 20/hour

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `text` | string | **Yes** | Text to translate. |
| `targetLang` | string | No | Target language (default: English). |

### Handbrake safety check

```http
POST /api/{orgSlug}/conversations/{id}/ai/handbrake
```

**Plan:** Business+ | **Rate limit:** 60/hour

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `message` | string | **Yes** | Outgoing agent message to check. |

**Response:**
```json
{
  "data": {
    "safe": false,
    "issues": [
      {
        "type": "rudeness",
        "description": "Message contains dismissive language.",
        "suggestion": "I understand your frustration. Let me look into this for you."
      }
    ]
  }
}
```

Fails open: if the AI is unreachable, `{ safe: true, issues: [] }` is returned.

---

## Bulk import

Import historical conversations and messages from another platform.

### Import conversations

```http
POST /api/v1/{orgSlug}/import/conversations
Authorization: Bearer brly_sk_...
```

**Required scope:** `conversations:write` | **Max:** 100 per request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `channelId` | number | **Yes** | Target channel ID. |
| `conversations` | array | **Yes** | Array of conversation objects. |
| `conversations[].externalId` | string | **Yes** | Unique external ID for dedup. |
| `conversations[].subject` | string | No | Conversation subject. |
| `conversations[].status` | string | No | `open`, `resolved`, or `closed`. Default: `open`. |
| `conversations[].contact` | object | **Yes** | Contact: `{ externalId, name?, email?, avatarUrl? }`. |
| `conversations[].createdAt` | string | No | ISO-8601 timestamp. Preserves original date. |
| `conversations[].resolvedAt` | string | No | ISO-8601 timestamp. |

**Response:** `{ data: { imported: 5, skipped: 2, errors: 0, total: 7 } }`

### Import messages

```http
POST /api/v1/{orgSlug}/import/messages
Authorization: Bearer brly_sk_...
```

**Required scope:** `messages:write` | **Max:** 500 per request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `channelId` | number | **Yes** | Channel ID containing the conversations. |
| `messages` | array | **Yes** | Array of message objects. |
| `messages[].conversationExternalId` | string | **Yes** | External ID of the parent conversation. |
| `messages[].externalId` | string | No | Message external ID for dedup. |
| `messages[].content` | string | **Yes** | Message body. |
| `messages[].authorType` | string | No | `customer`, `agent`, `bot`, `api`, `system`. Default: `customer`. |
| `messages[].authorName` | string | No | Display name of the sender. |
| `messages[].sentAt` | string | No | ISO-8601 timestamp. Preserves original date. |

**Response:** `{ data: { imported: 50, skipped: 3, errors: 0, total: 53 } }`

---

## Staff identity links

Map external platform user IDs to Berlay team members. When a staff message arrives via webhook, Berlay auto-attributes it to the linked agent.

### List links

```http
GET /api/v1/{orgSlug}/staff-links?channelId=N
Authorization: Bearer brly_sk_...
```

**Required scope:** `config:write`

### Create link

```http
POST /api/v1/{orgSlug}/staff-links
Authorization: Bearer brly_sk_...
```

**Required scope:** `config:write`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `channelId` | number | **Yes** | Channel this mapping applies to. |
| `userId` | number | **Yes** | Berlay user ID of the team member. |
| `externalId` | string | **Yes** | External platform user ID. |
| `platformName` | string | No | Platform name (e.g. "Discord"). |
| `displayName` | string | No | Display name on the external platform. |

### Delete link

```http
DELETE /api/v1/{orgSlug}/staff-links/{id}
Authorization: Bearer brly_sk_...
```

**Required scope:** `config:write`

---

## Back-requests

Trigger Berlay to query your integration for missing data.

```http
POST /api/{orgSlug}/conversations/{id}/backrequest
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | **Yes** | Query type: `query.contact.avatar`, `query.contact.info`, `query.message.content`, `query.conversation.metadata`. |
| `data` | object | No | Additional query parameters. |

When `backRequestsEnabled` is true, Berlay also auto-queries on conversation open for missing avatars and contact info (rate-limited to 5/type/contact/24h).

---

