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.Master-data intake
Endpoints and upsert keys
Request format
Each of these endpoints accepts either a bare array of rows or an envelope object: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 with409and nothing is written:Resend the batch withoverwrite: 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
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 andgtin 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 ownsizeReference. That is how the CSV sheet declares sizes — a cell cannot hold an array. A request may mix both spellings. POST /api/ppwr/formatsdoes 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
invalidorfailed. The one exception is the409overwrite 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 withoutinternalRef(see above). importedcounts entities, not rows. A size is not an entity of its own: one created with a new unit is covered by that unit’simported; one created on an existing unit appears inupdatedas<unit> / <size>. The size count is available to machines insizesCreated.
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.Quantity feed & tonnage
Quantities per SKU, market and year
The recurring feed: a sales/billing extract, one row per SKU × market × year.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
articleNumberis a row error (unknownArticleNumber), never an invented article; a key repeated inside one batch is first-seen-wins, and the later rows reportduplicateInFile. 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.
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.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 (seeambiguous_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
Eachformat 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
reportTypeandsystemOperatorIdare required forlucid_deand unused by the worksheet registers (they always produce the annual report).reportTypeis the LUCID Meldungsart:HPM1(Planmengenmeldung),HMM1(interim report),HJM1(year-end report),HNM1(supplementary report),HAM1(deduction report).systemOperatorIdis your dual system’s LUCID number: two letters and 13 digits (lower case is accepted and upper-cased).marketis ISO alpha-2 (case-insensitive);periodYearis 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:
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:- Components (
POST /api/ppwr/components) withmaterialsandmassGrams. - Units (
POST /api/ppwr/units) — the sellable packaging, linked to components viaPOST /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 carryingformatReference. - SKU links —
articleNumberson the unit row, orPOST /api/ppwr/article-links: which article ships in which unit. This is what later turns sales figures into packaging masses.
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.