Overview
Batch imports run asynchronously: the POST stages an import job and responds
immediately with 202 and a jobId. Your system then polls the job
(GET /api/import-jobs/{jobId}) until the import finishes and collects the full result
there — including the reasons for skipped or failed rows.
The import runs through the same engine as the in-app CSV importer: same validation,
same idempotent upsert, same result shape.
| Endpoint | Operation | Upsert key |
|---|
POST /api/articles | Bulk-import articles | internalArticleNr |
POST /api/suppliers | Bulk-import suppliers | externalSupplierId or name |
POST /api/orders | Bulk-import orders | orderNumber |
GET /api/import-jobs/{jobId} | Poll job status and result | — |
A POST with a single object (no rows array) stays synchronous and returns
201 unchanged. Only the batch form (array or envelope) is async.
Flow
-
Send the batch —
POST a bare array of rows or an envelope object:
{ "rows": [ { "…": "…" } ] }
On success: 202 with { "jobId": "…", "status": "queued", "statusUrl": "/api/import-jobs/…" }
(also sent as a Location header). Nothing is imported at this point. Row
validation runs synchronously — a bad payload is rejected with 400 right away and
no job is staged.
-
Poll —
GET /api/import-jobs/{jobId} every ~2 seconds, backing off for large
jobs (e.g. exponentially up to ~30 s). completed and failed are terminal — stop
polling then.
-
Collect the result — once
status is "completed", result holds the merged
import result (imported, updated, failed, skipped, … per entity). Failed rows
carry stable, non-localized error keys.
Limits
- Max 5,000 rows per request. Above that:
413 { "error": "batchTooLarge", "maxBatchSize": 5000 } —
nothing is staged. Page a larger sync across multiple requests.
- ~4.5 MB request body (platform limit, enforced before our application runs).
- An empty array or a malformed envelope →
400.
Partial accept and idempotency
- Valid rows commit even when other rows land in
failed.
- Replaying an identical import is safe: matched rows come back as skipped/updated,
no duplicates are created.
Orders: replaceLines and the completeness rule
Importing an order replaces its entire line set. A request that names order O
must therefore carry all of O’s lines. If one order’s lines are split across
multiple requests, the second request would delete the previously imported lines.
The line-replacement gate turns that into a loud failure instead of silent data loss:
-
Default (
replaceLines absent or false): if the batch would delete stored lines,
the whole job fails before the first write. The job’s error field contains:
{ "key": "lineReplacementRequired", "orderNumbers": ["O"] }
Resend the listed orders with their complete line sets.
-
"replaceLines": true authorizes the replacement: the submitted line set becomes
authoritative, even where lines are removed.
Error keys (stable, non-localized)
| Key | Meaning |
|---|
batchTooLarge | request exceeded the 5,000-row cap (HTTP 413) |
lineReplacementRequired | an orders job would delete stored lines — resend full line sets or set replaceLines |
dispatchFailed | the job was staged but processing could not be started (HTTP 500); no work will run |
invalidPayload | the rows failed re-validation in the worker; error holds a JSON summary (path + code, never the values themselves) |
workerError | unexpected error after all retries (details captured server-side) |
dbError | per row: an unexpected database failure with no specific key |
Retention
The job result stays pollable; the staged input payload is freed ~24 hours after the
job finishes, and the job record is deleted ~30 days after creation. Poll and persist
the result on your side within that window.
Permissions
The POST requires the entity’s write scope (articles:write, suppliers:write,
orders:write); polling requires the matching read scope (implied by write). Jobs
are strictly tenant-bound: an unknown jobId and another company’s jobId return the
same 404.