Skip to main content

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.
On top of the general status codes, these PPWR-specific cases exist:

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 with rows:
The rows are checked with the same row schemas as the file importer, but the API does not translate values: send the canonical values (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, role and notes; on component rows code, name and materials. A row without them lands in invalid. Their values are compared with the stored ones like any other cell: a different name from 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 with 409 and nothing is written:
    Resend the batch with overwrite: true, or drop the affected rows.
  • A stated no switches a yes/no flag off. For contactSensitive and foodContact (components) and placedUnderOwnBrand, modifiesConformity and reusable (packaging units), a blank cell keeps the stored value; false (or "no") on a stored true is an overwrite and needs overwrite: 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 foodContact off is checked against the stored PFAS data: foodContactOffWithPfasData, or pfasOnlyFoodContact if 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, supplierName is ignored — the supplier is set when the record is created and never changed by the intake.
  • Components are looked up by code in the named supplier’s catalogue, otherwise in your own. Inside one request they are de-duplicated by code alone: two rows with the same code but different suppliers are one key, and the second row fails with duplicateInFile.
  • A row whose supplierName matches no supplier yet, for a code that already exists only under another supplier, fails with ambiguousComponentMatch — and no supplier is created for it.

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 packaging 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 — with GET /api/v1/ppwr/units (see Reading master data back) or via the Packaging units list in the app or its export. links and article-links address packaging units by specInternalRef.
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 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, a size whose reference is 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 with duplicateFormatReference or formatCapReached. A size that is refused inside the request itself — the same reference twice in one unit (duplicateFormatReference) or a conflictingSizeRow — 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 own sizeReference. That is how the CSV sheet declares sizes — a cell cannot hold an array. A request may mix both spellings.
  • POST /api/v1/ppwr/formats writes sizes as a standalone endpoint, for callers that already send sizes separately (see below).
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/links creates a link between a packaging unit and a component. It never changes an existing link: a different quantity, or a massGrams without formatReference, on a link that already exists is ignored and the row comes back in skipped. Change those in the app.
  • A links row with formatReference always sets the component’s mass for that size to the row’s massGrams, also when the link already exists (the row still comes back in skipped). A blank massGrams there keeps the stored mass for that size.
  • POST /api/v1/ppwr/article-links creates a link between a packaging unit and an article; an existing link is skipped.

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

  • invalid holds rows refused by the row schema, before anything is written; index is the row’s position in the request (from 0). failed holds rows the engine refused.
  • 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).
  • failed also holds new packaging units refused because your plan’s packaging-unit quota is reached, with packagingUnitQuotaReached (their sizes too). A refused unit creates nothing, not even its supplier.
What each field holds, per endpoint: 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.
Every key is reported per row. Schema keys appear in invalid[]: Engine keys appear in failed[]:
Two endpoints 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/v1/ppwr/article-links reports the same number as unknownArticleNumber. Likewise componentCodes creates a placeholder component for an unknown code, while POST /api/v1/ppwr/links reports unknownComponentCode. For an ambiguous code, however, both fail with ambiguousComponentMatch — the only fix is to make the code unique.

Reading master data back

Everything the intake writes can be read back, with packaging:read (a key holding packaging:write covers it too).
  • From the key to the id. ?internalRef=U-1 and ?code=K-1 resolve the intake’s upsert key to its row — that is how you obtain the id the detail endpoints expect. An empty value is not a filter that matches nothing, it is no filter at all.
  • code matches 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=none to your own catalogue. Any other value matches nothing rather than widening the result.
  • Paging is bounded. Without limit you get the first 100 rows (max limit 1000); page with offset until a short page. The ordering is stable (packaging units by name, components naturally by code, each with the id as the tiebreak), so page boundaries never repeat or skip a row. An invalid value is a 400.
  • Deleted rows stay out, and another company’s, an unknown or a malformed id is always a 404 — never a 403, 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. Requires packaging: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:
  1. Request an upload URL — with file name, MIME type and size.
  2. PUT the file to that URL with the same Content-Type, within 10 minutes.
  3. Pass the returned s3Key as storageKey to POST /api/v1/ppwr/documents.
The API accepts the same files as the dialog in the app: PDF, JPEG, PNG, at most 10 MB. The check happens when the URL is issued: any other MIME type, a missing 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: false with aiSkippedReason — the analysis was not queued: disabled (AI add-on not active), quota or spend (a monthly cap is reached) or enqueue (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:
with status 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.
After a successful attach the storageKey is used up. The file is moved out of the upload area and the upload key is deleted. If a network error left you without a response, start again at step 1 (new URL, new upload) — the same storageKey a second time yields 404 { "error": "upload_missing" }. If the first attach went through, step 3 answers 409 with the id of the existing document — no second copy is created. The same applies after a 500: the document may already be stored.

Errors

Not supported

  • Documents on re-use systems (pool documents) — in the app only.
  • Addressing by internalRef or code — 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, or null:
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. lang picks the language: exactly one of de 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 a 400 that lists the supported values. Without lang (or with an empty one) you get German.
  • lang=all returns 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 (lang does not apply). If that file was deleted, the answer is 404, although pdfUrl is set.

ERP recipe — write the DoC back to your item master

  • Poll GET /api/v1/ppwr/declarations?changedSince=<last run> (no status filter).
  • For each issued row with verified: true, store its referenceNumber and the pdfUrl file on each packaging unit in activeSpecIds, 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 use specIds here — it also lists units a newer DoC has taken over.
  • A row with verified: false is 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 in specIds.
  • Reconcile now and then with GET /api/v1/ppwr/units?include=doc: issued means a verified DoC is on file and current; outdated and in_review need work in the app; ready, blocked and collect have no DoC yet; not_applicable needs none.

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. 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 quantity is 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. A quantity must be ≥ 0.
  • 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.
  • 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 with quantity: 0. Without replaceScope, a batch may leave out SKUs that did not change. Send replaceScope only 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.
The job’s 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.
A figure typed in the app is never overwritten — neither by the quantity feed nor by tonnage. Affected packaging 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. A figure that was taken over in the app from order data is not protected: the feed overwrites it.The way out differs: hand a packaging unit back to the feed by clearing all of its typed figures in the app (only clearing — saving a new number keeps the packaging unit manual); the feed’s figure then applies immediately. 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.
A unit counts either its unit-level figure or its per-size figures, never both. The feed’s figure is computed per size when every linked SKU of a multi-size unit names a size, otherwise on the unit level. Link every SKU of a unit whose sizes weigh differently to its size.
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 (see ambiguous_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.
A tonnage row replaces the SKU-derived mass for its category, period and market — the two are never added.
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

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

  • reportType is required for lucid_de. systemOperatorId is required for lucid_de unless a valid SystemOperatorID is stored in the app: an omitted one then defaults to the stored one, and a value you send wins. The 201 answers the systemOperatorId that was used. The worksheet registers do not use them (they always produce the annual report); a malformed systemOperatorId is still a 400 there.
  • 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 } (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").
The scope is periodYear, market, format and reportType. A LUCID generation supersedes only the previous report of the same Meldungsart for that year: generating an HJM1 for 2026 leaves an earlier HMM1 or HPM1 for 2026 untouched, and version counts per Meldungsart. superseded means “not the latest generation”, not “withdrawn at LUCID”.
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:
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/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:
  1. Components (POST /api/v1/ppwr/components) with materials and massGrams.
  2. Packaging units (POST /api/v1/ppwr/units) — the sellable packaging, linked to components via POST /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 carrying formatReference.
  3. SKU links — articleNumbers on the unit row, or POST /api/v1/ppwr/article-links: which article ships in which packaging unit. This is what later turns sales figures into packaging masses.
For the worksheet registers, confirm the category mappings in the app 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/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.
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 and one browser upload away from done.