Overview
The PPWR API is the complete machine surface for EPR volume reporting. It consists of these groups, which build on one another:
The master-data intake runs on 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. One difference: the
API does not translate values — see Request format.
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, and the PPWR
module must be enabled for the user the key acts as (a personal key: its owner; a company key: the
workspace’s highest-ranking active user). Otherwise the answer is
403 (in a customer workspace:
PPWR compliance module is not enabled). 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
All five endpoints respond synchronously with the result in the body — unlike
POST /api/v1/ppwr/quantities, which creates an asynchronous job. A single object without rows is a 400.
Request format
Each of these endpoints accepts either a bare array of rows or an object withrows:
plastic, sales, manufacturer, …). Localized labels
such as Kunststoff and your workspace’s saved value mappings work in file imports only; over the API
such a value fails with invalidPpwrValue. On the row itself 12 and "12", ["plastic"] and
"plastic;paper_board", true and "yes" are each equivalent. Inside sizes[] this does not apply:
gtin and reference must be JSON strings ("4012345678918", not a number), otherwise the whole
row lands in invalid.
overwrite only has an effect on components, units and sizes (formats). The
bill-of-materials and article-link endpoints accept the field and ignore it. What they do instead is described under
Sizes and links: no confirmation.Column policy: fill-only by default
- An empty or omitted optional cell changes nothing — it never clears a stored value.
-
Mandatory columns are needed on every row, also on a row that only updates an existing
record: on unit rows
name,packagingType,roleandnotes; on component rowscode,nameandmaterials. A row without them lands ininvalid. Their values are compared with the stored ones like any other cell: a differentnamefrom your ERP is an overwrite. - 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. -
A stated
noswitches a yes/no flag off. ForcontactSensitiveandfoodContact(components) andplacedUnderOwnBrand,modifiesConformityandreusable(packaging units), a blank cell keeps the stored value;false(or"no") on a storedtrueis an overwrite and needsoverwrite: true. -
Cross-field rules use the effective flags. When a row states a yes/no flag or a PFAS value, the
rules use the row’s cell and, where a cell is blank, the stored value. A row with PFAS values for a
component already stored as food contact is accepted. A row that switches a stored
foodContactoff is checked against the stored PFAS data:foodContactOffWithPfasData, orpfasOnlyFoodContactif the row itself carries PFAS values. - Provenance and evidence columns (last supplying supplier, timestamps, document back-pointers) are never writable through this API.
Suppliers
supplierName on a component or unit row is matched loosely against your suppliers: case,
umlauts (ä = ae), punctuation and legal forms such as GmbH or AG are ignored, so Verso matches
Verso AG.
- On a create, a name that matches none of your suppliers creates a new supplier with that name
(no email, no country, status “data incomplete”, marked as PPWR-relevant), and the new record is
attached to it. The result lists the new names in
suppliersCreated. Check spelling before you send: a typo creates a second supplier. - A name that matches two or more of your suppliers fails the row with
ambiguousSupplierName. - On an existing component or unit,
supplierNameis ignored — the supplier is set when the record is created and never changed by the intake. - Components are looked up by
codein the named supplier’s catalogue, otherwise in your own. Inside one request they are de-duplicated bycodealone: two rows with the samecodebut different suppliers are one key, and the second row fails withduplicateInFile. - A row whose
supplierNamematches no supplier yet, for acodethat already exists only under another supplier, fails withambiguousComponentMatch— and no supplier is created for it.
Units: the reference trap
notes is mandatory on unit rows — it is the “description & intended use” the form requires
too. A row without a non-empty notes value fails with descriptionRequired, also when it only
updates an existing packaging unit.
A unit row may name its components in componentCodes (a code with no component yet creates a
placeholder component) or, for a single-part packaging, carry material and optionally massGrams:
the packaging unit then gets one main-part component of that material — an existing packaging unit
only while it has no component. material together with componentCodes fails with
materialWithComponentCodes; massGrams without material fails with materialsRequired.
componentCodes and articleNumbers only ever add links; they never remove one.
A packaging 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, a size whose
referenceis already used by another packaging unit or size, or one that would take the unit past 50 sizes, cancels the whole row: the packaging unit is not created, and the row fails withduplicateFormatReferenceorformatCapReached. A size that is refused inside the request itself — the samereferencetwice in one unit (duplicateFormatReference) or aconflictingSizeRow— fails alone, and the packaging unit is created without it. - If the row matches an existing packaging unit, each size is upserted on its own; a refused size
fails alone. Sizes the request does not mention are never deleted. A changed size value sent
here counts as an overwrite and needs
overwrite: true. - 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/v1/ppwr/formatswrites sizes as a standalone endpoint, for callers that already send sizes separately (see below).
Sizes and links: no confirmation
POST /api/v1/ppwr/formats follows the column policy of units and components: a blank cell keeps the
stored value, and a non-blank value that would change one is refused with
409 { "error": "overwriteRequired", "confirmUpdateCount": … } — nothing is written — until you
resend the batch with overwrite: true. A row whose reference is the packaging unit’s own
internalRef addresses the primary size — that is, the unit’s own GTIN and dimensions.
POST /api/v1/ppwr/linkscreates a link between a packaging unit and a component. It never changes an existing link: a differentquantity, or amassGramswithoutformatReference, on a link that already exists is ignored and the row comes back inskipped. Change those in the app.- A
linksrow withformatReferencealways sets the component’s mass for that size to the row’smassGrams, also when the link already exists (the row still comes back inskipped). A blankmassGramsthere keeps the stored mass for that size. POST /api/v1/ppwr/article-linkscreates a link between a packaging unit and an article; an existing link isskipped.
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 packaging unit
holds at most 50, the primary one included), so the row cap alone does not bound the write. Above
either cap the answer is 400 with Too many rows: max 1000 per request or
Too many sizes: max 1000 per request, and nothing is written. There is no per-key limit, but the
general rate limit of 1000 requests per minute per
IP applies.
Result
invalidholds rows refused by the row schema, before anything is written;indexis the row’s position in the request (from0).failedholds rows the engine refused.- 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). failedalso holds new packaging units refused because your plan’s packaging-unit quota is reached, withpackagingUnitQuotaReached(their sizes too). A refused unit creates nothing, not even its supplier.
On
units, a size created together with a new packaging unit is covered by that unit’s imported;
a size created on an existing packaging unit appears in updated as <unit> / <size>.
Error keys
The values in
failed[] are stable, non-localized machine keys — anything unexpected is
clamped to dbError. In invalid[] it is usually a key too, but a field with no custom message
(for example a text over 120 characters, or an entry in sizes[]) falls back to the validator’s
own English text. Match on the keys you know and display the rest verbatim.invalid[]:
Engine keys appear in
failed[]:
Reading master data back
Everything the intake writes can be read back, withpackaging:read (a key holding
packaging:write covers it too).
- From the key to the id.
?internalRef=U-1and?code=K-1resolve the intake’s upsert key to its row — that is how you obtain theidthe detail endpoints expect. An empty value is not a filter that matches nothing, it is no filter at all. codematches a set, not a row. A component code is unique per supplier: the same code may sit once in your own catalogue and once under a replying supplier.?supplierId=<uuid>narrows to one,?supplierId=noneto your own catalogue. Any other value matches nothing rather than widening the result.- Paging is bounded. Without
limityou get the first 100 rows (maxlimit1000); page withoffsetuntil a short page. The ordering is stable (packaging units by name, components naturally by code, each with theidas the tiebreak), so page boundaries never repeat or skip a row. An invalid value is a400. - Deleted rows stay out, and another company’s, an unknown or a malformed id is always a
404— never a403, which would confirm that the id exists somewhere.
Uploading documents
A document — a test report, a declaration of conformity, a technical drawing — can be attached to a packaging unit or a component over the API. It is the API twin of Upload evidence in the app and runs the same code: the document is stored with the type you send as approved, identical bytes are recognised as a duplicate, and the document is read by the AI when your plan includes it. Requirespackaging:write.
Why two steps
The file does not travel through the API; it goes straight to storage. The reason is the platform: request bodies are capped at about 4.5 MB, documents may be up to 10 MB. There is therefore no single-call multipart upload. The flow is:- Request an upload URL — with file name, MIME type and size.
PUTthe file to that URL with the sameContent-Type, within 10 minutes.- Pass the returned
s3KeyasstorageKeytoPOST /api/v1/ppwr/documents.
fileSize or a fileSize over
the limit is a 400, and no URL is issued. fileSize must be the file’s exact byte count (an integer
greater than 0): it is signed into the URL, and storage refuses a PUT of any other length with
403.
The target: a UUID, not a key
target is exactly one of { "packagingSpecId": "<id>" } (packaging unit) or
{ "componentId": "<id>" } (component), each the UUID. The intake’s upsert keys (internalRef,
code) address nothing here — a component code is not unique (see
Reading master data back). Resolve the id first:
GET /api/v1/ppwr/units?internalRef=U-1 or GET /api/v1/ppwr/components?code=K-1.
Example
<UNIT_ID> is the id from GET /api/v1/ppwr/units?internalRef=U-1, <S3_KEY> the s3Key from
step 1. fileName is a required key in step 3; its value may be null.
Document types
type is always required. Attaching without a type is a 400; an untyped upload for the AI
to classify is not possible over the API.
A unit-only type sent to a component is refused with
404 { "error": "target" } — just as the
dialog in the app does not offer it there. What the AI reads from which type is described on the
Evidence page.
AI analysis: extractionQueued and aiSkippedReason
Filing never depends on the AI. A 201 means the document is stored and visible on the
packaging unit or component. Whether it is also analysed is in the response:
extractionQueued: true— the analysis is queued; its suggestions then appear in the review workspace in the app.extractionQueued: falsewithaiSkippedReason— the analysis was not queued:disabled(AI add-on not active),quotaorspend(a monthly cap is reached) orenqueue(queueing failed — the document is stored; trigger the analysis again in the app).
Duplicates and retries
When a live document of your company already has exactly the same bytes, nothing is written:409. anchor names where the existing document is attached (kind is unit or
component), or is null. To attach the file a second time anyway (say, to another packaging unit),
re-send the same body with "allowDuplicate": true — the uploaded file is kept for that, no new
upload is needed.
If the only copy with these bytes is in the trash, it is restored and attached to the new target:
you get 201 with that document’s original id, not a 409.
Errors
Not supported
- Documents on re-use systems (pool documents) — in the app only.
- Addressing by
internalReforcode— by UUID only. - Single-call upload (multipart) — because of the platform’s body cap, see Why two steps.
- Supplier replies — those arrive through the supplier portal, not through this endpoint.
Reading Declarations of Conformity
Read back whether a packaging unit has a Declaration of Conformity (DoC), which one, and where its PDF is. Same requirements as the other read endpoints:packaging:read, customer workspace, PPWR
module.
GET /api/v1/ppwr/units?include=doc
include=doc is opt-in (it costs an extra read per page); without it the rows are unchanged. Any
other include value is a 400. Each row gains:
-
docState— the same value the app’s units table shows: -
activeDeclaration— the packaging unit’s active declaration, ornull:
fields accepts docState and activeDeclaration like any other key, and naming either implies
include=doc — a selected key never comes back missing.
Listing and reading declarations
GET /api/v1/ppwr/declarations and GET /api/v1/ppwr/declarations/{id} return the same fields as
activeDeclaration, plus specIds (every packaging unit the declaration has ever covered — a family
DoC is one row) and activeSpecIds (the packaging units it is currently the active DoC for). A
family DoC stays issued when a newer DoC takes over some of its units; those units then leave its
activeSpecIds but stay in specIds. A family DoC carries supersedesId: null. Never the signed
snapshot or the file itself. pdfUrl is null for a draft, and for a collected declaration whose
uploaded file is no longer on record (verified is then false too). supersededAt is when a newer
declaration replaced this one for its last packaging unit; null unless status is superseded.
Filters, all optional, each passed at most once (a blank value is no filter, a malformed one is a
400):
limit / offset as on the other lists (100 default, max 1000). Ordered by issuedAt, newest
first, drafts last.
changedSince matches a declaration issued, withdrawn or re-checked (whatever the outcome of the
check) at or after the instant, a collected one whose uploaded file expired (it then reads
verified: false), and one that lost a packaging unit to a newer declaration in that time — a
superseded declaration as well as a family DoC that stays issued with fewer activeSpecIds.
Drafts never match.
404 on /declarations/{id} for an unknown, malformed, deleted or another company’s id.
Downloading the PDF
GET /api/v1/ppwr/declarations/{id}/pdf returns the file behind pdfUrl.
- A generated DoC is rendered from the snapshot frozen at signing, after re-verifying its hash.
langpicks the language: exactly one ofde en fr es it nl bg cs da el et fi hr hu lt lv pl pt ro sk sl sv, case-insensitive (EN=en). Any other value —ga, a typo — is a400that lists the supported values. Withoutlang(or with an empty one) you get German. lang=allreturns a ZIP with one PDF per language the packaging unit’s markets require (Art. 39(2)), Irish and Maltese not included. For a family DoC the languages come from one active member (the one with the lowest id). A withdrawn or superseded DoC has no active member, so its ZIP holds the German PDF only.- A collected DoC is the manufacturer’s uploaded file, served unchanged, as PDF, PNG or JPEG with
the matching
Content-Type(langdoes not apply). If that file was deleted, the answer is404, althoughpdfUrlis set.
ERP recipe — write the DoC back to your item master
- Poll
GET /api/v1/ppwr/declarations?changedSince=<last run>(nostatusfilter). - For each
issuedrow withverified: true, store itsreferenceNumberand thepdfUrlfile on each packaging unit inactiveSpecIds, overwriting what that unit had. This is also how a superseded DoC is replaced: its successor shows up in the poll, the superseded one too (ignore it). Do not usespecIdshere — it also lists units a newer DoC has taken over. - A row with
verified: falseis not on file (a collected DoC not yet verified, or its file expired): clear what you stored for that declaration. - On
withdrawn, clear what you stored for that declaration on every packaging unit inspecIds. - Reconcile now and then with
GET /api/v1/ppwr/units?include=doc:issuedmeans a verified DoC is on file and current;outdatedandin_reviewneed work in the app;ready,blockedandcollecthave no DoC yet;not_applicableneeds none.
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. statusUrl and Location point to /api/import-jobs/{jobId}, which is the same endpoint as
/api/v1/import-jobs/{jobId}. Poll it (needs packaging:read) until status is terminal — see
Batch Import (async).
The whole batch is checked before the job is created, and one bad row rejects the whole batch —
never a job that fails minutes later:
- Each
quantityis the absolute total for that SKU, market and year, and replaces the stored value — it is never added to it. Never send increments, returns or credit notes as separate rows: a credit-note row would replace the year’s total with the credit amount. Net them into the total and send the corrected total; re-sending a period is the normal way to correct it. Aquantitymust be ≥ 0. - 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. - An article that exists but is linked to no packaging unit is accepted and counted in
imported/updated, but lands on no packaging unit and in no report. Nothing in the result flags it — check the SKU links before you trust the totals. replaceScope: "period-market"declares the batch the complete set of every(periodYear, market)pair it carries: stored rows in exactly those pairs that the batch omits are deleted. Only pairs from rows whose article number was found count. A pair the batch does not carry at all is never touched — a market you stopped selling in keeps its old rows; to zero it, send its SKUs withquantity: 0. WithoutreplaceScope, a batch may leave out SKUs that did not change. SendreplaceScopeonly as"period-market", otherwise omit it.- Per-unit quantities are derived, not pushed. A packaging unit’s placed figure is the sum of its linked SKUs’ quantities. A packaging unit that loses all its linked quantities no longer counts a feed figure.
result carries
{ imported, updated, deleted, failed[], derived, cleared, conflicts[], multiLinkedArticleNrs[] }.
For the periods and markets the batch touched, derived is the number of packaging units whose
counted figures come from the feed after the batch, and cleared the number that counted the feed’s
figures before the batch and no longer do. conflicts lists the packaging units whose figures typed
in the app win over the feed (see below).
multiLinkedArticleNrs is a notice, not an error. It lists the article numbers whose
quantity feeds more than one packaging unit — article and packaging unit are deliberately
many-to-many (e.g. sales and transport packaging), so the same quantity lands on every linked
packaging unit. Review those articles for double counting. The notice never blocks.Feed figures are computed whenever they are read; link and SKU changes apply without a re-push.
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? }, as a bare array or in rows.
zsvrCategory is one of the eight VerpackG material categories, sent as a JSON string ("50000",
not 50000): 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.
market accepts any two letters and stores them, but only DE is ever read.
The response is 200 with { written, skipped, failed, invalid }:
failed and invalid items are { index, identifier, error }; identifier is the row’s
zsvrCategory when it was sent as a string (otherwise empty), and an invalid error is the
validator’s English text. An empty batch is 200 with empty
arrays; more than 1,000 rows is 400 (Too many rows: max 1000 per request). A key repeated in one
batch: the last row wins, and both rows are listed in written.
DELETE /api/v1/ppwr/tonnage?periodYear=2026&market=DE&zsvrCategory=50000 removes one tonnage row;
the SKU-derived mass for that category counts again. A row entered in the app is not removed:
409 with code: "manualOverridePresent" — delete it in the app. An unknown key is 404, also on a
repeat after a success.
Tonnage is also the 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. To move a
category to SKU-based figures later, delete its tonnage rows (in the app or with
DELETE /api/v1/ppwr/tonnage); until then the quantity feed has no effect on that category.
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 file is a deterministic worksheet — the
per-category figures in that portal’s own categories, units of measure and order — which a person
types into the portal. There is nothing more a file could automate.
Generating a report
reportTypeis required forlucid_de.systemOperatorIdis required forlucid_deunless a valid SystemOperatorID is stored in the app: an omitted one then defaults to the stored one, and a value you send wins. The201answers thesystemOperatorIdthat was used. The worksheet registers do not use them (they always produce the annual report); a malformedsystemOperatorIdis still a400there.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 } (for lucid_de with systemOperatorId as well): the
report was generated and frozen. Re-generating after the data changed is the normal flow: the new
report supersedes the previous one of the same type (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). If every placed
packaging unit is reusable, tonnage figures are ignored as well and the answer is
nothing_to_declare too.
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/v1/ppwr/mengenmeldung/{reportId}/xml returns 200 with the file as an attachment: for
lucid_de the LUCID Datenmeldung XML (application/xml; charset=utf-8), for the other registers the
JSON worksheet (application/json; charset=utf-8). A superseded report downloads too. The file is
rebuilt from the frozen snapshot after re-verifying the snapshot’s SHA-256.
403—{ "error": "…", "code": "EXPORTS_BLOCKED" }when exports are locked for your workspace, or the PPWR module is not enabled.404— unknown or malformed report id, including any id belonging to another company.409—{ "error": "Report integrity check failed" }: the snapshot no longer matches its hash; the file is refused rather than served.
Integration recipe ERP to LUCID
Step 1 is a data project; everything after it is routine. 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/v1/ppwr/components) withmaterialsandmassGrams. - Packaging units (
POST /api/v1/ppwr/units) — the sellable packaging, linked to components viaPOST /api/v1/ppwr/links. A packaging 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/v1/ppwr/article-links: which article ships in which packaging 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/v1/ppwr/quantities, then poll
GET /api/v1/import-jobs/{jobId}. Cadence is yours. Every row carries the absolute total for its
SKU, market and year — net returns and credit notes into that total; exporters that send the full
set for a period and market send replaceScope: "period-market".3
Generate the report
POST /api/v1/ppwr/mengenmeldung/reports for the period, market and register, with your
systemOperatorId and reportType for LUCID. 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/v1/ppwr/mengenmeldung/reports?periodYear=2026&market=DE&status=generated, then
GET /api/v1/ppwr/mengenmeldung/{id}/xml. Hash-verified; 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.