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

# PPWR: Packaging Data, Quantities, Volume Reports

> The complete machine surface for EPR volume reporting — master-data intake, quantity feed and report generation

## Overview

The PPWR API is the complete machine surface for EPR volume reporting. It consists of three groups
that build on one another:

| Group                    | Endpoints                                                  | Purpose                                                                                 |
| :----------------------- | :--------------------------------------------------------- | :-------------------------------------------------------------------------------------- |
| **Master-data intake**   | `components`, `units`, `formats`, `links`, `article-links` | One-time modelling: components, packaging units, their sizes and the links between them |
| **Quantities & tonnage** | `quantities`, `tonnage`                                    | The recurring push from sales/billing                                                   |
| **Volume reports**       | `mengenmeldung/reports`, `mengenmeldung/{reportId}/xml`    | Generate, list and download the report                                                  |

The master-data intake is a thin HTTP adapter over the **same** upsert engine as the CSV/Excel
import in the app: same row schemas, same validation, same write path, same idempotency, same column
policy. Anything the file importer accepts, the API accepts — with identical results.

## Permissions

The key mechanism, base URL and the general status codes are covered in the
[Introduction](/en/api-reference/introduction). PPWR adds two scopes:

| Scope             | Applies to                                     |
| :---------------- | :--------------------------------------------- |
| `packaging:write` | Every `POST` endpoint on this page             |
| `packaging:read`  | Report list, file download, import-job polling |

The write scope includes the read scope — a key with `packaging:write` covers the whole integration
recipe below.

<Info>
  **Module and tenant boundary.** Every endpoint requires a **customer workspace** with the **PPWR
  module enabled**. A supplier or partner workspace, and an account without the PPWR module, receive
  `403`. Each key is bound to exactly one company; it can neither read nor write another company's
  data.
</Info>

On top of the general status codes, these PPWR-specific cases exist:

| Code  | Meaning                                                                                                           |
| :---- | :---------------------------------------------------------------------------------------------------------------- |
| `400` | Row or size cap exceeded; on `POST /api/ppwr/quantities` also an **empty** batch (`At least one row is required`) |
| `409` | Unconfirmed overwrite (`overwriteRequired`) or a report collision (`reportAlreadyGenerated`)                      |
| `413` | Quantity batch above 5,000 rows (`batchTooLarge`)                                                                 |
| `422` | The report is not declarable yet (`blocking_errors` / `nothing_to_declare`)                                       |

## Master-data intake

### Endpoints and upsert keys

| Endpoint                       | Operation                                   | Upsert key                                                |
| :----------------------------- | :------------------------------------------ | :-------------------------------------------------------- |
| `POST /api/ppwr/components`    | Create & update components                  | `code` (plus the resolved supplier)                       |
| `POST /api/ppwr/units`         | Create & update packaging units             | `internalRef` — **optional**, blank always means "create" |
| `POST /api/ppwr/formats`       | Create & update a unit's sizes              | `specInternalRef` + `reference`                           |
| `POST /api/ppwr/links`         | Create bill-of-materials links (idempotent) | `specInternalRef` + `componentCode`                       |
| `POST /api/ppwr/article-links` | Create article links (idempotent)           | `specInternalRef` + `articleNumber`                       |

### Request format

Each of these endpoints accepts **either a bare array of rows** or an envelope object:

```json theme={null}
{
  "rows": [ { "…": "…" } ],
  "overwrite": false
}
```

Values are coerced exactly as cells in the spreadsheet importer are. `12` and `"12"`, `["plastic"]`
and `"plastic;paper_board"`, `true` and `"yes"` are each equivalent.

<Info>
  **`overwrite` only has an effect on components and units.** The size, bill-of-materials and
  article-link endpoints accept the field but ignore it: sizes are updated blank-is-keep (there is
  nothing to confirm), and links are create-only.
</Info>

### Column policy: fill-only by default

* An **empty or omitted** cell is a **no-op** — it never clears a stored value.
* A cell that **fills** a previously empty field is applied automatically.
* A cell that would **overwrite** an existing value (including supplier- or AI-attested regulated
  values) requires **`overwrite: true`**. Without that flag, a batch containing any such change is
  refused with `409` and **nothing** is written:

  ```json theme={null}
  { "error": "overwriteRequired", "confirmUpdateCount": 3 }
  ```

  Resend the batch with `overwrite: true`, or drop the affected rows.
* **Provenance and evidence columns** (last supplying supplier, timestamps, evidence back-pointers)
  are **never** writable through this API.

### Units: the reference trap

<Warning>
  **A unit row without `internalRef` is always a create.** The reference *is* the upsert key — a row
  that states none can match nothing. The server assigns your company's next free reference
  `PU-<n>`. Sending the same row again creates **another** unit.

  Send an `internalRef` on every row you intend to reuse later. If you have imported without
  references, **read the assigned `PU-n` values back first** — currently only via the **Packaging
  units** list in the app or its export, as there is no read endpoint for them yet. `links` and
  `article-links` address units by `specInternalRef`.
</Warning>

`notes` is **mandatory** on unit rows — it is the "description & intended use" the form gates on
too. A row without a non-empty `notes` value fails with `descriptionRequired`, including a fill-only
update of an existing unit.

### A unit's sizes

The unit row's own dimensions and `gtin` are its **first (primary) size**. Further sizes go into the
optional `sizes` array of the same row — this is the intended way:

```json theme={null}
{
  "rows": [
    {
      "name": "Tea filter folding box", "internalRef": "U-1",
      "packagingType": "sales", "role": "manufacturer",
      "notes": "Folding box for tea filters",
      "gtin": "4012345678901", "widthMm": 100, "heightMm": 150, "depthMm": 40,
      "sizes": [
        { "reference": "U-1-500", "label": "500 g", "gtin": "4012345678918", "widthMm": 120 },
        { "reference": "U-1-1KG", "label": "1 kg" }
      ]
    }
  ]
}
```

* **On a create the row is one transaction:** if a size is refused (`duplicateFormatReference`,
  `formatCapReached`), the unit is not created either.
* **If the row matches an existing unit**, each size is upserted on its own; a refused size fails
  alone. Sizes the payload does not mention are **never** deleted.
* The same grouping also works flat: several rows sharing one `internalRef`, each with its own
  `sizeReference`. That is how the CSV sheet declares sizes — a cell cannot hold an array. A request
  may mix both spellings.
* `POST /api/ppwr/formats` does the same thing as a standalone endpoint, for callers that already
  send sizes separately.

### Limits

At most **1,000 rows** per request (`400`). The non-obvious one: a unit request additionally carries
at most **1,000 `sizes` entries across all rows** — a row may ask for up to 49 sizes (a unit holds
at most 50, the primary one included), so the row cap alone does not bound the write. There is no
separate rate limiter.

### Result

```json theme={null}
{
  "imported": 2,
  "updated": ["K-3"],
  "skipped": ["K-4"],
  "failed": [{ "identifier": "K-5", "error": "ambiguousComponentMatch" }],
  "invalid": [{ "index": 7, "identifier": "K-6", "error": "materialsRequired" }]
}
```

* **Partial accept:** valid rows are committed even when others land in `invalid` or `failed`. The
  one exception is the `409` overwrite gate, which applies to the whole batch.
* **Idempotency:** resending an identical request changes nothing; matched rows come back as
  `skipped`. The exception is unit rows without `internalRef` (see above).
* **`imported` counts entities, not rows.** A size is not an entity of its own: one created with a
  new unit is covered by that unit's `imported`; one created on an **existing** unit appears in
  `updated` as `<unit> / <size>`. The size count is available to machines in `sizesCreated`.

### Error keys

<Info>
  The values in `failed[]` are **stable, non-localized machine keys** — anything unexpected is
  clamped to `dbError`. In `invalid[]` (schema validation, before the engine runs) it is usually a
  key too, but a field with no custom message falls back to the validator's own English text. Match
  on the keys you know and display the rest verbatim.
</Info>

The most important keys — among others:

| Key                                                              | Meaning                                                                                                                                                                                                                                                                               |
| :--------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `materialsRequired`                                              | Component row without a material                                                                                                                                                                                                                                                      |
| `descriptionRequired`                                            | Unit row without `notes`                                                                                                                                                                                                                                                              |
| `internalRefRequired` / `codeRequired` / `articleNumberRequired` | A mandatory identifier is missing                                                                                                                                                                                                                                                     |
| `duplicateInFile`                                                | The same key appears several times in one request — the first occurrence wins                                                                                                                                                                                                         |
| `ambiguousSupplierName`                                          | The supplier name matches **two or more** of your suppliers — the row is refused rather than guessed. (A name matching **none** is not an error: the record lands in your own bucket with no supplier.)                                                                               |
| `ambiguousComponentMatch`                                        | In the component intake: the row names **no** supplier, but a supplier-owned component already holds that code — the import will not guess between your own bucket and the supplier's. In `componentCodes` and `POST /api/ppwr/links`: the code names **two or more** live components |
| `unknownComponentCode`                                           | `POST /api/ppwr/links`: the code names no live component — **no** placeholder is created here                                                                                                                                                                                         |
| `unknownUnitRef`                                                 | No live `specInternalRef` in **your** company (another tenant's reference never resolves)                                                                                                                                                                                             |
| `duplicateFormatReference`                                       | The `reference` already belongs to another unit or size                                                                                                                                                                                                                               |
| `formatCapReached`                                               | The unit already carries 50 sizes — updating its existing sizes still works                                                                                                                                                                                                           |
| `conflictingSizeRow`                                             | A size row contradicts the unit's own columns, carries `componentCodes`, or a unit row carries `sizeLabel` without `sizeReference`                                                                                                                                                    |
| `unknownFormatRef`                                               | `formatReference` names no size of this unit — the link is **not** created                                                                                                                                                                                                            |
| `duplicateInternalRef` / `duplicateComponentCode`                | A concurrent request took that reference or code between planning and writing — resend the row                                                                                                                                                                                        |
| `dbError`                                                        | Unexpected database error with no specific key                                                                                                                                                                                                                                        |

<Warning>
  **Two doors onto the same link behave differently.** The unit row's `articleNumbers` column
  **silently skips** an unknown article number (it only counts in `articleNumbersUnmatched`), while
  `POST /api/ppwr/article-links` reports the same number as an **error row**. Likewise
  `componentCodes` auto-creates a placeholder component for an unknown code, while
  `POST /api/ppwr/links` reports `unknownComponentCode`. For an **ambiguous** code, however,
  **both** doors fail with `ambiguousComponentMatch` — the only fix is to make the code unique.
</Warning>

## Quantity feed & tonnage

| Endpoint                    | Shape                       | Upsert key                                |
| :-------------------------- | :-------------------------- | :---------------------------------------- |
| `POST /api/ppwr/quantities` | **Async job**, ≤ 5,000 rows | `articleNumber` + `periodYear` + `market` |
| `POST /api/ppwr/tonnage`    | Synchronous, ≤ 1,000 rows   | `zsvrCategory` + `periodYear` + `market`  |

### Quantities per SKU, market and year

The recurring feed: a sales/billing extract, one row per SKU × market × year.

```json theme={null}
{
  "rows": [
    { "articleNumber": "SKU-1", "periodYear": 2026, "market": "DE", "quantity": 125000 },
    { "articleNumber": "SKU-1", "periodYear": 2026, "market": "FR", "quantity": 31000 }
  ],
  "replaceScope": "period-market"
}
```

The response is `202` with `{ "jobId": …, "status": "queued", "statusUrl": … }` and a `Location`
header. Then poll `GET /api/import-jobs/{jobId}` (needs `packaging:read`) until `status` is terminal
— see [Batch Import (async)](/en/api-reference/batch-import). Validation runs **before** staging: a
malformed or empty batch is an immediate `400` (or `413` above the 5,000-row cap), never a job that
fails minutes later.

* **Restatement is the norm.** Re-pushing a period with corrected numbers is an idempotent upsert. An
  unknown `articleNumber` is a row error (`unknownArticleNumber`), never an invented article; a key
  repeated inside one batch is first-seen-wins, and the later rows report `duplicateInFile`.
* **`replaceScope: "period-market"`** declares the batch the authoritative snapshot of every
  `(periodYear, market)` pair it mentions: stored rows in exactly those scopes that the batch omits
  are deleted. Other scopes are never touched. Leave the field out for a delta feed.
* **Per-unit quantities are derived, not pushed.** After each accepted batch, every packaging unit's
  placed figure is recomputed as the sum of its linked SKUs' quantities. A unit that loses **all**
  its linked quantities has its derived figure cleared rather than left stale.

The job's `result` carries
`{ imported, updated, deleted, failed[], derived, cleared, conflicts[], multiLinkedArticleNrs[] }`.

<Info>
  **`multiLinkedArticleNrs` is an honesty flag, not an error.** It lists the **article numbers**
  whose volume feeds **more than one** packaging unit — article and unit are deliberately
  many-to-many (e.g. sales *and* transport packaging), so the same volume lands on **every** linked
  unit. Review those articles for double counting. The flag never blocks.
</Info>

<Warning>
  **A hand-entered figure is never overwritten** — neither in the quantity feed nor in tonnage.
  Affected units come back in the job result's `conflicts` with `manualOverridePresent`, affected
  material categories in the tonnage response's `skipped`; the remaining rows still commit. There is
  no confirm-and-retry — a scheduled push could not answer one.

  The escape hatch differs: hand a **unit** back to the feed by **clearing** its figure in the app
  (only clearing — saving a new number keeps the unit manual). A **tonnage row** cannot be empty
  because `tonnageKg` is mandatory; delete the manual entry in the app and the next push recreates
  it as an ERP-owned row.
</Warning>

<Info>
  **The feed writes unit-level figures only.** A packaging unit may have several sizes, and a figure
  can name one — the quantity feed never does, because its rows are SKU quantities and say nothing
  about which size shipped. Per-size figures are entered in the app; a push neither reads,
  overwrites nor clears them. On a unit whose sizes weigh a component differently, a unit-level
  figure cannot be converted into mass — the report then blocks with `placement_needs_format` until
  the figure is entered per size.
</Info>

<Note>
  **Recomputation happens on a push**, not continuously. Unlinking a SKU from a unit, or deleting a
  SKU, therefore leaves that unit's derived figure stale until the next push for the same period and
  market.
</Note>

### Tonnage per material category

The aggregate-kg mode, for a producer whose packaging composition is not modelled unit by unit — and
at the same time the way to declare ambiguous material families (see `ambiguous_material` below).
**LUCID (Germany) only.**

Rows are `{ periodYear, market, zsvrCategory, tonnageKg, note? }`; the response is `200` with
`{ written, skipped, failed, invalid }`. `zsvrCategory` is one of the eight VerpackG material
categories (`10000` glass, `20000` paper/board/carton, `30000` ferrous metals, `40000` aluminium,
`50000` plastics, `60000` beverage cartons, `70000` other composites, `80000` other materials).
`tonnageKg` is a **JSON number** — unlike the master-data rows, no numeric string is coerced here.

This is also the first-class **fallback mode**: if your packaging-composition data is (still) too
poor for bill-of-materials modelling, skip steps 1–3 of the
[integration recipe](#integration-recipe-erp-to-lucid) and push category totals only — you can move to
SKU grain at any time, and the precedence rules keep the two from fighting.

## Volume reports

| Endpoint                                     | Operation                | Scope             |
| :------------------------------------------- | :----------------------- | :---------------- |
| `POST /api/ppwr/mengenmeldung/reports`       | Generate a report        | `packaging:write` |
| `GET /api/ppwr/mengenmeldung/reports`        | List generated reports   | `packaging:read`  |
| `GET /api/ppwr/mengenmeldung/{reportId}/xml` | Download the report file | `packaging:read`  |

### Registers and markets

Each `format` belongs to exactly one register and one market — send the matching `market`. A
mismatched market (e.g. `verpact_nl` with `DE`) is rejected with `400`.

| `format`     | Register              | `market` | Download produces                  |
| :----------- | :-------------------- | :------- | :--------------------------------- |
| `lucid_de`   | LUCID (ZSVR, Germany) | `DE`     | Datenmeldung **XML** (upload file) |
| `verpact_nl` | Verpact (Netherlands) | `NL`     | Transcription worksheet (JSON)     |
| `ara_at`     | ARA (Austria)         | `AT`     | Transcription worksheet (JSON)     |
| `bdo_pl`     | BDO (Poland)          | `PL`     | Transcription worksheet (JSON)     |
| `miteco_es`  | MITECO (Spain)        | `ES`     | Transcription worksheet (JSON)     |
| `conai_it`   | CONAI (Italy)         | `IT`     | Transcription worksheet (JSON)     |

The non-LUCID registers are portal-form-only. Their artifact is a deterministic worksheet — the
per-category figures in that portal's own categories, units and order — which a human transcribes
there. There is nothing more a file could automate.

### Generating a report

```json theme={null}
{
  "periodYear": 2026,
  "market": "DE",
  "format": "lucid_de",
  "reportType": "HJM1",
  "systemOperatorId": "DE1234567890123"
}
```

* `reportType` and `systemOperatorId` are **required for `lucid_de`** and unused by the worksheet
  registers (they always produce the annual report).
* `reportType` is the LUCID Meldungsart: `HPM1` (Planmengenmeldung), `HMM1` (interim report), `HJM1`
  (year-end report), `HNM1` (supplementary report), `HAM1` (deduction report).
* `systemOperatorId` is your dual system's LUCID number: two letters and 13 digits (lower case is
  accepted and upper-cased).
* `market` is ISO alpha-2 (case-insensitive); `periodYear` is the calendar year (2000–2100).

**`201`** — `{ "id": "…", "version": 2 }`: the report was generated and frozen. Re-generating the
same scope after the data changed is the normal flow: the new report supersedes the previous one
(`version` increments, the old row stays listed with `status: "superseded"`).

**`409`** — `{ "error": "reportAlreadyGenerated" }`: two generations for the same scope raced and one
won. Re-list and retry if you still need a newer version.

**`422`** — the data is not declarable yet; **nothing** was written:

```json theme={null}
{ "error": "blocking_errors", "blockingErrors": [ { "kind": "missing_mass", "specRef": "U-1" } ] }
```

| `kind`                     | How to resolve                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| :------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing_mass`             | A component in a linked unit has no weight — add the mass (API, CSV, supplier or app).                                                                                                                                                                                                                                                                                                                                                          |
| `ambiguous_material`       | A material whose register category cannot be derived (for LUCID: the metal and composite families). **For LUCID this is resolved with figures, not a mapping:** declare kg for *every* category of the named family, per period and market — in the app, or via `POST /api/ppwr/tonnage` (enter `0` for categories you do not place). For worksheet registers, confirm the component's category once in the app; it is remembered per register. |
| `ambiguous_multi_material` | A multi-material component: the mass split is unknowable — do **not** try to split it. For LUCID, model it as a single `composite` component and declare its kg via the composite-family figures. For worksheet registers, map the component to one of its `candidates` in the app.                                                                                                                                                             |
| `unmapped_material`        | Worksheet registers only: a material with no category in that register's taxonomy.                                                                                                                                                                                                                                                                                                                                                              |
| `missing_channel`          | Worksheet registers only: the disposal channel (e.g. household vs. commercial) has not been chosen for that unit yet — once per unit, in the app.                                                                                                                                                                                                                                                                                               |
| `placement_needs_format`   | A unit whose sizes weigh a component differently carries only a unit-level figure — enter it per size.                                                                                                                                                                                                                                                                                                                                          |

`{ "error": "nothing_to_declare" }` (with an empty `blockingErrors`) means there are no declarable
figures for that period and market — for LUCID neither derived quantities nor tonnage figures, for
the worksheet registers no derived quantities (tonnage figures feed LUCID only).

### Listing reports

Optional query parameters: `periodYear`, `market`, `status` (`generated` or `superseded`), plus
`limit` and `offset` — paging applies **after** the filters. An invalid value for a known parameter
is a `400`; unrecognized parameter names are ignored.

The response is a JSON array, newest first, and carries metadata only — never the frozen
computation:

```json theme={null}
[
  {
    "id": "3f0e…",
    "periodYear": 2026,
    "market": "DE",
    "format": "lucid_de",
    "reportType": "HJM1",
    "systemOperatorId": "DE1234567890123",
    "xsdVersion": "…",
    "status": "generated",
    "version": 2,
    "supersedesId": "9a1b…",
    "snapshotHash": "…",
    "generatedAt": "2026-08-17T09:30:00.000Z"
  }
]
```

The report to download is the one with `status: "generated"` — exactly one per `periodYear`,
`market` and `format`. Worksheet registers always report `reportType: "ANNUAL"`, an empty
`systemOperatorId` and `xsdVersion: "worksheet-1"`.

### Downloading the file

`GET /api/ppwr/mengenmeldung/{reportId}/xml` returns `200` with the file as an attachment: for
`lucid_de` the LUCID Datenmeldung XML, for the other registers the JSON worksheet. The file is
re-derived from the frozen snapshot **after re-verifying its SHA-256** — a downloaded report is
therefore byte-identical to what was generated, every time.

* `404` — unknown or malformed report id, including any id belonging to another company.
* `409` — the snapshot integrity check failed; the file is refused rather than served.

## Integration recipe ERP to LUCID

The honest version up front: **step 1 is a data project, everything after it is plumbing.** Getting
packaging master data to declarable quality is real work — once, and again only when the assortment
changes. The recurring part (quantities in, report out) is a scheduled job and two HTTP calls.

<Steps>
  <Step title="One-time: packaging master data">
    Create an API key first; one with `packaging:write` covers everything. The register declares
    **mass per material category**, so every sold SKU must resolve to components with mono-material
    weights:

    1. **Components** (`POST /api/ppwr/components`) with `materials` and `massGrams`.
    2. **Units** (`POST /api/ppwr/units`) — the sellable packaging, linked to components via
       `POST /api/ppwr/links`. A unit that ships in several sizes declares them on the row itself
       (`sizes[]`); its per-size component masses come from bill-of-materials rows carrying
       `formatReference`.
    3. **SKU links** — `articleNumbers` on the unit row, or `POST /api/ppwr/article-links`: which
       article ships in which unit. This is what later turns sales figures into packaging masses.

    In the app rather than over the API: store your **SystemOperatorID**. For the worksheet
    registers, also confirm the category mappings where a material is ambiguous (once per register).
    For LUCID there is nothing to map — ambiguous families are declared as per-period figures.
  </Step>

  <Step title="Recurring: quantities per SKU, market and year">
    A scheduled export from sales/billing to `POST /api/ppwr/quantities`, then poll
    `GET /api/import-jobs/{jobId}`. Cadence is yours: corrections (returns, credit notes) are an
    idempotent upsert; full-snapshot exporters send `replaceScope: "period-market"`.
  </Step>

  <Step title="Generate the report">
    `POST /api/ppwr/mengenmeldung/reports` for the period, market and register. A `422` tells you
    item by item which data is not declarable yet — fix, re-push, generate again. A `201` is a
    frozen, versioned report.
  </Step>

  <Step title="Fetch the file">
    `GET /api/ppwr/mengenmeldung/reports?periodYear=2026&market=DE&status=generated`, then
    `GET /api/ppwr/mengenmeldung/{id}/xml`. Hash-verified and reproducible; the LUCID XML conforms to
    the ZSVR's published XSD.
  </Step>

  <Step title="Upload to LUCID — the one click that is not automated">
    Log in at LUCID, open **Datenmeldung**, upload the XML.

    <Warning>
      That click **cannot be automated — by us or by anyone**: the ZSVR provides no submission API,
      and VerpackDG §5(1) S.2 makes registration and the Datenmeldung personal obligations of the
      producer that cannot be delegated to a third party. The pipeline is built to make this the
      *only* manual step, and to make it trivial: the file is final, validated, and one browser
      upload away from done.
    </Warning>
  </Step>
</Steps>
