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 start or record a customer conversation in Onlo and find it again later. Onlo returns a durable UUID for you to store against your own record. A supplied first message is always stored in Inbox.
API AI replies are off by default. While off, an operator takes over and replies manually; AI does not generate a title or reply, and no AI credits are used. An owner or admin can enable AI replies in Dashboard → Integrations → API for future messages. Replaying the same `external_id`, or enabling the setting later, never generates a reply for an old message.
| 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 |
| Read API audit events | GET /audit | 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.
- `body` is optional in Onlo. When supplied, Onlo stores it as the first customer message. AI replies are an API channel opt-in and default off; Intercom requires a body.
- 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`, a human-readable `subject`, 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", "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" } }'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 a conversation with its first customer message. API AI replies
// default off, so this is stored for manual handling unless an owner enables them.
const { body: created, response: createResponse } = await callOnlo('/conversations', {
method: 'POST',
body: JSON.stringify({
external_id: 'legacy_conv_987',
subject: 'Order ord_456',
from: { type: 'contact', external_id: 'customer_123', name: 'Sarah Chen' },
custom_attributes: { order_id: 'ord_456' },
body: 'I need help with my order.',
}),
});
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;
}