Historical imports
/v1/inbox/import/* is how history gets into Inbox — the routes axis-api's migration commands call
during cutover. They write conversations, tickets, contacts, segments, campaigns and flows that
happened somewhere else, backdated to when they really occurred.
Every route is admin
| Method | Route | Batch field |
|---|---|---|
POST |
/v1/inbox/import/threads |
threads |
POST |
/v1/inbox/import/tickets |
tickets |
POST |
/v1/inbox/import/contacts |
contacts |
POST |
/v1/inbox/import/segments |
segments |
POST |
/v1/inbox/import/campaigns |
campaigns |
POST |
/v1/inbox/import/campaign-links |
links |
POST |
/v1/inbox/import/flows |
flows |
GET |
/v1/inbox/import/jobs/:id |
— |
admin for the same reason POST /v1/connections/import is: these write history the caller merely
asserts happened. Nothing validates that a message with a 2023 timestamp was ever sent. An ordinary
inbox-capable key can reply to a conversation; it must not be able to invent one, backdate it, and
have it appear in every report as though it were real.
Being POSTs, they also need an Idempotency-Key like any other write.
Async by design
Each POST persists the batch as an import_job, enqueues it, and returns 202 immediately:
{ "data": { "jobId": "cmr…", "status": "queued" }, "meta": { "requestId": "…" } }
No per-row work happens in the request. The synchronous version timed out at 30 seconds on large batches, and a timed-out import left the caller's ledger disagreeing with what had actually been written — some rows in, the command convinced none were.
A BullMQ worker (ImportProcessor, on the inbox-import queue) runs the bulk import off-request,
then calls back to axis-api with the per-row results. The caller also polls:
curl -sS "${AUTH[@]}" $INBOX/v1/inbox/import/jobs/$JOB_ID
{
"data": {
"jobId": "cmr…",
"kind": "contacts",
"status": "done",
"totals": { … },
"jobError": null,
"results": { … }
}
}
results is the per-kind response verbatim — the same shape the old synchronous body returned —
so reconciliation is identical whichever of the callback or the poll lands first. The job is
tenant-scoped: a job is visible only to the tenant that created it, and anything else 404s.
The error field is named
jobError, noterror, and that is load-bearing. The global response interceptor treats an object containingdataorerroras already shaped and returns it unwrapped — so a top-levelerrorkey would strip thedataenvelope, and a poller readingdata.statuswould see nulls forever and time out.
Job lifecycle
kind |
contacts, threads, tickets, segments, campaigns, campaign-links, flows |
|---|---|
status |
queued → processing → done, or failed (retryable) / dead_letter (attempts exhausted) |
failed is a non-terminal failure the worker may retry with backoff; dead_letter is where it lands
when RETRY_MAX_ATTEMPTS is spent.
One thing is still synchronous: an empty or absent batch is rejected with validation_failed
(422) before a job is created. A malformed request is the caller's error and must surface now, not
enqueue a job doomed to fail in a worker where nobody is watching.
Batch caps
| Cap | Value |
|---|---|
| Threads per batch | 500 |
| Entries per thread | 5,000 |
| Tickets per batch | 1,000 |
| Contacts per batch | 1,000 |
| Segments per batch | 200 |
| Members per segment | 5,000 |
| Campaigns per batch | 100 |
| Recipients per campaign | 5,000 |
| Flows per batch | 25 |
Exceeding one is a validation_failed naming the limit. Chunk client-side and accumulate — the
imports are idempotent, so chunk boundaries do not matter.
The 25 MB body limit
Fastify's default bodyLimit is 1 MB. Inbox raises it to 25 MB:
const adapter = new FastifyAdapter({ maxParamLength: 512, bodyLimit: 25 * 1024 * 1024 });
The reason is the import rows, which are rich rather than thin. A campaign's recipients each carry a full delivery lifecycle — several ISO timestamps, a service message id, cost and currency, error strings — so even ~1,300 of them exceed 1 MB. One real campaign (#48, "Tulivu Gardens") 413'd at 1,323 recipients.
The per-batch row caps already bound the realistic ceiling well under 25 MB; the raise only stops a legitimate import being rejected as too large. Live webhook and API traffic is far smaller and unaffected. Note that a reverse proxy in front of the service needs its own limit raised to match, or it 413s before the request arrives.
Reconciliation by source key
Imports are re-runnable. Every kind is idempotent by constraint, not by a read-then-write check:
| Kind | Idempotent on |
|---|---|
| Contacts | A caller-supplied sourceKey (e.g. legacy:<contactId>), required |
| Threads | Thread upsert, plus @@unique(threadId, externalEntryId) per entry |
| Segments | legacySegmentId, and each membership on its own constraint |
| Campaigns | legacyCampaignId; each recipient on (campaignId, contactId) |
| Flows | Flow, nodes, edges, versions and runs each on their source id |
sourceKey on contacts is required rather than optional because a generated id would make the
import non-idempotent for exactly the rows that most need it: a re-run would fork a second contact
for the same person. A contact row without one is rejected with reason: 'sourceKey is required'
rather than being silently imported.
Because dedup is enforced by database constraints, a re-run restamps rather than duplicates, and
a partially-failed batch can be resubmitted whole. Reconcile by comparing the per-row results
against your source ledger keyed on the same sourceKey, not by counting rows.
Entry ids deserve a note: externalEntryId is the dedup key, and some providers repeat a message id
within one conversation, so the import derives an id that stays stable across re-runs rather than
trusting the provider's verbatim. Ingest additionally applies its own 60-second body-and-author
dedup window, which is correct for a live webhook and shows up in import results as
suppressed by the ingest dedup window.
Operator visibility
GET /v1/operator/imports and GET /v1/operator/tenants/:tenantId/imports list import jobs across
the estate — the place to look when a migration command reports a job that never completed. See
The operator surface.