# 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.

- **Audience:** Backend engineers integrating Onlo from a server or migration job
- **Intent:** Build with Onlo
- **Active work:** 15 minutes
- **Where this happens:** Everything here runs server-side. The API key is a long-lived secret and must never reach browser or mobile code.

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

## 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.

- [Migrating from Intercom](https://onlo.ai/docs/developers/conversations-api/migrate-from-intercom): Every difference, what to change, and what you gain.

## Base URL

> **Keep your key server-side:** An organization API key can read and write every conversation in your organization. Never place it in a query string, a request body, a cookie, a frontend bundle, a mobile app, a log line, or an analytics event.

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

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.
   ```shell
   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 result:** A `200` response containing your `organization_id` and `scope`.
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.
   ```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",
       "from": {
         "type": "contact",
         "external_id": "customer_123",
         "name": "Sarah Chen",
         "email": "sarah@example.com"
       },
       "custom_attributes": { "order_id": "ord_456", "plan": "pro" }
     }'
   ```
   - **Expected result:** A `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.
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.
   ```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
       }
     }'
   ```
   ```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 }
     }'
   ```
   - **Expected result:** The `PUT` returns the full conversation with the updated `custom_attributes`; the search returns that conversation inside a `conversation.list`.

### Expected result

Run the three calls above in order with a real full-access key.

**Success:** 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 do not 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+

Requires ONLO_API_TOKEN in the server environment.

```typescript
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;
}
```

## Where to go next

- [Authentication and API keys](https://onlo.ai/docs/developers/conversations-api/authentication): Key scopes, safe rotation, and what each failure means.
- [API reference](https://onlo.ai/docs/developers/conversations-api/reference): Every endpoint, field, limit, and error code.
- [Migrating from Intercom](https://onlo.ai/docs/developers/conversations-api/migrate-from-intercom): Every difference from Intercom and how to adapt.

## Next

- [Authentication and API keys](https://onlo.ai/docs/developers/conversations-api/authentication): Create the right key scope, send it correctly, rotate it without downtime, and read each authentication failure.

## Related pages

- [Authentication and API keys](https://onlo.ai/docs/developers/conversations-api/authentication): Create the right key scope, send it correctly, rotate it without downtime, and read each authentication failure.
- [Conversations API reference](https://onlo.ai/docs/developers/conversations-api/reference): Every endpoint, request field, response field, limit, and error code for the Conversations REST API.
- [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.
