# Conversations API reference

Every endpoint, request field, response field, limit, and error code for the Conversations REST API.

- **Audience:** Backend engineers implementing against the Conversations API
- **Intent:** Build with Onlo
- **Active work:** 20 minutes

Canonical page: https://onlo.ai/docs/developers/conversations-api/reference

## Before you start

- An API key and a successful `/ping` response

## Conventions

- Base URL is `https://onlo.ai/api/v1`. There is no API subdomain.
- Field names are `snake_case`. Every resource carries a `type` discriminator.
- All timestamps are INTEGER Unix epoch **seconds**, in responses and in search filters. They are not milliseconds and not ISO-8601 strings.
- Your organization is derived from the API key. `organization_id` and `workspace_id` are never accepted, and sending them is a `400` rather than being silently ignored.
- Unknown fields are rejected at every level of the request body.
- Every response carries `X-Onlo-Request-Id` and `Cache-Control: no-store`. Give the request id to support when you report a problem.

## The conversation resource

> **Internal fields are never returned:** This API never returns session ids, visitor data, internal metadata, AI context, tier or sentiment classifications, model/token/cost fields, assignment, routing, or private transcripts. Create may include its initial public reply; GET can explicitly include public text from API-created conversations. Requests without that opt-in retain their existing response shape. Serialization is an allowlist, so new internal fields cannot appear here later.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | UUID string | Onlo’s durable conversation id. Persist this. |
| `external_id` | string or null | Your source conversation id — present only when `channel` is `api`. On every other channel it is `null`, because the stored value there is a provider-private identifier you never supplied. |
| `created_at`, `updated_at` | integer | Unix epoch seconds. |
| `last_message_at` | integer or null | Unix epoch seconds, or `null` when there are no messages. |
| `channel` | string | `api`, `widget`, `email`, `slack`, `voice`, and so on. |
| `source.type` | string | The same value as `channel`, in Intercom’s shape. |
| `state` | `open` or `closed` | Intercom-compatible projection of Onlo’s status. |
| `open` | boolean | `true` when `state` is `open`. |
| `status` | string | Onlo’s finer status: `active`, `waiting`, `human_takeover`, `resolved`, `archived`. |
| `custom_attributes` | object | Your attributes. Defaults to `{}`. |
| `message_count` | integer | Persisted messages on the conversation. |
| `reply` | object or null | Create response only. `null` by default while API AI replies are off; when enabled, it may contain the immediate reply to a newly created `body`. |
| `contact` | object or null | The linked customer, including `external_id`, `name`, `email`, `phone`. |
| `contacts` | object | Intercom’s `contact.list` shape holding the same contact. An Onlo conversation has at most one. |

### Conversation

```json
{
  "type": "conversation",
  "id": "9a4f18f5-93ac-4476-b87f-6af63c700c64",
  "external_id": "legacy_conv_987",
  "created_at": 1787740200,
  "updated_at": 1787740200,
  "channel": "api",
  "source": { "type": "api" },
  "state": "open",
  "open": true,
  "status": "active",
  "custom_attributes": { "order_id": "ord_456", "plan": "pro" },
  "message_count": 1,
  "last_message_at": 1787740200,
  "contact": {
    "type": "contact",
    "id": "29894c6a-7187-45cc-af77-8c58a9dd1511",
    "external_id": "customer_123",
    "name": "Sarah Chen",
    "email": "sarah@example.com",
    "phone": null
  },
  "contacts": {
    "type": "contact.list",
    "contacts": [
      { "type": "contact", "id": "29894c6a-7187-45cc-af77-8c58a9dd1511", "external_id": "customer_123" }
    ]
  }
}
```

## POST /conversations

> **Profile fields do not update on a replay:** Sending from.name, from.email, or from.phone updates the contact only when a NEW conversation is created. A replay returns the existing conversation untouched. Omit these fields when you do not intend a profile change.

Creates one conversation and returns its durable id. On a new create, an optional `body` always stores the first customer message. API AI replies are off by default, so `reply` is normally `null` and an operator handles the message in Inbox. Omitting `body` creates an empty conversation. A new conversation emits `conversation.created` to subscribed webhook endpoints; an idempotent replay does not. Requires a full-access key.

An owner or admin can enable AI replies at Dashboard → Integrations → API. Only messages created after enable can receive an automatic AI reply. Messages stored while off and idempotent replays never receive one later.

`external_id` is the idempotency key. Repeating a create with the same `external_id` and the same contact returns the existing conversation and stored reply with `200` and the header `X-Onlo-Idempotent-Replay: true`. It does not generate or charge a second time.

When AI replies is on, `reply` is the response available during this request. Some work may require approval and continue later. Read later public text with GET `include=messages,latest_reply`. There is no reply webhook; do not keep the create request open waiting for later updates.

| Field | Required | Limits | Behavior |
| --- | --- | --- | --- |
| `external_id` | Yes | 1–255 chars | Your source conversation id, and the idempotency key. |
| `subject` | No | 1–255 chars | Human-supplied Inbox title. Trimmed, stored internally, and never generated by AI. |
| `body` | No | 1–10,000 chars | Initial customer message. Trimmed and stored. It remains operator-handled while API AI replies are off. Omit it to create an empty conversation. |
| `from.type` | No | `contact`, `user`, `lead`, `customer` | Intercom’s vocabulary; all four are equivalent here. |
| `from.external_id` | One of | 1–255 chars | Your user id. Resolves the contact, or creates it if new. |
| `from.id` | One of | Onlo contact UUID | An existing Onlo contact. Never creates one — an unknown id is a `404`. |
| `from.name` | No | ≤255 chars | Applied only when a new conversation is created. |
| `from.email` | No | ≤255 chars | Lowercased on write. Applied only when a new conversation is created. |
| `from.phone` | No | ≤50 chars | Trimmed. Applied only when a new conversation is created. |
| `custom_attributes` | No | See limits below | Initial attributes. Defaults to `{}`. |

### Request

```shell
curl --request POST "$ONLO_API_BASE_URL/conversations" \
  --header "Authorization: Bearer $ONLO_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "external_id": "legacy_conv_987",
    "subject": "Order ord_456",
    "from": {
      "type": "contact",
      "external_id": "customer_123",
      "name": "Sarah Chen",
      "email": "sarah@example.com"
    },
    "body": "I need help with my order.",
    "custom_attributes": { "order_id": "ord_456", "plan": "pro" }
  }'
```

### 201 Created

```json
{
  "type": "conversation",
  "id": "9a4f18f5-93ac-4476-b87f-6af63c700c64",
  "external_id": "legacy_conv_987",
  "created_at": 1787740200,
  "updated_at": 1787740201,
  "channel": "api",
  "source": { "type": "api" },
  "state": "open",
  "open": true,
  "status": "active",
  "custom_attributes": { "order_id": "ord_456", "plan": "pro" },
  "message_count": 1,
  "last_message_at": 1787740201,
  "contact": {
    "type": "contact",
    "id": "29894c6a-7187-45cc-af77-8c58a9dd1511",
    "external_id": "customer_123",
    "name": "Sarah Chen",
    "email": "sarah@example.com",
    "phone": null
  },
  "contacts": {
    "type": "contact.list",
    "contacts": [
      { "type": "contact", "id": "29894c6a-7187-45cc-af77-8c58a9dd1511", "external_id": "customer_123" }
    ]
  },
  "reply": null
}
```

## Create: what each retry does

| Case | Result |
| --- | --- |
| New `external_id` | `201`, one conversation created |
| Same `external_id`, same contact | `200`, the same `id`, plus `X-Onlo-Idempotent-Replay: true` |
| Same `external_id`, a DIFFERENT contact | `409 external_id_conflict`. Nothing is changed. Stop this item and reconcile your mapping. |
| Same `external_id`, its contact has been erased | `409 customer_unavailable`. No deleted data is returned. |
| Same `external_id` and contact, different attributes in the retry body | The existing conversation is returned unchanged. Use `PUT` to change attributes on purpose. |
| Same `external_id` and contact, different `body` or `subject` on retry | The existing conversation, first message, title, and reply are returned unchanged. A replay never appends, replaces, generates, or charges again. |
| `from.email` or `from.phone` belongs to a different active contact | `409 customer_profile_conflict`. Onlo never merges contacts. Correct or omit the field. |
| Two identical creates race each other | One conversation and one customer message exist. When AI replies are on, a request arriving before the response is ready may get a retryable `503`; retry the same `external_id` to receive the saved result. |

### Expected result

Create a conversation, then send the exact same request body a second time.

**Success:** The first returns `201`. The second returns `200`, the identical `id`, and `X-Onlo-Idempotent-Replay: true`.

**If you do not see this:**

- If the second call returns `201` with a different id, you changed the `external_id` between attempts — confirm your retry reuses the exact same value, including case and whitespace.
- If it returns `409 external_id_conflict`, that source id is already mapped to a different user in Onlo. Do not overwrite your mapping; investigate which user is correct.

## GET /conversations/{conversation_id}

Returns one conversation. Accepts a read-only key.

For API-created conversations, `include=messages` adds a newest bounded page as `messages: {type: "message.list", data: [...], has_more, next_cursor}`. Use `message_limit` (1–100, default 50) and pass `next_cursor` unchanged as `message_cursor` for older pages. Each page renders oldest-to-newest.

`include=latest_reply` adds the latest available public operator/AI text reply, or `null`. Both includes can be combined with `include=messages,latest_reply`. Without include, the original response is unchanged. Other-channel transcript requests return `404 not_found`.

Each event has `type: "message"`, its persisted `id`, `role`, `body`, epoch-second `created_at` and lossless ISO `orderingTimestamp`. Order by `(orderingTimestamp, id)` and deduplicate by id. Invalid/expired message cursors return `400 invalid_cursor`; restart from the newest page. Legacy cursors recover the scoped persisted anchor.

Private notes, system/workflow events and pending/failed replies are excluded. An accepted API text reply is available for authorized polling; this does not acknowledge customer delivery or reading. Files and later customer-message writes are not supported. Keep the workspace API key on your backend and enforce each customer’s Conversation access there.

A conversation that does not exist, has been deleted, or belongs to another organization all return the same `404 not_found`. This is deliberate — a distinguishable response would confirm whether an id exists in someone else’s organization.

## PUT /conversations/{conversation_id}

Sets and removes custom attributes. Requires a full-access key. Only `custom_attributes` is writable — any other field is rejected by name, and status, assignment, channel, and internal metadata cannot be changed through this API.

A `null` VALUE removes that key. There is no separate unset list. This also means a stored JSON `null` is not representable: a key either has a value or does not exist.

Attributes are merged inside the database in a single statement, so a concurrent update to a different key is never lost.

### Request

Sets order_id and is_vip, and removes plan.

```shell
curl --request PUT "$ONLO_API_BASE_URL/conversations/9a4f18f5-93ac-4476-b87f-6af63c700c64" \
  --header "Authorization: Bearer $ONLO_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "custom_attributes": {
      "order_id": "ord_789",
      "is_vip": true,
      "plan": null
    }
  }'
```

### Expected result

Set two attributes, then send a second `PUT` that removes one of them with `null`.

**Success:** The response `custom_attributes` contains the remaining key and no longer contains the removed one. Keys you did not mention are untouched.

**If you do not see this:**

- A `400` naming `custom_attributes` means a key or value broke the limits below — the message names the offending path.
- A `400` about the attribute limit means the change would exceed 20 stored keys. Your existing attributes are unchanged; remove keys in the same request to make room.
- A `404` means the conversation id is wrong, deleted, or in another organization.

## Custom attribute limits

> **Do not put secrets in attributes:** Custom attributes are business lookup fields. Anyone with a read-only key for your organization can read them, and support operators can view them read-only in the selected conversation’s “This conversation” sidebar. Send order ids and plan names, not tokens or personal data you would not show a support agent.

| Rule | Limit |
| --- | --- |
| Stored keys per conversation | 20 |
| Key length | 1–64 characters |
| Key syntax | Starts with a letter, then letters, digits, `_`, `-`, or `.` |
| Reserved prefix | `onlo_` is rejected, case-insensitively |
| Value types | String, finite number, boolean, or `null` to remove the key |
| String value length | 500 characters |
| Nested objects or arrays | Rejected |
| Key matching | Case-sensitive |

## POST /conversations/search

Finds one contact’s conversations. This uses `POST` because the filter grammar is typed JSON, but it is read-only and accepts a read-only key. It returns conversation records and their attributes — never transcript messages.

Every search must include exactly one `contact.external_id` or `contact.id` filter with operator `=`. A single filter can also be passed directly as `query`, as in Intercom.

| Field | Operators | Value |
| --- | --- | --- |
| `contact.external_id`, `contact.id`, `contact_ids` | `=` | Exactly one is required. `contact_ids` is an Intercom alias for `contact.id`. |
| `custom_attributes.{key}` | `=` `!=` | Typed: `2` never matches `"2"`. `!=` matches a different value OR an absent key. |
| `external_id` | `=` `!=` `IN` `NIN` | Matches `channel: "api"` conversations only. |
| `channel`, `source.type` | `=` `!=` `IN` `NIN` | `api`, `widget`, `email`, `slack`, … |
| `state` | `=` `!=` | `open` or `closed`. |
| `status` | `=` `!=` `IN` `NIN` | Onlo’s status enum. `resolved` also matches legacy `closed` rows. |
| `created_at`, `updated_at` | `=` `!=` `>` `<` | Integer Unix epoch SECONDS. |

- `per_page` defaults to 50, maximum 100. An out-of-range value is REJECTED, not clamped — so a short page always means "no more results", never "your limit was quietly reduced".
- Only one flat `AND` group is supported. Nested groups and a root `OR` are rejected; a root `OR` could return conversations outside your contact filter.
- The `~`, `!~`, `^`, and `$` operators are rejected by name. No index can serve them.
- An unknown, unmapped, or erased contact returns `200` with an empty list — never a `404`, and never organization-wide results.

### Request

```shell
curl --request POST "$ONLO_API_BASE_URL/conversations/search" \
  --header "Authorization: Bearer $ONLO_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "query": {
      "operator": "AND",
      "value": [
        { "field": "contact.external_id",        "operator": "=", "value": "customer_123" },
        { "field": "custom_attributes.order_id", "operator": "=", "value": "ord_789" }
      ]
    },
    "pagination": { "per_page": 50, "starting_after": null }
  }'
```

### 200 OK

```json
{
  "type": "conversation.list",
  "conversations": [ { "type": "conversation", "id": "9a4f18f5-…" } ],
  "total_count": 1,
  "pages": { "type": "pages", "page": 1, "per_page": 50 }
}
```

### Expected result

Search for a contact you have created a conversation for, with no attribute filters.

**Success:** A `conversation.list` containing that conversation, with `total_count` matching the number of conversations that contact has.

**If you do not see this:**

- An empty list with `total_count: 0` means the contact was not found. Matching on `external_id` is exact and case-sensitive.
- A `400 parameter_not_found` on `query` means you omitted the required contact filter.
- A `400 invalid_field` or `invalid_operator` names the field or operator that is unsupported — the message lists what is allowed.

## Pagination

Results are ordered newest first and paginated by cursor. When more results exist, `pages.next.starting_after` holds an opaque cursor; send it back in `pagination.starting_after` with an otherwise IDENTICAL request body.

`pages.next` is ABSENT on the last page rather than `null`, so a `while (page.pages.next)` loop terminates on its own.

| Rule | What it means for you |
| --- | --- |
| Treat the cursor as opaque | Never construct, parse, or edit it. It is signed and will be rejected if altered. |
| Keep the query identical between pages | The cursor is bound to your contact, filters, and `per_page`. Change any of them and you get `400 invalid_cursor`. |
| Key order does not matter | Reordering the JSON keys in your filters keeps the same cursor context. |
| Cursors last 7 days | An expired cursor returns `400 invalid_cursor`. Restart from `starting_after: null`. |
| Pagination is best-effort, not a snapshot | Conversations created after page 1 may not appear in later pages. De-duplicate by `id`, and run the search again to pick up new rows. |

## GET /audit

Returns this organization’s public-API security events newest first. Audit logging is mandatory; there is no enable/disable setting. A read-only key is sufficient.

Audit records contain operation metadata, not request or response bodies. They never contain raw keys, key hashes, contact profile fields, custom attributes, transcript content, or your raw external ids.

| Query parameter | Behavior |
| --- | --- |
| `per_page` | Optional integer from 1 to 100. Defaults to 50. |
| `starting_after` | The prior response’s `pages.next.starting_after` value. Invalid or cross-organization ids return `400 invalid_cursor`. |

- The audit request itself is written after its response is assembled, so it appears on a later audit read rather than inside its own result.
- Retention is not currently expressed as a contractual number. Export records into your own security system if you require a specific compliance retention period.

### Request

```shell
curl --request GET "$ONLO_API_BASE_URL/audit?per_page=50" \
  --header "Authorization: Bearer $ONLO_API_TOKEN" \
  --header "Accept: application/json"
```

### 200 OK

```json
{
  "type": "audit_log.list",
  "audit_logs": [
    {
      "type": "audit_log",
      "id": "603a3cd8-43be-43fa-97ea-181b97d1afdd",
      "request_id": "977fba8b-1b57-45c4-a965-bb62e6eebd1d",
      "api_key_id": "12137dde-394b-4e30-aa72-b5bf5f5b9671",
      "operation": "conversation.search",
      "http_status": 200,
      "outcome": "allowed",
      "denial_reason": null,
      "conversation_id": null,
      "contact_id": "29894c6a-7187-45cc-af77-8c58a9dd1511",
      "created_at": 1787740200
    }
  ],
  "pages": { "type": "pages", "per_page": 50 }
}
```

## Errors

Errors use Intercom’s `error.list` envelope. `errors` is an ARRAY — read `errors[0].code` to branch, and `field` to point a user at the offending input.

| HTTP | Code | What to do |
| --- | --- | --- |
| 400 | `parameter_invalid` | A field breaks the contract. Fix it; do not retry unchanged. |
| 400 | `parameter_not_found` | A required field is missing. |
| 400 | `invalid_json` | The body is not valid JSON. |
| 400 | `invalid_cursor` | The cursor is malformed, expired, or from a different query. Restart from the first page. |
| 400 | `invalid_operator` | That operator is unsupported for that field. The message names it. |
| 400 | `invalid_field` | That field is not searchable. The message lists the searchable set. |
| 401 | `unauthorized` | The key is missing, malformed, or revoked. |
| 403 | `api_plan_restricted` | Your plan does not include API access. |
| 403 | `insufficient_scope` | A read-only key attempted a write. Use a full-access key. |
| 403 | `legacy_key_not_accepted` | The retired `olk_test_` prefix. Create an `olk_read_` or `olk_live_` key. |
| 403 | `ip_not_allowed`, `https_required` | Your organization’s security policy rejected the request. |
| 404 | `not_found` | Absent, deleted, or another organization’s. Verify the id. |
| 405 | `method_not_allowed` | Check the `Allow` header. |
| 406 | `not_acceptable` | Send `Accept: application/json` or `*/*`. |
| 409 | `external_id_conflict` | That source id belongs to another contact. Stop and reconcile. |
| 409 | `customer_profile_conflict` | That email or phone belongs to another active contact. Correct or omit it. |
| 409 | `customer_unavailable` | The linked contact was erased. Reconcile the source item. |
| 413 | `payload_too_large` | The body exceeds 64 KiB. |
| 415 | `unsupported_media_type` | Set `Content-Type: application/json`. |
| 429 | `rate_limit_exceeded` | Wait for `Retry-After`, then back off exponentially. |
| 500 | `server_error` | Retry with backoff. Give support the request id. |
| 503 | `service_unavailable` | Retry the same request with backoff. When API AI replies are on, a create with `body` may have saved the conversation and customer message before reply processing failed. |

### 400 Bad Request

```json
{
  "type": "error.list",
  "request_id": "977fba8b-1b57-45c4-a965-bb62e6eebd1d",
  "errors": [
    {
      "code": "parameter_not_found",
      "message": "from.external_id is required",
      "field": "from.external_id"
    }
  ]
}
```

## Retry policy

| Situation | What to do |
| --- | --- |
| Request timeout | Storage-only creates are short. When API AI replies are on, use a client timeout of at least 75 seconds for create with `body`. |
| `400`, `401`, `403`, `404`, `409` | Do not retry unchanged. Fix credentials, payload, permissions, or mapping. |
| `429` | Wait at least `Retry-After`, then exponential backoff with jitter. |
| `500`, `502`, `503`, `504`, network timeout | Retry up to five times with exponential backoff and jitter. |
| Retrying a create | Reuse the exact same `external_id`. A previously successful attempt returns the same `id`. |
| Retrying a `PUT` | Reuse the same body. It is idempotent for that body. |
| Retrying a search | Reuse the exact same body and cursor. Restart from `null` only after `invalid_cursor`. |
| Reporting a problem | Record `X-Onlo-Request-Id`, the status, the code, and the timestamp. Never log your token or the full customer payload. |

## Next

- [API security and webhooks](https://onlo.ai/docs/developers/conversations-api/security-webhooks): Lock API access to your infrastructure, read mandatory audit events, receive Onlo events, and verify webhook signatures against the raw request body.

## Related pages

- [Conversations API](https://onlo.ai/docs/developers/conversations-api): Create a conversation for one of your users from your own backend, receive its durable Onlo id, set conversation attributes, and search that user’s conversations over REST.
- [API security and webhooks](https://onlo.ai/docs/developers/conversations-api/security-webhooks): Lock API access to your infrastructure, read mandatory audit events, receive Onlo events, and verify webhook signatures against the raw request body.
- [Migrating from Intercom](https://onlo.ai/docs/developers/conversations-api/migrate-from-intercom): What carries over from your Intercom integration unchanged, the differences that need code changes, and how to move existing conversation history.
