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.
- For
- Backend engineers integrating Onlo from a server or migration job
- Needs
- Everything here runs server-side. The API key is a long-lived secret and must never reach browser or mobile code.
- Time
- 15 minutes
Before you start
- An Onlo organization on a plan that includes API access
- A full-access API key from Dashboard → Integrations → API
- A stable identifier for each of your users, and one for each source conversation you will create
What this API does
The Conversations API gives your backend one path to record a customer conversation in Onlo and find it again later. You create a conversation, Onlo returns a durable UUID, and you store that UUID against your own record.
It is deliberately narrow. Creating a conversation writes an empty conversation — it sends no message, and it never triggers the AI, a playbook, an action, a ticket, or any outbound delivery. If you are looking for the AI to answer a customer, that happens through a connected channel or the Messenger SDK, not here.
| Operation | Endpoint | Key scope |
|---|---|---|
| Test your credentials | GET /ping | read or full |
| Create a conversation for a user | POST /conversations | full |
| Fetch one conversation | GET /conversations/{id} | read or full |
| Set or remove custom attributes | PUT /conversations/{id} | full |
| Search one user’s conversations | POST /conversations/search | read or full |
If you are coming from Intercom
This API deliberately matches Intercom’s wire shape, so most Intercom client code carries over: bearer auth, snake_case fields, a `type` discriminator on every resource, `conversation.list` with `pages.next.starting_after`, the `error.list` error envelope, integer Unix-epoch-second timestamps, `PUT` to update, and the `query`/`operator`/`field`/`value` search grammar.
Three differences will affect your code on day one, and there is a dedicated page for the full list.
- Creating a conversation does NOT take a `body` and does NOT create a message. Intercom requires one.
- Every search must filter on exactly one contact. Intercom lets you search a whole workspace.
- Creating a conversation is idempotent on your `external_id`. Intercom has no idempotency, so a retry there duplicates.
Base URL
All requests go to `https://onlo.ai/api/v1`. There is no API subdomain — if you have seen `api.onlo.ai` or `api.onlo.app` referenced anywhere, those hosts do not exist and will not resolve.
The three identifiers you need
Your identifiers are opaque and case-sensitive. Onlo stores them exactly as sent and rejects leading or trailing whitespace rather than trimming it, so one string in your system can never become two different keys.
| Value | Who creates it | Example | What you do with it |
|---|---|---|---|
| `from.external_id` | You | `customer_123` | Your stable id for one user. Reuse it on every create and every search for that user. |
| `external_id` | You | `legacy_conv_987` | Your stable id for one source conversation. It is also the idempotency key — reuse the exact same value on retries. |
| `id` | Onlo | `9a4f18f5-93ac-4476-b87f-6af63c700c64` | The durable Onlo conversation UUID. Persist it; you need it to update or fetch the conversation. |
Make your first three calls
- Step 1
Confirm your key works
Call `/ping` before anything else. It verifies the key, your plan, any IP allowlist, and reports which scope the key carries.
Connection testshell
export ONLO_API_BASE_URL="https://onlo.ai/api/v1" export ONLO_API_TOKEN="olk_live_replace_with_your_key" curl --request GET "$ONLO_API_BASE_URL/ping" \ --header "Authorization: Bearer $ONLO_API_TOKEN" \ --header "Accept: application/json"Expected resultA `200` response containing your `organization_id` and `scope`.
- Step 2
Create a conversation and store the id
Send your source conversation id as `external_id` and your user id as `from.external_id`. If that user does not exist in Onlo yet, this creates them.
Createshell
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" } }'Expected resultA `201` response whose `id` is the Onlo conversation UUID. Persist the mapping from your `external_id` to that `id` before you treat the record as migrated.
- Step 3
Set attributes, then search for them
Use `PUT` with the returned id to set business fields you want to look up by later, then search for that user filtered on those fields.
Set attributesshell
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 } }'Searchshell
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 } }'Expected resultThe `PUT` returns the full conversation with the updated `custom_attributes`; the search returns that conversation inside a `conversation.list`.
Run the three calls above in order with a real full-access key.
Expected result
The create returns `201` with an `id`; the `PUT` returns that same `id` with your attributes applied; the search returns exactly that conversation with `total_count: 1`.
If you don't see this
- A `401` means the key is missing, malformed, revoked, or pasted with surrounding whitespace — re-copy it from the dashboard.
- A `403 insufficient_scope` means you used a read-only key for create or `PUT`. Create a full-access key.
- A `403 api_plan_restricted` means your plan does not include API access. Contact your organization owner.
- A search returning an empty list means the contact was not found. Confirm you sent the same `from.external_id` you created the conversation with — matching is exact and case-sensitive.
Complete backend example
The full loop, including the retry-safety and pagination behavior you should copy rather than reimplement. Run it only from a trusted backend.
Node.js 18+typescript
Requires ONLO_API_TOKEN in the server environment.
const baseUrl = process.env.ONLO_API_BASE_URL ?? 'https://onlo.ai/api/v1';
const token = process.env.ONLO_API_TOKEN;
if (!token) throw new Error('ONLO_API_TOKEN is required');
async function callOnlo(path: string, options: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...options.headers,
},
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
// Errors arrive as Intercom's error.list: errors is an array.
const first = body.errors?.[0];
const requestId = response.headers.get('X-Onlo-Request-Id');
throw new Error(`${response.status} ${first?.code ?? 'request_failed'} ${first?.message ?? ''} request_id=${requestId ?? 'missing'}`);
}
return { body, response };
}
// 1. Create an empty conversation and keep the durable Onlo id.
const { body: created, response: createResponse } = await callOnlo('/conversations', {
method: 'POST',
body: JSON.stringify({
external_id: 'legacy_conv_987',
from: { type: 'contact', external_id: 'customer_123', name: 'Sarah Chen' },
custom_attributes: { order_id: 'ord_456' },
}),
});
const conversationId = created.id;
const wasReplay = createResponse.headers.get('X-Onlo-Idempotent-Replay') === 'true';
// Persist legacy_conv_987 -> conversationId now. If THAT write fails, retry this
// exact request: the same id comes back with wasReplay === true.
// 2. Set attributes. A null value removes that key.
await callOnlo(`/conversations/${encodeURIComponent(conversationId)}`, {
method: 'PUT',
body: JSON.stringify({ custom_attributes: { order_id: 'ord_789', plan: null } }),
});
// 3. Page through every match for this contact.
const conversations = [];
let startingAfter: string | null = null;
for (;;) {
const { body: page } = await callOnlo('/conversations/search', {
method: 'POST',
body: JSON.stringify({
query: {
operator: 'AND',
value: [{ field: 'contact.external_id', operator: '=', value: 'customer_123' }],
},
pagination: { per_page: 50, starting_after: startingAfter },
}),
});
conversations.push(...page.conversations);
// `next` is ABSENT on the last page, so this loop terminates on its own.
if (!page.pages?.next) break;
startingAfter = page.pages.next.starting_after;
}