# Migrating from Intercom

What carries over from your Intercom integration unchanged, the differences that need code changes, and how to move existing conversation history.

- **Audience:** Teams porting an existing Intercom backend integration to Onlo
- **Intent:** Build with Onlo
- **Active work:** 30 minutes
- **Waiting:** Contact mapping is agreed with Onlo before the migration run

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

## Before you start

- A working Intercom backend integration you can read
- An Onlo full-access API key
- A stable identifier for each of your users that exists in both systems

## What carries over with no changes

Onlo deliberately matches Intercom’s wire shape, so a large part of your client stays as it is.

- `Authorization: Bearer <token>` authentication.
- `snake_case` field names, and a `type` discriminator on every resource.
- The `conversation.list` collection shape, with `total_count` and a `pages` object.
- Cursor pagination through `pages.next.starting_after`.
- The `error.list` error envelope, including `request_id` and an `errors` array.
- Integer Unix-epoch-second timestamps — your existing date parsing works.
- `PUT` to update a conversation, with `custom_attributes` in the body.
- A `null` attribute value meaning "remove this key".
- The `query` / `operator` / `field` / `value` search grammar, and `POST` for search.
- `X-RateLimit-Limit`, `-Remaining`, and `-Reset` response headers.

## What needs a code change

| Intercom | Onlo | What to change |
| --- | --- | --- |
| `POST /conversations` requires `body` and creates a first message | Create writes an EMPTY conversation and takes no `body` | Remove `body` from your create call. If you need the message content in Onlo, that is not yet supported — raise it with us before you plan around it. |
| `from: { type, id }` with an Intercom contact id, after a separate `POST /contacts` | `from: { type, external_id }` resolves or creates the contact in the same call | Send your own user id as `from.external_id` and delete the separate contact-creation call. `from.id` still works for a contact you already know. |
| No idempotency — a retried create duplicates | `external_id` is required and IS the idempotency key | Send your source conversation id as `external_id`. Then you can retry safely: a replay returns the same `id` with `X-Onlo-Idempotent-Replay: true`. |
| Search a whole workspace | Every search requires exactly one `contact.external_id` or `contact.id` filter | Split an unscoped search into one search per user. There is no organization-wide listing. |
| Two levels of nesting, and a root `OR` | One flat `AND` group | Flatten your query, or run one search per `OR` branch and merge by conversation `id`. |
| `~`, `^`, `$` substring operators | Rejected by name | Replace substring matching with exact attribute values. If you need contains-matching, tell us what for. |
| Custom attributes must be pre-declared in workspace settings | Bounded free-form: 20 keys, typed values, no setup | Delete your attribute-registration step. Check your keys fit the limits (`onlo_` prefix is reserved). |
| Every conflict is `conflict` | Three codes: `external_id_conflict`, `customer_profile_conflict`, `customer_unavailable` | Branch on `errors[0].code` — each one needs a different response from your migration job. |
| `statistics` object on the conversation | Flat `message_count` and `last_message_at` | Read the flat fields. |
| Cursors never expire | Cursors last 7 days and are bound to the query | Handle `400 invalid_cursor` by restarting that search from `starting_after: null`. |
| Contacts can be resolved and merged by email | Resolution is by `external_id` only, and contacts are NEVER merged | Make sure every user has a stable `external_id`. An email that belongs to a different contact returns `409`, it does not merge. |

## Not available yet

These exist in Intercom and do not exist here. They are scope decisions, not oversights — if one blocks your migration, that is useful for us to know early.

- Webhooks. There is no event delivery from this API yet.
- A sandbox or developer workspace. Onlo issues production keys only, against real data.
- Message import, and conversation replies over REST.
- Contact endpoints. Contacts are created as a side effect of creating a conversation.

## Moving existing conversation history

> **Skipping the mapping is allowed, with a consequence:** Without it the API works perfectly for contacts it creates itself. It simply makes no promise about conversations attached to older, unmapped contacts — those stay invisible to search.

Searching by `contact.external_id` finds only conversations linked to the contact that owns that external id. If you already have contacts in Onlo from a connected channel, those contacts may have no `external_id` — and they are NOT matched by email or phone, because this API never merges contacts on an identifier you did not verify.

So if your migration must include conversations that already exist in Onlo, the contact mapping happens once, up front, with us.

1. **Export your mapping.** Produce an exact list of Onlo contact id to your own user id. Not emails — contact ids.
   - **Expected result:** A reconciliation file you and Onlo both agree on.
2. **Onlo dry-runs it.** An Onlo-owned tool validates the organization, that each contact is active, duplicate external ids, contacts already mapped, and conflicting email or phone uniqueness.
   - **Expected result:** A no-write report listing every conflict. Nothing has changed yet.
3. **Resolve every conflict explicitly.** You and Onlo decide each case. There is no fuzzy matching and no automatic merge.
   - **Expected result:** A conflict-free mapping.
4. **Apply, then verify.** After separate approval, the tool writes only the accepted missing external ids and produces an audit file.
   - **Expected result:** Each migrated user resolves to exactly one Onlo contact, and search returns their existing history.

### Expected result

For a handful of known users, search by `contact.external_id` and compare the count against your own records.

**Success:** Each user’s pre-existing Onlo conversations are returned, and every returned conversation has a non-null `contact.external_id`.

**If you do not see this:**

- An empty result for a user you expected to find means that contact was not mapped. Do not create a second contact — add them to the mapping file and re-run the dry run.
- A `409 customer_profile_conflict` during the migration means the email or phone you sent belongs to a different active contact. Omit the profile field, or resolve which contact is correct.

## A migration order that fails safely

1. **Port authentication and call /ping.** Swap the base URL and token, and confirm the scope you expect.
   - **Expected result:** A `200` from `/ping`.
2. **Port the create call for ONE user.** Drop `body`, add `external_id`, move the contact into `from.external_id`. Then send the identical request twice.
   - **Expected result:** `201` then `200` with the same `id` — proof your idempotency key is stable before you run it at scale.
3. **Port attributes and search.** Convert your flat filters into the `query` grammar, and add the required contact filter.
   - **Expected result:** The conversation you created comes back from search with its attributes.
4. **Run the full job with retries enabled.** Because create is idempotent on your `external_id`, a mid-run failure is safe to resume from the beginning.
   - **Expected result:** Every source conversation maps to exactly one Onlo id, with no duplicates.

### Expected result

After the full run, count distinct Onlo ids in your mapping table and compare with your source conversation count.

**Success:** The counts match exactly, and no source `external_id` maps to two different Onlo ids.

**If you do not see this:**

- If one source id maps to two different Onlo ids, STOP the migration. Keep both request ids and contact Onlo — do not pick one and overwrite the mapping.
- If the counts are short, look for `409` responses in your job log. Each one is a source item that needs reconciling, not a retry.

## Related pages

- [Conversations API](https://onlo.ai/docs/developers/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.
- [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.
