Browse Developers
Build with OnloView as Markdown

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

FieldTypeNotes
`id`UUID stringOnlo’s durable conversation id. Persist this.
`external_id`string or nullYour 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`integerUnix epoch seconds.
`last_message_at`integer or nullUnix epoch seconds, or `null` when there are no messages.
`channel`string`api`, `widget`, `email`, `slack`, `voice`, and so on.
`source.type`stringThe 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`stringOnlo’s finer status: `active`, `waiting`, `human_takeover`, `resolved`, `archived`.
`custom_attributes`objectYour attributes. Defaults to `{}`.
`message_count`integerPersisted messages on the conversation.
`contact`object or nullThe linked customer, including `external_id`, `name`, `email`, `phone`.
`contacts`objectIntercom’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.

FieldRequiredLimitsBehavior
`external_id`Yes1–255 charsYour 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 of1–255 charsYour user id. Resolves the contact, or creates it if new.
`from.id`One ofOnlo contact UUIDAn existing Onlo contact. Never creates one — an unknown id is a `404`.
`from.name`No≤255 charsApplied only when a new conversation is created.
`from.email`No≤255 charsLowercased on write. Applied only when a new conversation is created.
`from.phone`No≤50 charsTrimmed. Applied only when a new conversation is created.
`custom_attributes`NoSee limits belowInitial 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

CaseResult
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 bodyThe 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 otherOne 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

RuleLimit
Stored keys per conversation20
Key length1–64 characters
Key syntaxStarts with a letter, then letters, digits, `_`, `-`, or `.`
Reserved prefix`onlo_` is rejected, case-insensitively
Value typesString, finite number, boolean, or `null` to remove the key
String value length500 characters
Nested objects or arraysRejected
Key matchingCase-sensitive

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.

FieldOperatorsValue
`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.

RuleWhat it means for you
Treat the cursor as opaqueNever construct, parse, or edit it. It is signed and will be rejected if altered.
Keep the query identical between pagesThe cursor is bound to your contact, filters, and `per_page`. Change any of them and you get `400 invalid_cursor`.
Key order does not matterReordering the JSON keys in your filters keeps the same cursor context.
Cursors last 7 daysAn expired cursor returns `400 invalid_cursor`. Restart from `starting_after: null`.
Pagination is best-effort, not a snapshotConversations 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.

HTTPCodeWhat 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

SituationWhat to do
Request timeoutUse 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 timeoutRetry up to five times with exponential backoff and jitter.
Retrying a createReuse 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 searchReuse the exact same body and cursor. Restart from `null` only after `invalid_cursor`.
Reporting a problemRecord `X-Onlo-Request-Id`, the status, the code, and the timestamp. Never log your token or the full customer payload.