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

# Orders: lines, positions, status, delta sync

> Order-specific rules of the orders API — complete line sets, `positionId`, `status`, `changedSince`, per-order copies

You push orders (purchase and sale) with `POST /api/v1/orders` and read them back with
`GET /api/v1/orders` — keyed by your own `orderNumber` and hydrated with the linked DDS
(`ddsReferences`). In a batch import each row is one order line; rows with the same `orderNumber`
form one order, which upserts idempotently on `orderNumber`. This page covers the rules that apply
to orders only. The generic flow — stage a job, poll its status, collect the result — is in
[Batch Import (async)](https://help.polygon-one.com/en/api-reference/batch-import.md).

An order's supplier, `orderType` and `orderDate` are fixed once the order is stored. A push that
changes any of them fails that order with `conflictingDuplicateOrderRows`. Rows of one order that
disagree on an order-level field (supplier fields, `orderType`, `orderDate`, `deliveryDate`, DDS
reference fields) fail the same way. A changed `deliveryDate` on an existing order is applied.

A `POST` with a **single object** creates one new order. If that `orderNumber` already exists, the
answer is `409`. Before you resend a single order, look it up with
`GET /api/v1/orders?orderNumbers=<nr>`, or send it as a one-row batch, which is safe to replay. The single-object
form stores `lines[].positionId`; mixed or repeated position ids answer `400` with `code`
(`positionIdMixed`, `duplicatePositionId`). `PATCH /api/v1/orders/{id}` answers the same way, and a
`PATCH` that changes the order number to one that already exists answers `409`.

## 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 check** turns that into a loud failure instead of silent data loss:

* Default (`replaceLines` absent or not `true`): if the batch would delete stored lines,
  the **whole job fails before the first write**. The job's `error` field is a string that
  holds JSON — parse it:

  ```json theme={null}
  { "error": "{\"key\":\"lineReplacementRequired\",\"orderNumbers\":[\"O\"]}" }
  ```

  Resend the listed orders with their **complete** line sets. One exception: if another write
  changes one of the orders while the job runs, the job can fail with this key after earlier
  orders of the same job were already written.
* `"replaceLines": true` authorizes the replacement: the submitted line set becomes
  authoritative, even where lines are removed. It must be the JSON boolean `true`, at the top
  level next to `rows`. The string `"true"` counts as not set, and a bare array of rows cannot
  set it.
* Never have the same `orderNumber` in two requests in flight at the same time: a company's jobs
  may run concurrently and their order is not guaranteed, so the stored lines could end up being
  the older request's. Send a changed order again only after the previous job for it has reached
  `completed` or `failed`.

## Positions (`positionId`)

Every row of a batch import may carry an optional `positionId` — the line's position number in
your ERP (e.g. the SAP purchase-order item `00010`). It is trimmed and then compared character by
character (not numerically, leading zeros are kept), at most 64 characters.

* **Rows with a `positionId` are never merged.** Two rows of the same article with different
  position ids stay two lines; without a `positionId` they are compared as one line
  (sum of quantities).
* **All or none per order:** within one request, every row of an order must carry a
  `positionId` or none of them (`positionIdMixed`). A position id may appear only once per
  order (`duplicatePositionId`). Either fails the whole order; nothing of it is written.
* **Matching:** when an order is sent again, each line is compared by its `positionId` — a
  changed quantity or a changed article on the same position id counts as a change, a missing
  position id as a removed line (line-replacement check, see [above](#orders:-replacelines-and-the-completeness-rule)).
* **Switching:** an order stored without position ids that is sent with `positionId` for the
  first time counts as fully replaced. The same holds the other way round: an order stored with
  position ids that is sent without them. Send that import with `"replaceLines": true`.
* **Read-back:** `GET /api/v1/orders` returns `positionId` on every line. DDS references are not
  linked to lines: `ddsReferences` belongs to the whole order (see below).

## Order status (`status`)

`GET /api/v1/orders` returns a `status` field on every order with one of three values. It is
derived on every read, never stored:

* **`notRelevant`:** no line of the order carries an EUDR-relevant article. Lines whose article
  was deleted do not count (they also disappear from `lines[]`). An article whose HS code still
  awaits the manual EUDR-relevance check (e.g. heading 9403) keeps the order `open`, not
  `notRelevant` or `done`; `lines[].article.needsManualCheck` is `true` on that line.
* **`done`:** every EUDR-relevant line is covered by a submitted, validated or grouped DDS linked
  to *this* order that declares the line's article **for the order's supplier** with enough
  volume. A DDS entry is a supplier article: it covers only orders of that same supplier and is
  consumed only by their quantities. The article's quantity summed over every order of that
  supplier linked to the DDS must not exceed the declared volume — exactly like the DDS's own
  remaining volume. An order of another supplier linked to that DDS by hand stays `open`.
* **`open`:** anything else — including an order with an article that awaits the manual
  EUDR-relevance check.

A quantity increase, an article change or a withdrawn/rejected DDS turns `done` back into
`open` — there is no reopen call.

* **Unblocking a position (e.g. an SAP block):** release a position only when

  1. the order's `status` is `done` (every EUDR-relevant line is covered by a filed DDS) or
     `notRelevant`, or
  2. for a single line, `lines[].article.eudrRelevant` is `false` **and**
     `lines[].article.needsManualCheck` is `false` (the line's article is not
     EUDR-relevant).

  A single position has no `notRelevant` of its own. Always decide on the current read, never on
  an earlier one. You need `ddsReferences` only after that decision: for the TRACES reference and
  the verification number you print on the documents.

> [!WARNING]
>
> Do not release goods because `ddsReferences` is non-empty. `ddsReferences` belongs to the whole
> order and lists every linked DDS that is not withdrawn or rejected — including drafts without a
> `referenceNumber`, external and archived statements. When an order carries several references,
> the API does not say which one covers which position.

* **`status` can be `open` while `ddsReferences` is non-empty.** `ddsReferences` lists every
  linked DDS that is not withdrawn or rejected — including drafts, archived, external,
  under-control and soft-deleted statements, and statements whose declared volume is
  over-assigned; none of those make the order `done`. Read the TRACES numbers to print from
  `ddsReferences` (an external DDS never makes an order `done`, so its reference is only here);
  they belong to the order, not to a position. Read `status` for *whether* the whole order is
  covered.
* **`?status=` filter:** takes exactly **one** value (`open`, `done` or `notRelevant`) and may be
  passed only once; anything else is a `400`.
* **Not every `status` flip is a `changedSince` change for the order.** These move `status`
  without moving the order: another order raising its quantity on the same DDS, an article's
  EUDR relevance changing, an article's manual check being resolved, an article on the order being deleted, a DDS article's declared volume
  being edited, a supplier article being deleted, a covering DDS's articles changing. Re-read with
  `?status=open&limit=1000` to reconcile, paging with `offset` until a page is shorter than
  `limit`.
* **Deleting a supplier removes all its orders from `GET /api/v1/orders`**, full syncs included.
  Treat an order that is missing from a full sync as deleted.

## Delta sync (`changedSince`)

Poll `GET /api/v1/orders?changedSince=<start of your previous poll minus a few minutes>&limit=1000`
(hourly recommended). `changedSince` is an ISO 8601 timestamp with a time zone (`Z` or an offset,
e.g. `2026-10-05T05:55:00Z`), passed once; anything else is a `400`.

A full page (1000 orders) means changed orders were left out. Page the same `changedSince` window
with `offset` until a page is shorter than `limit`, and poll more often. An order can appear on two
pages, and an order that changes while you page shows up again in the next delta. An order deleted
while you page can shift the pages so that one order is skipped — the nightly full sync catches it.

There is no `changedBefore`; a nightly full sync is the recovery for anything a delta missed:
deleted orders, orders of a deleted supplier and unlinked DDS never show up in a delta.

## Article fields on order rows are create-only

`hsCode` and `articleDescription`
are read only when `articleNumber` matches no article at all — the row then creates
it (counted in `autoCreated.articles`; a blank or placeholder `hsCode` such as `-` fails
the whole order with `articleMissingHsCode`; a deleted article's number fails it with
`articleDeleted`). For an existing article both fields are silently ignored (no
result flag) — the article's HS code and EUDR relevance stay unchanged. Change article
master data via `PATCH /api/v1/articles/{id}` or the articles batch import. On an article with
per-order copies (see below), that changes the template only: the copies keep their HS code, and
they take the new EUDR classification only if their HS code equals the template's and their order
has no linked DDS (other than withdrawn or rejected ones).

## HS code on order rows with per-order article copies (`hsCode`)

An order line can point at a per-order copy of an article instead of the article itself. The
article is then a template, and each order gets **one copy per article** (two lines of the same
article share it); the copy's `internalArticleNr` is `<article nr>#<orderNumber>`. An article is a
template when:

* a purchase-order import created it — in every workspace, with or without the setting below;
* it was sent with `sourceScope: "perOrder"`;
* the workspace setting **"order import creates per-order copies"** is on (off by default,
  switched on by Polygon One on request) and a purchase-order row names it — except an article
  that carries an **external article number** (articles created by the order import carry none).

A new copy starts with the template's HS code and EUDR classification. Without the setting,
`hsCode` on a known article number stays create-only (see above). With the setting, `hsCode` on a
purchase-order row works as follows:

* **The copy carries the row's HS code.** EUDR relevance is recomputed when the HS code differs
  from the template's; otherwise the template's classification is kept. A row without `hsCode`
  keeps the copy it has, or takes the template's HS code.
* **A changed HS code moves the line to another version.** Re-pushing the order with a
  **changed** HS code (compared without separators, so `4407.11` = `440711`) moves the line to the
  version of that order's copy that has this HS code. A new version is created only if none exists
  yet; an older version with that HS code is reused, together with its supplier data. A new
  version starts without supplier data. The other versions stay untouched.
* **Asking the supplier again.** The auto-invite automation (`automations.autoInviteSuppliers`)
  invites with its own rules: a supplier in an EU country is not invited; a supplier outside the
  EU is invited whatever the risk level of its country, as long as EUDR-relevant data is still
  missing; a supplier without an email address is skipped (you get a notification); a live invite
  that you deliver **manually** is widened to the new version but not emailed. If per-order origin
  confirmation (`automations.confirmOriginPerOrder`) is switched on and the supplier holds a live
  invite, the supplier can also get an origin-confirmation request. That request is keyed by the
  article, not by the HS code, so it can offer "origin unchanged" for the new version, based on
  an earlier answered copy.
* **Unchanged HS code:** an identical re-push changes nothing — no new version, no new request.
* **Conflicting HS codes:** two rows of one order naming one article with different HS codes —
  or a concurrent push that created the same version with another HS code — fail the order with
  `conflictingHsCodeForArticle`.

## Per-order origin via ERP

With per-order copies (see above), each order line points at its own copy of the article whose
`internalArticleNr` is `<article nr>#<orderNumber>`. An ERP can fill that copy directly, without the
supplier portal:

1. **Find the copy.** Take it from `GET /api/v1/orders` → `lines[].article` (`id`, and
   `supplierLinks: [{ supplierId, supplierArticleId }]`). You can also search
   `GET /api/v1/articles?internalArticleNr=<nr>%23<orderNumber>` (exact match; `#` encoded as
   `%23`); after an HS change this returns one article per version, so take the one the order
   line points at.
2. **Plots.** `POST /api/v1/plots` with `supplierArticleIds: [<supplierArticleId>]` (`plots:write`).
3. **Documents.** `POST /api/v1/evidence/upload-url` with `{ fileName, contentType, fileSize }` →
   `PUT` the bytes to `uploadUrl` (same `Content-Type`, exactly `fileSize` bytes, otherwise `403`,
   within 10 minutes) → `POST /api/v1/evidence`
   as `multipart/form-data` with `supplierId`, `articleId` (the copy's id), `uploadedS3Key` (the
   returned `s3Key`), `uploadedFileName`, `uploadedFileSize`, `uploadedMimeType` (`evidence:write`).
   This path takes files up to 10 MB. The `file` field instead of the `uploaded*` fields takes at
   most 4 MB (a larger file is a `400`). List what is stored with
   `GET /api/v1/evidence?supplierArticleId=<id>` (`evidence:read`).

   To attach the same file to further supplier articles, do not upload it again: send
   `POST /api/v1/evidence` per target with only `supplierId`, `articleId` and `reuseS3Key` (the
   `s3Key` from the first successful response), optionally with `evidenceType` or `note`. Each call
   creates its own evidence record on that article.

| Case | Response |
| :- | :- |
| Same bytes already on that supplier article | `409 { "error": "duplicate" }`, nothing written — re-send with `allowDuplicate=true` to store a second copy |
| `supplierId` + `articleId` with no supplier link in your company (also when the link belongs to another company) | `404` |
| An `uploadedS3Key` not issued to your company, or a `reuseS3Key` your company does not own | `403` |
| No source or more than one (`file`, `uploaded*` fields, `reuseS3Key`) | `400` |

Under v1 only `POST /api/v1/evidence/upload-url`, `POST /api/v1/evidence` and `GET /api/v1/evidence`
are part of the contract. Deleting or re-analysing evidence is not available to API keys (`403`;
the body is `{ "error": "Not available for API keys" }`, or `{ "error": "Forbidden" }` when the key
lacks the route's permission).

The **ERP integration** key preset includes `plots:write` and `evidence:write`. A key created with
this preset before 1 October 2026 lacks them: check the key's permissions, or create a new key.

A document pushed this way is buyer-authored evidence (recorded as uploaded by the API key's user); a
supplier's own upload through the portal carries stronger provenance.

### Have the supplier confirm the origin

Once all current plots (and documents) of an order are uploaded, ask the supplier to confirm that
origin data:

```bash theme={null}
curl -X POST "https://app.polygon-one.com/api/v1/orders/<orderId>/origin-confirmation" \
  -H "Authorization: Bearer eudr_8f7d2a..." \
  -H "Content-Type: application/json" \
  -d '{ "supplierArticleIds": ["<supplierArticleId>"] }'
```

* **Selection:** each `supplierArticleId` from `lines[].article.supplierLinks` of this order — a
  live link of an EUDR-relevant article on the order to its supplier. Validation is all-or-nothing:
  if one id is invalid, nothing is queued.
* **Response:** `202 { "status": "queued", "orderId", "supplierArticleCount" }`. The supplier
  request and its email are created asynchronously. Repeating the call merges into the active
  request and sends no further email.
* **What the supplier confirms:** this order's current origin data. Previous-order data is never
  copied, and the authorship of the data you uploaded stays unchanged.
* **Requirements:** `orders:write`, and per-order origin confirmation
  (`automations.confirmOriginPerOrder`) switched on for your workspace.

| Case | Response |
| :- | :- |
| An id is not a live EUDR link of this order and its supplier | `400` with `code: origin_confirmation_targets_invalid` |
| A target has no live plot | `409` with `code: origin_confirmation_plots_missing` |
| Per-order origin confirmation is not switched on | `409` with `code: origin_confirmation_disabled` |
| A supplier answer is being processed | `409` with `code: origin_confirmation_processing` |
| The order does not belong to your company | `404` |


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