Skip to main content

Overview

The PPWR API is the complete machine surface for EPR volume reporting. It consists of three groups that build on one another: 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. PPWR adds two scopes: The write scope includes the read scope — a key with packaging:write covers the whole integration recipe below.
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.
On top of the general status codes, these PPWR-specific cases exist:

Master-data intake

Endpoints and upsert keys

Request format

Each of these endpoints accepts either a bare array of rows or an envelope object:
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.
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.

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:
    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

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.
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:
  • 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

  • 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

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.
The most important keys — among others:
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.

Quantity feed & tonnage

Quantities per SKU, market and year

The recurring feed: a sales/billing extract, one row per SKU × market × year.
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). 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[] }.
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.
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.
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.
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.

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

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

  • 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:
{ "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:
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.
1

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

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".
3

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

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

Upload to LUCID — the one click that is not automated

Log in at LUCID, open Datenmeldung, upload the XML.
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.