> ## Documentation Index
> Fetch the complete documentation index at: https://help.polygon-one.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Batch Import (async)

> Bulk-import articles, suppliers and orders — stage a job, poll its status, collect the result

## 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 | —                            |

<Info>
  A `POST` with a **single object** (no `rows` array) stays synchronous and returns
  `201` unchanged. Only the batch form (array or envelope) is async.
</Info>

## Flow

1. **Send the batch** — `POST` a bare array of rows or an envelope object:

   ```json theme={null}
   { "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.

2. **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.

3. **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:

  ```json theme={null}
  { "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`.
