Zum Hauptinhalt springen

Übersicht

Die PPWR-API ist die vollständige Maschinenschnittstelle für die EPR-Mengenmeldung. Sie besteht aus diesen Gruppen, die aufeinander aufbauen: Der Stammdaten-Intake läuft über dieselbe Upsert-Engine wie der CSV-/Excel-Import in der App: gleiche Zeilenschemata, gleiche Validierung, gleicher Schreibpfad, gleiche Idempotenz, gleiche Spaltenpolitik. Ein Unterschied: Die API übersetzt keine Werte — siehe Anfrageformat.

Berechtigungen

Schlüsselmechanismus, Basis-URL und die allgemeinen Statuscodes stehen in der Einführung. Für PPWR gelten zwei Berechtigungen: Die Schreibberechtigung schließt die Leseberechtigung ein — ein Schlüssel mit packaging:write deckt das gesamte Integrationsrezept weiter unten ab.
Modul- und Mandantengrenze. Alle Endpunkte setzen einen Kundenarbeitsbereich voraus, und das PPWR-Modul muss für den Benutzer aktiviert sein, für den der Schlüssel handelt (persönlicher Schlüssel: sein Inhaber; Unternehmensschlüssel: der ranghöchste aktive Benutzer des Arbeitsbereichs). Sonst lautet die Antwort 403 (im Kundenarbeitsbereich: PPWR compliance module is not enabled). Jeder Schlüssel ist an genau ein Unternehmen gebunden; er kann Daten eines anderen Unternehmens weder lesen noch schreiben.
Zusätzlich zu den allgemeinen Statuscodes gibt es diese PPWR-eigenen Fälle:

Stammdaten-Intake

Endpunkte und Abgleichschlüssel

Alle fünf Endpunkte antworten synchron mit dem Ergebnis im Body — anders als POST /api/v1/ppwr/quantities, das einen asynchronen Job anlegt. Ein Einzelobjekt ohne rows ist 400.

Anfrageformat

Jeder dieser Endpunkte nimmt entweder ein bloßes Array von Zeilen oder ein Objekt mit rows:
Die Zeilen werden mit denselben Zeilenschemata geprüft wie im Datei-Import, aber die API übersetzt keine Werte: Senden Sie die kanonischen Werte (plastic, sales, manufacturer, …). Lokalisierte Bezeichnungen wie Kunststoff und die gespeicherten Wertzuordnungen Ihres Arbeitsbereichs wirken nur im Datei-Import; über die API scheitert ein solcher Wert mit invalidPpwrValue. Auf der Zeile selbst sind 12 und "12", ["plastic"] und "plastic;paper_board", true und "ja" jeweils gleichwertig. Innerhalb von sizes[] gilt das nicht: gtin und reference müssen JSON-Strings sein ("4012345678918", keine Zahl), sonst landet die ganze Zeile in invalid.
overwrite wirkt nur bei Komponenten, Verpackungseinheiten und Größen (formats). Die Stücklisten- und Artikelverknüpfungs-Endpunkte akzeptieren das Feld und ignorieren es. Was sie stattdessen tun, steht unter Größen und Verknüpfungen: keine Bestätigung.

Spaltenpolitik: standardmäßig nur befüllen

  • Eine leere oder weggelassene optionale Zelle ändert nichts — sie löscht nie einen gespeicherten Wert.
  • Pflichtspalten sind auf jeder Zeile nötig, auch auf einer Zeile, die nur einen bestehenden Datensatz aktualisiert: auf Einheiten-Zeilen name, packagingType, role und notes, auf Komponenten-Zeilen code, name und materials. Eine Zeile ohne sie landet in invalid. Ihre Werte werden wie jede andere Zelle mit den gespeicherten verglichen: Ein abweichender name aus Ihrem ERP ist eine Überschreibung.
  • Eine Zelle, die ein bisher leeres Feld befüllt, wird automatisch übernommen.
  • Eine Zelle, die einen vorhandenen Wert überschreiben würde (auch lieferanten- oder KI-attestierte regulatorische Werte), verlangt overwrite: true. Ohne dieses Flag wird ein Stapel, der eine solche Änderung enthält, mit 409 abgelehnt und es wird nichts geschrieben:
    Senden Sie den Stapel erneut mit overwrite: true oder entfernen Sie die betroffenen Zeilen.
  • Ein ausdrückliches „nein” schaltet ein Ja/Nein-Merkmal aus. Bei contactSensitive und foodContact (Komponenten) sowie placedUnderOwnBrand, modifiesConformity und reusable (Verpackungseinheiten) behält eine leere Zelle den gespeicherten Wert; false (oder "no") auf einem gespeicherten true ist eine Überschreibung und verlangt overwrite: true.
  • Feldübergreifende Regeln prüfen die wirksamen Merkmale. Nennt eine Zeile ein Ja/Nein-Merkmal oder einen PFAS-Wert, zählt die Zelle der Zeile und, wo sie leer ist, der gespeicherte Wert. Eine Zeile mit PFAS-Werten für eine Komponente, die bereits als Lebensmittelkontakt gespeichert ist, wird angenommen. Schaltet eine Zeile ein gespeichertes foodContact aus, werden die gespeicherten PFAS-Daten mitgeprüft: foodContactOffWithPfasData, oder pfasOnlyFoodContact, wenn die Zeile selbst PFAS-Werte enthält.
  • Herkunfts- und Dokumentspalten (zuletzt liefernder Lieferant, Zeitstempel, Verweise auf Dokumente) sind über diese API niemals beschreibbar.

Lieferanten

supplierName auf einer Komponenten- oder Einheiten-Zeile wird unscharf mit Ihren Lieferanten abgeglichen: Groß-/Kleinschreibung, Umlaute (ä = ae), Satzzeichen und Rechtsformen wie GmbH oder AG werden ignoriert, Verso trifft also Verso AG.
  • Beim Anlegen erzeugt ein Name, der keinen Ihrer Lieferanten trifft, einen neuen Lieferanten mit diesem Namen (ohne E-Mail, ohne Land, als PPWR-relevant markiert), und der neue Datensatz wird ihm zugeordnet. Das Ergebnis nennt die neuen Namen in suppliersCreated. Prüfen Sie die Schreibweise vor dem Senden: Ein Tippfehler legt einen zweiten Lieferanten an.
  • Ein Name, der zwei oder mehr Ihrer Lieferanten trifft, lässt die Zeile mit ambiguousSupplierName scheitern.
  • Bei einer bestehenden Komponente oder Verpackungseinheit wird supplierName ignoriert — der Lieferant wird beim Anlegen festgelegt und vom Intake nie geändert.
  • Komponenten werden über code im Katalog des genannten Lieferanten gesucht, sonst in Ihrem eigenen. Innerhalb einer Anfrage werden sie allein nach code zusammengefasst: Zwei Zeilen mit demselben code, aber verschiedenen Lieferanten, sind ein Schlüssel, und die zweite scheitert mit duplicateInFile.
  • Eine Zeile, deren supplierName noch keinen Lieferanten trifft, für einen code, den es bisher nur bei einem anderen Lieferanten gibt, scheitert mit ambiguousComponentMatch — und für sie wird kein Lieferant angelegt.

Einheiten: die Referenz-Falle

Eine Einheiten-Zeile ohne internalRef legt immer neu an. Die Referenz ist der Abgleichschlüssel — eine Zeile, die keine nennt, kann nichts treffen. Der Server vergibt die nächste freie Referenz PU-<n> Ihres Unternehmens. Wird dieselbe Zeile erneut gesendet, entsteht eine weitere Verpackungseinheit.Senden Sie deshalb auf jeder Zeile eine internalRef, die Sie später wiederverwenden wollen. Haben Sie ohne Referenzen importiert, lesen Sie die vergebenen PU-n-Werte zuerst zurück — mit GET /api/v1/ppwr/units (siehe Stammdaten zurücklesen) oder über die Liste Verpackungseinheiten in der App bzw. deren Export. links und article-links adressieren Verpackungseinheiten über specInternalRef.
notes ist auf Einheiten-Zeilen Pflicht — es ist die „Beschreibung & Verwendungszweck”, die auch das Formular verlangt. Eine Zeile ohne nicht-leeres notes scheitert mit descriptionRequired, auch wenn sie nur eine bestehende Verpackungseinheit aktualisiert. Eine Einheiten-Zeile kann ihre Komponenten in componentCodes nennen (ein Code ohne Komponente legt eine Platzhalter-Komponente an) oder, bei einer einteiligen Verpackung, material und optional massGrams tragen: Die Verpackungseinheit erhält dann eine Hauptteil-Komponente aus diesem Material — eine bestehende Verpackungseinheit nur, solange sie keine Komponente hat. material zusammen mit componentCodes scheitert mit materialWithComponentCodes, massGrams ohne material mit materialsRequired. componentCodes und articleNumbers fügen immer nur Verknüpfungen hinzu; sie entfernen nie eine.

Größen einer Einheit

Die eigenen Maße und die gtin der Einheiten-Zeile sind die erste (primäre) Größe. Weitere Größen senden Sie im optionalen Array sizes derselben Zeile — das ist der vorgesehene Weg:
  • Beim Anlegen bricht eine Größe, deren reference schon von einer anderen Verpackungseinheit oder Größe belegt ist, oder die die Einheit über 50 Größen bringen würde, die ganze Zeile ab: Die Verpackungseinheit entsteht nicht, und die Zeile scheitert mit duplicateFormatReference bzw. formatCapReached. Eine Größe, die schon innerhalb der Anfrage abgewiesen wird — dieselbe reference zweimal in einer Einheit (duplicateFormatReference) oder ein conflictingSizeRow —, scheitert allein, und die Verpackungseinheit wird ohne sie angelegt.
  • Trifft die Zeile eine bestehende Verpackungseinheit, wird jede Größe für sich aktualisiert; eine abgewiesene Größe scheitert allein. Größen, die die Anfrage nicht nennt, werden nie gelöscht. Ein hier geänderter Größenwert zählt als Überschreibung und verlangt overwrite: true.
  • Dieselbe Gruppierung geht auch flach: mehrere Zeilen mit derselben internalRef, jede mit eigener sizeReference. So deklariert das CSV-Blatt seine Größen — eine Zelle kann kein Array tragen. Beide Schreibweisen dürfen sich in einer Anfrage mischen.
  • POST /api/v1/ppwr/formats schreibt Größen als eigener Endpunkt, für Aufrufer, die Größen bereits getrennt versenden (siehe unten).

Größen und Verknüpfungen: keine Bestätigung

POST /api/v1/ppwr/formats folgt der Spaltenregel von Verpackungseinheiten und Komponenten: Eine leere Zelle behält den gespeicherten Wert, und ein nicht-leerer Wert, der einen gespeicherten ändern würde, wird mit 409 { "error": "overwriteRequired", "confirmUpdateCount": … } abgelehnt — es wird nichts geschrieben —, bis Sie den Stapel mit overwrite: true erneut senden. Eine Zeile, deren reference die eigene internalRef der Verpackungseinheit ist, adressiert die primäre Größe — also GTIN und Maße der Einheit selbst.
  • POST /api/v1/ppwr/links legt eine Verknüpfung zwischen Verpackungseinheit und Komponente an. Eine bestehende Verknüpfung ändert er nie: Eine abweichende quantity oder ein massGrams ohne formatReference auf einer schon vorhandenen Verknüpfung wird ignoriert, und die Zeile kommt als skipped zurück. Ändern Sie diese Werte in der App.
  • Eine links-Zeile mit formatReference setzt die Masse der Komponente für diese Größe immer auf das massGrams der Zeile, auch wenn die Verknüpfung schon besteht (die Zeile kommt trotzdem als skipped zurück). Ein leeres massGrams behält dort die gespeicherte Masse dieser Größe.
  • POST /api/v1/ppwr/article-links legt eine Verknüpfung zwischen Verpackungseinheit und Artikel an; eine bestehende ist skipped.

Limits

Höchstens 1.000 Zeilen pro Anfrage (400). Nicht offensichtlich: Eine Einheiten-Anfrage trägt zusätzlich höchstens 1.000 sizes-Einträge über alle Zeilen — eine Zeile darf bis zu 49 Größen anfordern (eine Verpackungseinheit trägt höchstens 50, die primäre eingerechnet), das Zeilenlimit allein begrenzt den Schreibvorgang also nicht. Über einem der beiden Limits lautet die Antwort 400 mit Too many rows: max 1000 per request bzw. Too many sizes: max 1000 per request, und es wird nichts geschrieben. Ein Limit je Schlüssel gibt es nicht, es gilt aber das allgemeine Rate-Limit von 1000 Anfragen pro Minute je IP-Adresse.

Ergebnis

  • invalid enthält Zeilen, die das Zeilenschema abgelehnt hat, bevor etwas geschrieben wurde; index ist die Position der Zeile in der Anfrage (ab 0). failed enthält Zeilen, die die Engine abgelehnt hat.
  • Teil-Akzeptanz: Gültige Zeilen werden übernommen, auch wenn andere in invalid oder failed landen. Einzige Ausnahme ist die 409-Überschreibungssperre — die gilt für den ganzen Stapel.
  • Idempotenz: Eine identische Anfrage erneut zu senden ändert nichts; getroffene Zeilen kommen als skipped zurück. Ausnahme sind Einheiten-Zeilen ohne internalRef (siehe oben).
  • failed enthält auch neue Verpackungseinheiten, die abgelehnt wurden, weil das Verpackungseinheiten-Kontingent Ihres Tarifs erreicht ist, mit packagingUnitQuotaReached (ihre Größen ebenso). Eine abgelehnte Einheit legt nichts an, auch nicht ihren Lieferanten.
Was jedes Feld je Endpunkt enthält: Bei units steckt eine Größe, die zusammen mit einer neuen Verpackungseinheit angelegt wird, in deren imported; eine Größe, die an einer bestehenden Verpackungseinheit angelegt wird, erscheint in updated als <Einheit> / <Größe>.

Fehlerschlüssel

Die Werte in failed[] sind stabile, nicht lokalisierte Maschinenschlüssel — alles Unerwartete wird auf dbError reduziert. In invalid[] ist das meistens ebenfalls ein Schlüssel, aber ein Feld ohne eigene Meldung (etwa ein Text über 120 Zeichen oder ein Eintrag in sizes[]) fällt auf den englischen Standardtext des Validators zurück. Werten Sie deshalb auf bekannte Schlüssel aus und zeigen Sie den Rest unverändert an.
Jeder Schlüssel wird je Zeile gemeldet. Schema-Schlüssel stehen in invalid[]: Engine-Schlüssel stehen in failed[]:
Zwei Endpunkte auf dieselbe Verknüpfung verhalten sich unterschiedlich. Die Spalte articleNumbers der Einheiten-Zeile überspringt eine unbekannte Artikelnummer still (sie zählt nur in articleNumbersUnmatched), während POST /api/v1/ppwr/article-links dieselbe Nummer als unknownArticleNumber meldet. Analog legt componentCodes für einen unbekannten Code eine Platzhalter-Komponente an, während POST /api/v1/ppwr/links unknownComponentCode meldet. Bei einem mehrdeutigen Code scheitern dagegen beide mit ambiguousComponentMatch — die einzige Lösung ist, den Code eindeutig zu machen.

Stammdaten zurücklesen

Alles, was der Intake schreibt, lässt sich auch wieder lesen — mit packaging:read (ein Schlüssel mit packaging:write deckt das mit ab).
  • Der Weg vom Schlüssel zur ID. ?internalRef=U-1 bzw. ?code=K-1 löst den Abgleichschlüssel des Intakes auf die Zeile auf — so kommen Sie an die id, die die Detail-Endpunkte erwarten. Ein leerer Wert ist kein Filter, der nichts trifft, sondern gar kein Filter.
  • code trifft eine Menge, keine Zeile. Ein Komponenten-Code ist je Lieferant eindeutig: Derselbe Code kann einmal in Ihrem eigenen Katalog und einmal bei einem antwortenden Lieferanten liegen. ?supplierId=<uuid> grenzt darauf ein, ?supplierId=none auf Ihren eigenen Katalog. Ein anderer Wert trifft nichts, statt die Menge zu erweitern.
  • Paging ist begrenzt. Ohne limit kommen die ersten 100 Zeilen (limit max. 1000); blättern Sie mit offset, bis eine Seite kürzer ist. Die Sortierung ist stabil (Verpackungseinheiten nach Name, Komponenten natürlich nach Code, jeweils mit der id als Gleichstand-Kriterium), Seitengrenzen wiederholen oder überspringen also keine Zeile. Ein ungültiger Wert ist ein 400.
  • Gelöschtes bleibt draußen, und eine fremde, unbekannte oder syntaktisch falsche ID ist immer 404 — nie 403, das würde die Existenz einer fremden ID bestätigen.

Dokumente hochladen

Ein Dokument — Prüfbericht, Konformitätserklärung, technische Zeichnung — lässt sich per API an eine Verpackungseinheit oder eine Komponente hängen. Das ist das API-Gegenstück zu Nachweis hochladen in der App und läuft durch denselben Code: Das Dokument wird mit dem gesendeten Typ als genehmigt abgelegt, identische Bytes werden als Duplikat erkannt, und das Dokument wird von der KI gelesen, wenn Ihr Tarif das enthält. Benötigt packaging:write.

Warum zwei Schritte

Die Datei geht nicht durch die API, sondern direkt in den Speicher. Der Grund ist die Plattform: Anfragen sind auf rund 4,5 MB begrenzt, Dokumente dürfen bis 10 MB groß sein. Einen Multipart-Upload in einem Aufruf gibt es deshalb nicht. Der Ablauf ist:
  1. Upload-URL anfordern — mit Dateiname, MIME-Typ und Größe.
  2. Die Datei per PUT an diese URL senden, mit demselben Content-Type, innerhalb von 10 Minuten.
  3. Den zurückgegebenen s3Key als storageKey an POST /api/v1/ppwr/documents übergeben.
Angenommen werden dieselben Dateien wie im App-Dialog: PDF, JPEG, PNG, höchstens 10 MB. Geprüft wird beim Ausstellen der URL: Ein anderer MIME-Typ, eine fehlende fileSize oder eine fileSize über dem Limit ist ein 400, und es wird keine URL ausgestellt. fileSize muss die exakte Bytezahl der Datei sein (eine ganze Zahl größer als 0): Sie ist in die URL signiert, und einen PUT mit jeder anderen Länge lehnt der Speicher mit 403 ab.

Das Ziel: eine UUID, kein Schlüssel

target ist genau eines von { "packagingSpecId": "<id>" } (Verpackungseinheit) oder { "componentId": "<id>" } (Komponente), jeweils die UUID. Die Abgleichschlüssel des Intakes (internalRef, code) adressieren hier nichts — ein Komponenten-Code ist nicht eindeutig (siehe Stammdaten zurücklesen). Lösen Sie die ID vorher auf: GET /api/v1/ppwr/units?internalRef=U-1 bzw. GET /api/v1/ppwr/components?code=K-1.

Beispiel

<UNIT_ID> ist die id aus GET /api/v1/ppwr/units?internalRef=U-1, <S3_KEY> der s3Key aus Schritt 1. fileName ist in Schritt 3 ein Pflichtschlüssel; sein Wert darf null sein.

Dokumenttypen

type ist immer Pflicht. Ein Anhängen ohne type ist ein 400; ein Upload ohne Typ, den die KI erst einordnet, ist über die API nicht möglich. Ein Typ, der nur an die Verpackungseinheit gehört, wird an einer Komponente mit 404 { "error": "target" } abgelehnt — wie im App-Dialog, der ihn dort gar nicht anbietet. Was die KI aus welchem Typ liest, beschreibt die Seite Nachweise.

KI-Auswertung: extractionQueued und aiSkippedReason

Das Ablegen hängt nie an der KI. Ein 201 heißt: Das Dokument ist gespeichert und an der Verpackungseinheit bzw. Komponente sichtbar. Ob es zusätzlich ausgewertet wird, sagt die Antwort:
  • extractionQueued: true — die Auswertung ist eingereiht; Vorschläge erscheinen danach in der Prüfansicht der App.
  • extractionQueued: false mit aiSkippedReason — die Auswertung wurde nicht eingereiht: disabled (KI-Zusatzmodul nicht aktiv), quota bzw. spend (ein Monatslimit ist erreicht) oder enqueue (das Einreihen ist fehlgeschlagen — das Dokument ist gespeichert, stoßen Sie die Analyse in der App erneut an).

Duplikate und Wiederholungen

Liegt in Ihrem Unternehmen bereits ein aktives Dokument mit exakt denselben Bytes, wird nichts geschrieben:
mit Status 409. anchor nennt, woran das vorhandene Dokument hängt (kind ist unit oder component), oder ist null. Wollen Sie die Datei trotzdem ein zweites Mal anhängen (etwa an eine andere Verpackungseinheit), senden Sie denselben Body mit "allowDuplicate": true erneut — die hochgeladene Datei wird dafür vorgehalten, ein neuer Upload ist nicht nötig. Liegt die einzige Kopie mit diesen Bytes im Papierkorb, wird sie wiederhergestellt und an das neue Ziel gehängt: Sie erhalten 201 mit der ursprünglichen id dieses Dokuments, kein 409.
Nach einem erfolgreichen Anhängen ist der storageKey verbraucht. Die Datei wird aus dem Upload-Bereich übernommen und der Schlüssel gelöscht. Haben Sie nach einem Netzwerkfehler keine Antwort erhalten, beginnen Sie deshalb wieder bei Schritt 1 (neue URL, erneuter Upload) — derselbe storageKey ein zweites Mal ergibt 404 { "error": "upload_missing" }. War das erste Anhängen erfolgreich, antwortet Schritt 3 mit 409 und der id des vorhandenen Dokuments — ein zweites Exemplar entsteht nicht. Dasselbe gilt nach einem 500: Das Dokument kann bereits gespeichert sein.

Fehler

Nicht unterstützt

  • Dokumente an Mehrwegsystemen (Pool-Dokumente) — nur in der App.
  • Adressierung über internalRef oder code — nur über die UUID.
  • Upload in einem einzigen Aufruf (Multipart) — wegen der Größengrenze der Plattform, siehe Warum zwei Schritte.
  • Antworten von Lieferanten — die kommen über das Lieferantenportal, nicht über diesen Endpunkt.

Konformitätserklärungen lesen

Lesen Sie zurück, ob eine Verpackungseinheit eine Konformitätserklärung (DoC) hat, welche, und wo ihr PDF liegt. Gleiche Voraussetzungen wie bei den übrigen Lese-Endpunkten: packaging:read, Kundenarbeitsbereich, PPWR-Modul.

GET /api/v1/ppwr/units?include=doc

include=doc ist optional (es kostet einen zusätzlichen Lesezugriff pro Seite); ohne den Parameter bleiben die Zeilen unverändert. Jeder andere include-Wert ist ein 400. Jede Zeile erhält:
  • docState — derselbe Wert, den die Einheitentabelle der App zeigt:
  • activeDeclaration — die aktive Erklärung der Verpackungseinheit, oder null:
fields akzeptiert docState und activeDeclaration wie jeden anderen Schlüssel; wer eines davon nennt, bekommt include=doc automatisch — ein ausgewähltes Feld fehlt nie.

Erklärungen auflisten und abrufen

GET /api/v1/ppwr/declarations und GET /api/v1/ppwr/declarations/{id} liefern dieselben Felder wie activeDeclaration, plus specIds (alle Verpackungseinheiten, die die Erklärung je abgedeckt hat — eine Familien-DoC ist eine Zeile) und activeSpecIds (die Verpackungseinheiten, für die sie gerade die aktive DoC ist). Eine Familien-DoC bleibt issued, wenn eine neuere DoC einige ihrer Einheiten übernimmt; diese Einheiten fallen dann aus ihren activeSpecIds, bleiben aber in specIds. Eine Familien-DoC trägt supersedesId: null. Nie den signierten Snapshot oder die Datei selbst. pdfUrl ist null bei einem Entwurf und bei einer gesammelten Erklärung, deren hochgeladene Datei nicht mehr vorliegt (verified ist dann ebenfalls false). supersededAt ist der Zeitpunkt, zu dem eine neuere Erklärung diese für ihre letzte Verpackungseinheit abgelöst hat; null, solange status nicht superseded ist. Filter, alle optional, jeder höchstens einmal (ein leerer Wert ist kein Filter, ein fehlerhafter ein 400): limit / offset wie bei den anderen Listen (Standard 100, max. 1000). Sortiert nach issuedAt, neueste zuerst, Entwürfe zuletzt. changedSince trifft eine Erklärung, die zu oder nach dem Zeitpunkt ausgestellt, zurückgezogen oder erneut geprüft wurde (unabhängig vom Ergebnis der Prüfung), oder eine gesammelte, deren hochgeladene Datei abgelaufen ist (sie liest dann verified: false), sowie eine, die in dieser Zeit eine Verpackungseinheit an eine neuere Erklärung verloren hat — eine abgelöste Erklärung ebenso wie eine Familien-DoC, die mit weniger activeSpecIds issued bleibt. Entwürfe treffen nie. 404 auf /declarations/{id} für eine unbekannte, fehlerhafte, gelöschte oder die eines anderen Unternehmens.

PDF herunterladen

GET /api/v1/ppwr/declarations/{id}/pdf liefert die Datei hinter pdfUrl.
  • Eine erzeugte DoC wird aus dem bei der Unterzeichnung eingefrorenen Snapshot gerendert, nach erneuter Prüfung seines Hashs. lang wählt die Sprache: genau einer der Werte de en fr es it nl bg cs da el et fi hr hu lt lv pl pt ro sk sl sv, ohne Rücksicht auf Groß-/Kleinschreibung (EN = en). Jeder andere Wert — ga, ein Tippfehler — ist ein 400, der die unterstützten Werte nennt. Ohne lang (oder mit leerem Wert) kommt Deutsch.
  • lang=all liefert ein ZIP mit einem PDF je Sprache, die die Märkte der Verpackungseinheit verlangen (Art. 39 Abs. 2), ohne Irisch und Maltesisch. Bei einer Familien-DoC kommen die Sprachen von einem aktiven Mitglied (dem mit der kleinsten ID). Eine zurückgezogene oder abgelöste DoC hat kein aktives Mitglied; ihr ZIP enthält nur das deutsche PDF.
  • Eine gesammelte DoC ist die hochgeladene Datei des Herstellers, unverändert, als PDF, PNG oder JPEG mit passendem Content-Type (lang gilt dort nicht). Wurde diese Datei gelöscht, lautet die Antwort 404, obwohl pdfUrl gesetzt ist.

ERP-Rezept — die DoC in den Artikelstamm zurückschreiben

  • Fragen Sie GET /api/v1/ppwr/declarations?changedSince=<letzter Lauf> ab (ohne status-Filter).
  • Speichern Sie bei jeder issued-Zeile mit verified: true deren referenceNumber und die Datei aus pdfUrl an jeder Verpackungseinheit in activeSpecIds und überschreiben Sie, was diese Einheit bisher hatte. So wird auch eine abgelöste DoC ersetzt: Ihre Nachfolgerin taucht in der Abfrage auf, die abgelöste ebenfalls (übergehen Sie sie). Verwenden Sie hier nicht specIds — dort stehen auch Einheiten, die eine neuere DoC übernommen hat.
  • Eine Zeile mit verified: false liegt nicht vor (eine gesammelte DoC, noch nicht verifiziert, oder ihre Datei ist abgelaufen): Entfernen Sie, was Sie zu dieser Erklärung gespeichert haben.
  • Bei withdrawn entfernen Sie, was Sie zu dieser Erklärung gespeichert haben, an jeder Verpackungseinheit in specIds.
  • Abgleichen Sie ab und zu mit GET /api/v1/ppwr/units?include=doc: issued heißt, eine verifizierte DoC liegt vor und ist aktuell; outdated und in_review brauchen Arbeit in der App; ready, blocked und collect haben noch keine DoC; not_applicable braucht keine.

Mengen-Feed & Tonnage

Mengen je SKU, Markt und Jahr

Der wiederkehrende Feed: ein Auszug aus Vertrieb/Fakturierung, eine Zeile je SKU × Markt × Jahr.
Die Antwort ist 202 mit { "jobId": …, "status": "queued", "statusUrl": … } und einem Location-Header. statusUrl und Location zeigen auf /api/import-jobs/{jobId}, das ist derselbe Endpunkt wie /api/v1/import-jobs/{jobId}. Fragen Sie ihn ab (benötigt packaging:read), bis status final ist — siehe Stapelimport (asynchron). Der ganze Stapel wird geprüft, bevor der Job angelegt wird, und eine fehlerhafte Zeile weist den ganzen Stapel ab — niemals ein Job, der Minuten später scheitert:
  • Jede quantity ist die absolute Gesamtmenge für diese SKU, diesen Markt und dieses Jahr und ersetzt den gespeicherten Wert — sie wird nie dazuaddiert. Senden Sie niemals Zuwächse, Retouren oder Gutschriften als eigene Zeilen: Eine Gutschrift-Zeile würde die Jahresmenge durch den Gutschriftbetrag ersetzen. Verrechnen Sie sie in der Gesamtmenge und senden Sie die korrigierte Gesamtmenge; eine Periode erneut zu senden ist der normale Weg, sie zu korrigieren. Eine quantity muss ≥ 0 sein.
  • Eine unbekannte articleNumber ist ein Zeilenfehler (unknownArticleNumber), niemals ein erfundener Artikel; ein innerhalb eines Stapels wiederholter Schlüssel wird beim ersten Vorkommen übernommen, die späteren Zeilen melden duplicateInFile.
  • Ein Artikel, der existiert, aber mit keiner Verpackungseinheit verknüpft ist, wird angenommen und in imported/updated gezählt, landet aber bei keiner Verpackungseinheit und in keinem Bericht. Nichts im Ergebnis weist darauf hin — prüfen Sie die SKU-Verknüpfungen, bevor Sie den Summen trauen.
  • replaceScope: "period-market" erklärt den Stapel zum vollständigen Stand jeder (periodYear, market)-Kombination, die er enthält: Gespeicherte Zeilen genau dieser Kombinationen, die der Stapel nicht enthält, werden gelöscht. Es zählen nur Kombinationen aus Zeilen, deren Artikelnummer gefunden wurde. Eine Kombination, die der Stapel gar nicht enthält, bleibt unberührt — ein Markt, in dem Sie nicht mehr verkaufen, behält seine alten Zeilen; um ihn auf null zu setzen, senden Sie seine SKUs mit quantity: 0. Ohne replaceScope darf ein Stapel SKUs weglassen, die sich nicht geändert haben. Senden Sie replaceScope nur als "period-market", sonst lassen Sie es weg.
  • Mengen je Verpackungseinheit werden abgeleitet, nicht gesendet. Die in Verkehr gebrachte Menge einer Verpackungseinheit ist die Summe der Mengen ihrer verknüpften SKUs. Eine Verpackungseinheit, die alle verknüpften Mengen verliert, zählt keinen Feed-Wert mehr.
Das result des Jobs enthält { imported, updated, deleted, failed[], derived, cleared, conflicts[], multiLinkedArticleNrs[] }. Für die Perioden und Märkte, die der Stapel berührt, ist derived die Zahl der Verpackungseinheiten, deren gezählte Werte nach dem Stapel aus dem Feed stammen, und cleared die Zahl derer, die vor dem Stapel Feed-Werte zählten und danach keine mehr. conflicts listet die Verpackungseinheiten, deren in der App eingetippte Werte Vorrang vor dem Feed haben (siehe unten).
multiLinkedArticleNrs ist ein Hinweis, kein Fehler. Es listet die Artikelnummern, deren Menge in mehr als eine Verpackungseinheit fließt — Artikel und Verpackungseinheit stehen absichtlich in einer n:m-Beziehung (z. B. Verkaufs- und Transportverpackung), also landet dieselbe Menge bei jeder verknüpften Verpackungseinheit. Prüfen Sie diese Artikel auf Doppelzählung. Blockierend ist der Hinweis nie.
Ein in der App eingetippter Wert wird nie überschrieben — weder vom Mengen-Feed noch von der Tonnage. Betroffene Verpackungseinheiten kommen im Job-Ergebnis unter conflicts mit manualOverridePresent zurück, betroffene Materialarten in skipped der Tonnage-Antwort; die übrigen Zeilen werden trotzdem geschrieben. Ein Bestätigen-und-Wiederholen gibt es nicht — eine geplante Übertragung könnte darauf nicht antworten. Ein Wert, der in der App aus Bestelldaten übernommen wurde, ist nicht geschützt: Der Feed überschreibt ihn.Der Ausweg unterscheidet sich: Eine Verpackungseinheit geben Sie an den Feed zurück, indem Sie alle ihre eingetippten Werte in der App leeren (nur das Leeren — eine neue Zahl zu speichern hält die Einheit manuell); der Wert des Feeds gilt dann sofort. Eine Tonnage-Zeile kann nicht leer sein, weil tonnageKg ein Pflichtwert ist; löschen Sie den manuellen Eintrag in der App, dann legt die nächste Übertragung ihn als ERP-eigene Zeile neu an.
Eine Verpackungseinheit zählt entweder ihren Wert auf Einheiten-Ebene oder ihre Werte je Größe, nie beides. Der Wert des Feeds wird je Größe berechnet, wenn jede verknüpfte SKU einer Verpackungseinheit mit mehreren Größen eine Größe nennt, sonst auf Einheiten-Ebene. Verknüpfen Sie jede SKU einer Verpackungseinheit, deren Größen unterschiedlich wiegen, mit ihrer Größe.
Feed-Werte werden bei jedem Lesen berechnet; Änderungen an Verknüpfungen und SKUs wirken ohne erneute Übertragung.

Tonnage je Materialart

Der Aggregat-Modus in Kilogramm, für Inverkehrbringer, deren Verpackungszusammensetzung nicht Einheit für Einheit modelliert ist — und zugleich der Weg, mehrdeutige Materialfamilien zu deklarieren (siehe ambiguous_material weiter unten). Nur LUCID (Deutschland). Zeilen sind { periodYear, market, zsvrCategory, tonnageKg, note? }, als bloßes Array oder in rows. zsvrCategory ist eine der acht VerpackG-Materialarten, gesendet als JSON-String ("50000", nicht 50000): 10000 Glas, 20000 Papier/Pappe/Karton, 30000 Eisenmetalle, 40000 Aluminium, 50000 Kunststoffe, 60000 Getränkekartonverbunde, 70000 sonstige Verbunde, 80000 sonstige Materialien. tonnageKg ist eine JSON-Zahl — anders als bei den Stammdaten-Zeilen wird hier kein Zahlen-String umgewandelt. market nimmt zwei beliebige Buchstaben an und speichert sie, gelesen wird aber nur DE. Die Antwort ist 200 mit { written, skipped, failed, invalid }:
Einträge in failed und invalid sind { index, identifier, error }; identifier ist die zsvrCategory der Zeile, wenn sie als String gesendet wurde (sonst leer), und ein Fehler in invalid ist der englische Text des Validators. Ein leerer Stapel ist 200 mit leeren Arrays; mehr als 1.000 Zeilen sind 400 (Too many rows: max 1000 per request). Ein im Stapel wiederholter Schlüssel: Die letzte Zeile gewinnt, und beide Zeilen stehen in written.
Eine Tonnage-Zeile ersetzt die aus SKUs abgeleitete Masse für ihre Materialart, Periode und ihren Markt — die beiden werden nie addiert.
DELETE /api/v1/ppwr/tonnage?periodYear=2026&market=DE&zsvrCategory=50000 löscht eine Tonnage-Zeile; danach zählt wieder die aus SKUs abgeleitete Masse dieser Materialart. Eine in der App eingetragene Zeile wird nicht gelöscht: 409 mit code: "manualOverridePresent" — löschen Sie sie in der App. Ein unbekannter Schlüssel ist 404, auch bei einer Wiederholung nach einem Erfolg. Tonnage ist zugleich der Ausweichmodus: Sind die Daten zur Verpackungszusammensetzung (noch) zu dünn für eine Stücklisten-Modellierung, überspringen Sie die Schritte 1–3 des Integrationsrezepts und senden nur Kategorie-Summen. Um eine Materialart später auf SKU-basierte Werte umzustellen, löschen Sie ihre Tonnage-Zeilen (in der App oder mit DELETE /api/v1/ppwr/tonnage); bis dahin wirkt der Mengen-Feed auf diese Materialart nicht.

Mengenmeldung-Berichte

Register und Märkte

Jedes format gehört zu genau einem Register und einem Markt — senden Sie den passenden market. Ein abweichender Markt (z. B. verpact_nl mit DE) wird mit 400 abgelehnt. Die Nicht-LUCID-Register kennen nur Portalformulare. Ihre Datei ist eine deterministische Übertragungshilfe — die Werte je Kategorie in den Kategorien, Maßeinheiten und der Reihenfolge des jeweiligen Portals —, die ein Mensch dort eintippt. Mehr kann eine Datei dort nicht automatisieren.

Bericht erzeugen

  • reportType ist für lucid_de Pflicht. systemOperatorId ist für lucid_de Pflicht, außer in der App ist eine gültige SystemOperatorID hinterlegt: Fehlt sie dann in der Anfrage, gilt die hinterlegte, und ein gesendeter Wert hat Vorrang. Der 201 nennt die verwendete systemOperatorId. Die Übertragungshilfe-Register verwenden sie nicht (diese erzeugen immer den Jahresbericht); eine fehlerhafte systemOperatorId ist dort trotzdem ein 400.
  • reportType ist die LUCID-Meldeart: HPM1 (Planmengenmeldung), HMM1 (unterjährige Mengenmeldung), HJM1 (Jahresabschlussmengenmeldung), HNM1 (Ergänzungsmengenmeldung), HAM1 (Abzugsmengenmeldung).
  • systemOperatorId ist die LUCID-Nummer Ihres dualen Systems: zwei Buchstaben und 13 Ziffern (Kleinschreibung wird angenommen und hochgesetzt).
  • market ist ISO-Alpha-2 (Groß-/Kleinschreibung egal), periodYear das Kalenderjahr (2000–2100).
201 — { "id": "…", "version": 2 } (bei lucid_de zusätzlich mit systemOperatorId): Der Bericht wurde erzeugt und eingefroren. Nach einer Datenänderung erneut zu erzeugen, ist der Normalfall: Der neue Bericht löst den vorherigen derselben Meldeart ab (version zählt hoch, die alte Zeile bleibt mit status: "superseded" gelistet).
Der Bereich ist periodYear, market, format und reportType. Eine LUCID-Erzeugung löst nur den vorherigen Bericht derselben Meldeart für dieses Jahr ab: Ein HJM1 für 2026 lässt ein früheres HMM1 oder HPM1 für 2026 unverändert, und version zählt je Meldeart. superseded heißt „nicht die neueste Erzeugung”, nicht „bei LUCID zurückgezogen”.
409 — { "error": "reportAlreadyGenerated" }: Zwei Erzeugungen für denselben Bereich liefen gleichzeitig, eine hat gewonnen. Liste erneut abrufen und bei Bedarf wiederholen. 422 — die Daten sind noch nicht meldefähig; es wurde nichts geschrieben:
{ "error": "nothing_to_declare" } (mit leerem blockingErrors) bedeutet, dass es für diese Periode und diesen Markt keine meldefähigen Zahlen gibt — bei LUCID weder abgeleitete Mengen noch Tonnage-Werte, bei den Übertragungshilfe-Registern keine abgeleiteten Mengen (Tonnage-Werte speisen nur LUCID). Ist jede in Verkehr gebrachte Verpackungseinheit eine Mehrwegverpackung, werden auch die Tonnage-Werte ignoriert, und die Antwort ist ebenfalls nothing_to_declare.

Berichte auflisten

Optionale Query-Parameter: periodYear, market, status (generated oder superseded) sowie limit und offset — das Paging greift nach den Filtern. Ein ungültiger Wert eines bekannten Parameters ist ein 400; unbekannte Parameternamen werden ignoriert. Die Antwort ist ein JSON-Array, neueste zuerst, und enthält ausschließlich Metadaten — nie die eingefrorene Berechnung:
Der herunterzuladende Bericht ist der mit status: "generated" — genau einer je Kombination aus periodYear, market und format. Übertragungshilfe-Register melden immer reportType: "ANNUAL", einen leeren systemOperatorId und xsdVersion: "worksheet-1".

Datei herunterladen

GET /api/v1/ppwr/mengenmeldung/{reportId}/xml liefert 200 mit der Datei als Anhang: für lucid_de das LUCID-Datenmeldungs-XML (application/xml; charset=utf-8), für die übrigen Register die JSON-Übertragungshilfe (application/json; charset=utf-8). Auch ein superseded-Bericht lässt sich herunterladen. Die Datei wird aus der eingefrorenen Momentaufnahme neu erzeugt, nach erneuter Prüfung des SHA-256 der Momentaufnahme.
  • 403 — { "error": "…", "code": "EXPORTS_BLOCKED" }, wenn Exporte für Ihren Arbeitsbereich gesperrt sind, oder das PPWR-Modul ist nicht aktiv.
  • 404 — unbekannte oder fehlerhafte Berichts-ID, einschließlich jeder ID eines anderen Unternehmens.
  • 409 — { "error": "Report integrity check failed" }: Die Momentaufnahme passt nicht mehr zu ihrem Hash; die Datei wird verweigert statt ausgeliefert.

Integrationsrezept ERP zu LUCID

Schritt 1 ist ein Datenprojekt, alles danach ist Routine. Die Verpackungsstammdaten auf Meldequalität zu bringen, ist echte Arbeit — einmalig, und erneut nur bei Sortimentsänderungen. Der wiederkehrende Teil (Mengen rein, Bericht raus) ist ein geplanter Job und zwei HTTP-Aufrufe.
1

Einmalig: Verpackungsstammdaten

Legen Sie zuerst einen API-Schlüssel an; einer mit packaging:write deckt alles ab. Das Register deklariert Masse je Materialart, jede verkaufte SKU muss sich also in Komponenten mit monomateriellen Gewichten auflösen:
  1. Komponenten (POST /api/v1/ppwr/components) mit materials und massGrams.
  2. Verpackungseinheiten (POST /api/v1/ppwr/units) — die verkaufsfähige Verpackung, verknüpft mit Komponenten über POST /api/v1/ppwr/links. Eine Verpackungseinheit in mehreren Größen deklariert diese auf der Zeile selbst (sizes[]); ihre Komponenten-Massen je Größe kommen aus Stücklisten-Zeilen mit formatReference.
  3. SKU-Verknüpfungen — articleNumbers auf der Einheiten-Zeile oder POST /api/v1/ppwr/article-links: welcher Artikel in welcher Verpackungseinheit ausgeliefert wird. Das ist es, was später Verkaufszahlen in Verpackungsmassen übersetzt.
Für die Übertragungshilfe-Register bestätigen Sie in der App die Kategorie-Zuordnungen, wo eine Materialart mehrdeutig ist (einmalig je Register). Für LUCID gibt es nichts zuzuordnen — mehrdeutige Familien werden als Zahlen je Periode deklariert.
2

Wiederkehrend: Mengen je SKU, Markt und Jahr

Ein geplanter Export aus Vertrieb/Fakturierung an POST /api/v1/ppwr/quantities, danach GET /api/v1/import-jobs/{jobId} abfragen. Die Frequenz bestimmen Sie. Jede Zeile trägt die absolute Gesamtmenge für ihre SKU, ihren Markt und ihr Jahr — verrechnen Sie Retouren und Gutschriften in dieser Gesamtmenge; wer für eine Periode und einen Markt den vollständigen Stand exportiert, sendet replaceScope: "period-market".
3

Bericht erzeugen

POST /api/v1/ppwr/mengenmeldung/reports für Periode, Markt und Register, bei LUCID mit Ihrer systemOperatorId und dem reportType. Ein 422 nennt Ihnen Eintrag für Eintrag, welche Daten noch nicht meldefähig sind — beheben, erneut senden, erneut erzeugen. Ein 201 ist ein eingefrorener, versionierter Bericht.
4

Datei abholen

GET /api/v1/ppwr/mengenmeldung/reports?periodYear=2026&market=DE&status=generated, dann GET /api/v1/ppwr/mengenmeldung/{id}/xml. Hash-geprüft; das LUCID-XML entspricht dem veröffentlichten ZSVR-XSD.
5

Bei LUCID hochladen — der eine Klick, der nicht automatisiert wird

Bei LUCID anmelden, Datenmeldung öffnen, XML hochladen.
Dieser Klick kann nicht automatisiert werden — von uns so wenig wie von irgendjemandem: Die ZSVR stellt keine Einreichungs-API bereit, und VerpackDG §5(1) S.2 macht Registrierung und Datenmeldung zu höchstpersönlichen Pflichten des Inverkehrbringers, die nicht an Dritte delegiert werden können. Die Pipeline ist darauf ausgelegt, dass dies der einzige manuelle Schritt bleibt — und dass er trivial ist: Die Datei ist fertig und einen Browser-Upload von „erledigt” entfernt.