> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# API recipes: message-history import and reconciliation

> Land historical messages from your old provider in Orbit and prove nothing was lost: per-message ingest with idempotent retries, the source-to-Orbit id mapping your script owns, and a read-path reconciliation loop over GET /messages/:id and POST /messages/export.

# API recipes: message-history import and reconciliation

The per-provider guides land your *configuration* (numbers, services, contacts, templates) on Orbit; message *history* needs its own loop. This page is that loop: ingest historical rows as messages, keep the source-to-Orbit id mapping, and reconcile until both sides agree. Base URL is `https://api.orbit.devotel.io/api/v1` throughout; authenticate with `X-API-Key`.

<Note>
  There is no bulk "post my history" endpoint. Orbit ingests configuration through the [assisted import wizard](/guides/assisted-import-wizard) (whose `conversations` entity pulls up to 90 days of Twilio history server-side), and everything else lands through the same message API you send with today. Your migration script owns the mapping between source ids and Orbit ids; the recipes below are the reads and writes that make that mapping provable. These are **tenant-owned controls**: Orbit supplies and enforces the surface, you own the source credentials, the mapping store, and the go-live call.
</Note>

## 1. Ingest: post historical rows as messages

POST each historical row to the unified send endpoint, one message per source row. Keep the body to what the row actually carried: the phone pair, the channel, and the body. Send a stable `Idempotency-Key` per row (derive it from the source message id) so a retried post after a `429` or a timeout can never double-import the row.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/messages \
    -H "X-API-Key: dv_test_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: src-SM9f2a1c4b8d6e5f0a1b2c3d4e5f6a7b8c" \
    -d '{
      "to": "+14155552671",
      "from": "+14155551234",
      "channel": "sms",
      "body": "Acme: order 98421 shipped."
    }'
  ```

  ```typescript Node.js theme={null}
  import { Orbit } from "@devotel-orbit/node";

  const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! });

  async function importRow(row: { sourceId: string; to: string; from: string; body: string }) {
    const res = await orbit.request("POST", "/messages", {
      to: row.to,
      from: row.from,
      channel: "sms",
      body: row.body,
    }, { headers: { "Idempotency-Key": `src-${row.sourceId}` } });
    return (res as { data: { id: string } }).data.id; // msg_… — store against row.sourceId
  }
  ```
</CodeGroup>

The response carries Orbit's own id, a `msg_…` string. Store the pair `(source id → msg id)` in your mapping table the moment the post succeeds; Orbit does not echo or persist your source id on the message row, so the mapping table is the only place the two sides join. The channel list for the unified send (sms, whatsapp, and the rest) is the one documented at [API recipes: third-party migrators](/guides/api-recipes/third-party-migrators) and the per-channel guides; WhatsApp history beyond Meta's own window arrives as free-text records, not templates. For WhatsApp cutover specifics, including the 24-hour customer-care window on *new* sends, see [Migration from Twilio](/guides/migration-from-twilio).

Rate-limit your loop to the key's allowance: on `429 RATE_LIMITED`, read `Retry-After` (and `error.retry_after`), wait, and retry with the same `Idempotency-Key`.

## 2. Map: keep source ids where you can prove them

Two levels of mapping, matching what each surface actually stores:

* **Messaging services** carry the source id in Orbit itself: create the service with `metadata.external_id` set to the old Messaging Service SID (`SM…`), exactly as [loop 1 of the migrator recipes](/guides/api-recipes/third-party-migrators#1-resolve-a-twilio-messaging-service-sid-into-messaging_service_id) shows. History rows you post under that service then inherit a resolvable source handle.
* **Messages** carry Orbit's `msg_…` id only. Your mapping table is the join: `source_id → msg_id`, written in section 1, read back in sections 3 and 4. Do not encode source ids in the message body to smuggle them through; that pollutes the transcript your agents and inbox render.

## 3. Read-path reconciliation: agree on what landed

Reconcile in two passes. First, spot-check the mapping: `GET /messages/:id` for a sample of mapped ids (and every row your ingest flagged as retried).

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/messages/msg_01hxv4k2d8e9f0a1b2c3d4e5f6a7b8c9 \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node.js theme={null}
const one = await orbit.request("GET", `/messages/${msgId}`);
// (one as { data: { id: string; channel: string; status: string } }).data
```

Branch on the outcome:

| `GET /messages/:id` reply | Meaning | Branch |
| - | - | - |
| `200` with the expected body | Row landed | Mark the pair reconciled |
| `404 NOT_FOUND` | A well-formed `msg_…` id with no row behind it | The post never committed: re-post with the same `Idempotency-Key`, then re-map |
| `422 VALIDATION_ERROR` | The id is not a `msg_…` id at all (wrong prefix, a source `SM…` id, a channel name) | A mapping-table bug in your script: fix the stored id, never retry blindly |

Orbit's message read path returns `404` for absent rows; it has no tombstone state on reads. If your *source* system answers `410 Gone` for deleted history, treat that as "removed by design" and count the row as reconciled-removed rather than missing: Orbit intentionally does not replicate source-side deletions it never observed.

Second, count-check the whole window: `POST /messages/export` pulls up to 10,000 rows with the same filters the list accepts (channel, status, `from_date`/`to_date`), so per-day buckets give you an exact landed-count per channel to compare against the source's per-day count.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/export \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms", "from_date": "2026-06-01", "to_date": "2026-06-02" }'
```

Convert the export to CSV and diff column-by-column against the source export; the column semantics are documented at [Message export columns vs CSV](/guides/messages-export-columns-vs-csv).

## 4. Delivery receipts: reconcile statuses, not just rows

A history row is only as good as its final status. Subscribe one tenant-wide webhook endpoint to `message.delivered` and `message.failed` (the loop at [third-party migrators](/guides/api-recipes/third-party-migrators#3-rewire-status-callbacks-into-orbit-webhooks) shows the envelope), and match events to source rows through your mapping table on `data.message_id`. For historical rows that predate the cutover, the source's terminal status is authoritative: copy it into your reconciliation report rather than waiting for Orbit receipts, since a row imported after delivery will not generate a new carrier receipt.

Where a source system spooled undelivered mail or SMS for later retry (the IMAP-spool and `imap.raise` failure class some legacy SMS gateways document), do not try to replay the spool into Orbit: post the row once with its terminal status recorded in your report, and let Orbit's own delivery pipeline own everything from the cutover moment forward. Scope the receipt comparison to HTTP status plus `message.delivered` / `message.failed` events; anything the source retried internally shows up on your side as a single final status.

Outbound traffic never routes through your old provider as part of an import: history ingest writes rows into Orbit, and live sends exit through Orbit only.

## Errors as recipes

| Signal | `error.code` | HTTP | Branch |
| - | - | - | - |
| Ingest loop too fast | `RATE_LIMITED` | 429 | Wait `Retry-After`, retry with the same `Idempotency-Key` |
| Body or phone failed validation | `VALIDATION_ERROR` / `INVALID_PHONE_NUMBER` | 422 | Normalize to E.164, fix the named field, re-post under a new key only if the row genuinely changed |
| Read-back of a mapped id | `NOT_FOUND` | 404 | Re-post with the same `Idempotency-Key`, then re-map |
| Read-back with a source-format id | `VALIDATION_ERROR` | 422 | Mapping-table bug: store the `msg_…` id, not the `SM…` |
| Wizard history pull older than 90 days | *(dry-run/run rejection on `conversationDays`)* | 422 | Twilio exposes at most 90 days without Insights; pull the rest with this page's per-message loop |

## See also

* [API recipes: third-party migrators](/guides/api-recipes/third-party-migrators) — the five loops this page's mapping and webhook recipes hang off
* [Assisted import wizard](/guides/assisted-import-wizard) — the `conversations` entity that pulls Twilio history server-side
* [Imports API](/api-reference/imports) — the connect/dry-run/run/track/rollback surface for configuration
* [Message export columns vs CSV](/guides/messages-export-columns-vs-csv) — the column semantics for the count-check diff
* [Idempotent requests](/guides/idempotent-requests) — `Idempotency-Key` semantics the ingest loop relies on


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.