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, not error, and that is load-bearing. The global response interceptor treats an object containing data or error as already shaped and returns it unwrapped — so a top-level error key would strip the data envelope, and a poller reading data.status would see nulls forever and time out.

Job lifecycle

kind contacts, threads, tickets, segments, campaigns, campaign-links, flows
status queuedprocessingdone, 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.