Conversations API reference
Every endpoint, request field, response field, limit, and error code for the Conversations REST API.
- For
- Backend engineers implementing against the Conversations API
- Time
- 20 minutes
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
| 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. |
| `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. |
Conversationjson
{
"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": 0,
"last_message_at": null,
"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
Creates one EMPTY conversation and returns its durable id. It writes no message, sends nothing, and invokes no AI. Requires a full-access key.
`external_id` is the idempotency key. Repeating a create with the same `external_id` and the same contact returns the existing conversation with `200` and the header `X-Onlo-Idempotent-Replay: true`, instead of creating a second one.
| Field | Required | Limits | Behavior |
|---|---|---|---|
| `external_id` | Yes | 1–255 chars | Your source conversation id, and the idempotency key. |
| `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 `{}`. |
Requestshell
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",
"from": {
"type": "contact",
"external_id": "customer_123",
"name": "Sarah Chen",
"email": "sarah@example.com"
},
"custom_attributes": { "order_id": "ord_456", "plan": "pro" }
}'201 Createdjson
{
"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": 0,
"last_message_at": null,
"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" }
]
}
}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. |
| `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 exists; both responses carry the same `id`. |
Create a conversation, then send the exact same request body a second time.
Expected result
The first returns `201`. The second returns `200`, the identical `id`, and `X-Onlo-Idempotent-Replay: true`.
If you don't 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.
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.
Requestshell
Sets order_id and is_vip, and removes plan.
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
}
}'Set two attributes, then send a second `PUT` that removes one of them with `null`.
Expected result
The response `custom_attributes` contains the remaining key and no longer contains the removed one. Keys you did not mention are untouched.
If you don't 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
| 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. |
Requestshell
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 OKjson
{
"type": "conversation.list",
"conversations": [ { "type": "conversation", "id": "9a4f18f5-…" } ],
"total_count": 1,
"pages": { "type": "pages", "page": 1, "per_page": 50 }
}- `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.
Search for a contact you have created a conversation for, with no attribute filters.
Expected result
A `conversation.list` containing that conversation, with `total_count` matching the number of conversations that contact has.
If you don't 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. |
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 with backoff. No write was committed. |
400 Bad Requestjson
{
"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 | Use a 30-second client timeout. |
| `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. |