API v1.2.0
A REST API over the packaging data you already keep in PPWR Connect: SKUs, their component graph, the recyclability grade we compute for each, and your Declarations of Conformity. Built for ERP middleware — SAP, Exact and the rest — not for a browser.
Everything on this page is public, and stays public: the endpoint reference, the field tables and the OpenAPI document. You can size the integration, build against the spec and generate a client before you talk to anyone.
What we issue on request is the credential. API keys are company-scoped and provisioned by us for customers on a paid plan; there is no self-service key page yet, so the first one comes from a conversation. Tell us which system is calling and whether it reads or writes, and we set it up.
One header authenticates every call, and that is the whole of it if you want it to be — the OAuth flow below is an option, not a step. Keys are company-scoped: a key can only ever read and write the data of the company it was issued to, and there is no parameter that widens that. Ask your PPWR Connect contact for one — we store only its hash, so a lost key is replaced, never recovered.
curl -s https://ppwrconnect.com/api/v1/skus?limit=5 \ -H "X-API-Key: ppwr_…"
SAP and other middleware are usually built around client credentials rather than a static header. Exchange your key for a bearer token good for 15 minutes: your client_id is the id of your API key, your client_secret is the key itself. Both HTTP Basic and body parameters work, and the body may be form-encoded or JSON.
curl -s https://ppwrconnect.com/api/v1/oauth/token \
-u "<client_id>:ppwr_…" \
-d grant_type=client_credentials
# → { "access_token": "eyJ…", "token_type": "Bearer",
# "expires_in": 900, "scope": "read write" }
curl -s https://ppwrconnect.com/api/v1/skus?limit=5 \
-H "Authorization: Bearer eyJ…"Scopes are read and write. Omit scope to get both, or ask for scope=read to hand a reporting job a token that cannot change anything — a token can only ever do less than the key behind it, never more. There is no refresh token: your secret needs no user present, so re-requesting is the refresh.
The key is not deprecated by any of this. Both credentials are supported permanently, and if a request carries both, the key is the one used. Revoking the key invalidates outstanding tokens immediately — every request re-checks the token against the live key rather than trusting what was true at issue time.
The full contract is machine-readable at /api/v1/openapi.json — an OpenAPI 3.1 document you can point openapi-generator, Kiota or NSwag at to generate a typed client. It is generated from the same schemas the handlers run on, so it cannot describe an endpoint we do not serve.
Operations marked beta are published so your architects can review the contract while we build them. They are not served yet — calling one today returns 404. Everything marked stable is live.
| Method | Path | Does | Status |
|---|---|---|---|
| POST | /api/v1/oauth/token | Exchange client credentials for a bearer token | stable |
| GET | /api/v1/skus | List your SKUs | stable |
| POST | /api/v1/skus | Create or update one SKU | stable |
| GET | /api/v1/skus/{id} | Get one SKU with components and compliance | stable |
| PUT | /api/v1/skus/{id} | Update one SKU by id | stable |
| POST | /api/v1/skus/batch | Create or update many SKUs in one call | stable |
| POST | /api/v1/imports | Submit a CSV or XLSX file for import | stable |
| GET | /api/v1/imports/{id} | Import status and line-by-line error report | stable |
| GET | /api/v1/docs | List your Declarations of Conformity | stable |
POST /api/v1/oauth/token
RFC 6749 §4.4. client_id is your API key id, client_secret is the ppwr_… key itself; send them as HTTP Basic credentials or as body parameters. The token is valid for 900 seconds and carries the scopes you asked for — omit scope to get every scope your client holds. There is no refresh token: a client credentials grant needs no user present, so re-requesting is the refresh. Revoking the API key invalidates outstanding tokens at once. Both application/x-www-form-urlencoded (what the RFC mandates) and application/json bodies are accepted. Unknown parameters are ignored rather than refused, so a generic OAuth library that sends extras works.
Request body: TokenRequest as JSON. Unknown fields are rejected rather than ignored, so a typo in a field name is an error you see at once instead of data that silently never arrived.
| Status | Body | When |
|---|---|---|
| 200 | TokenResponse | A bearer token. Never cached — the response is no-store. |
| 400 | TokenError | Missing parameter (invalid_request), a grant type we do not serve (unsupported_grant_type), or a scope this client does not hold (invalid_scope). |
| 401 | TokenError | Unknown client, wrong secret, or revoked credential (invalid_client). The three answer identically on purpose. |
| 429 | ApiError | Over 30 token requests a minute, per client and per IP (rate_limited). A healthy integration needs one per 15 minutes. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
GET /api/v1/skus
Newest first. Use limit/offset to page through the portfolio.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| limit | query | No | Page size. Default 50, maximum 200; out-of-range values are clamped. |
| offset | query | No | Row offset. Default 0; negative values are clamped to 0. |
| Status | Body | When |
|---|---|---|
| 200 | SkuListResponse | A page of SKUs. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
POST /api/v1/skus
Upserts by sku_code within your company: an unknown code creates, a known one updates. The recyclability grade is recomputed on every write, from the component graph when there is one. Your plan's SKU limit applies exactly as it does in the app — the API is not a way around it, though re-sending SKUs you already hold costs no quota. A field you omit is left exactly as it is; a field you send as null is cleared. So an ERP exporting ten columns never wipes the twenty a colleague filled in the app. components is the one array field: sending it replaces the SKU’s whole component graph, omitting it keeps the existing one, and [] clears it.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| dry_run | query | No | Validate and grade the payload, report what would happen, write nothing. The way to test an ERP export end to end before trusting it. |
| Idempotency-Key | header | No | Repeat the same key to make a retry safe: an identical request replayed within 24 hours returns the first response instead of writing twice. Any string up to 255 characters — a UUID per logical operation works well. |
Request body: SkuInput as JSON. Unknown fields are rejected rather than ignored, so a typo in a field name is an error you see at once instead of data that silently never arrived.
| Status | Body | When |
|---|---|---|
| 200 | SkuWriteResult | The SKU existed and was updated. |
| 201 | SkuWriteResult | The SKU was created. |
| 400 | ApiError | The body failed validation (invalid_body); details says which fields. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 403 | ApiError | The write would exceed your plan's SKU limit (plan_limit_reached). |
| 409 | ApiError | The Idempotency-Key was already used for a different payload (idempotency_key_reuse). Use a fresh key per logical operation. |
| 413 | ApiError | The body is over 2 MiB (payload_too_large). Split the batch. |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
GET /api/v1/skus/{id}
Adds the component graph and the compliance block (Annex II grade, DoC status, Annex V format-ban flags) to the list shape. Another company's id returns 404 — the same answer as an id that does not exist.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| id | path | Yes | PPWR Connect SKU id (not your sku_code). |
| Status | Body | When |
|---|---|---|
| 200 | SkuDetailResponse | The SKU. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 404 | ApiError | No such SKU in your company (not_found). |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
PUT /api/v1/skus/{id}
Same body as POST /api/v1/skus, addressed by PPWR Connect id instead of sku_code. The body's sku_code must match the row: renaming a code through PUT would quietly create a second record rather than rename anything. A field you omit is left exactly as it is; a field you send as null is cleared. So an ERP exporting ten columns never wipes the twenty a colleague filled in the app. components is the one array field: sending it replaces the SKU’s whole component graph, omitting it keeps the existing one, and [] clears it.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| id | path | Yes | PPWR Connect SKU id (not your sku_code). |
| dry_run | query | No | Validate and grade the payload, report what would happen, write nothing. The way to test an ERP export end to end before trusting it. |
| Idempotency-Key | header | No | Repeat the same key to make a retry safe: an identical request replayed within 24 hours returns the first response instead of writing twice. Any string up to 255 characters — a UUID per logical operation works well. |
Request body: SkuInput as JSON. Unknown fields are rejected rather than ignored, so a typo in a field name is an error you see at once instead of data that silently never arrived.
| Status | Body | When |
|---|---|---|
| 200 | SkuWriteResult | The updated SKU. |
| 400 | ApiError | The body failed validation, or its sku_code does not match this row (invalid_body). |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 404 | ApiError | No such SKU in your company (not_found). |
| 409 | ApiError | The Idempotency-Key was already used for a different payload (idempotency_key_reuse). Use a fresh key per logical operation. |
| 413 | ApiError | The body is over 2 MiB (payload_too_large). Split the batch. |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
POST /api/v1/skus/batch
Up to 1 000 SKUs per call, same upsert semantics as the single write. Rows are independent: a rejected row is reported in results with its reasons and does not roll back the others — a 500-reference push lands the 499 good ones and tells you what to fix. Set dry_run: true to validate an export end to end without writing anything. A field you omit is left exactly as it is; a field you send as null is cleared. So an ERP exporting ten columns never wipes the twenty a colleague filled in the app. components is the one array field: sending it replaces the SKU’s whole component graph, omitting it keeps the existing one, and [] clears it.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| Idempotency-Key | header | No | Repeat the same key to make a retry safe: an identical request replayed within 24 hours returns the first response instead of writing twice. Any string up to 255 characters — a UUID per logical operation works well. |
Request body: SkuBatchInput as JSON. Unknown fields are rejected rather than ignored, so a typo in a field name is an error you see at once instead of data that silently never arrived.
| Status | Body | When |
|---|---|---|
| 200 | SkuBatchResponse | Per-row outcome of the batch. |
| 400 | ApiError | The body failed validation (invalid_body); details says which fields. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 403 | ApiError | The batch would exceed your plan's SKU limit (plan_limit_reached). |
| 409 | ApiError | The Idempotency-Key was already used for a different payload (idempotency_key_reuse). Use a fresh key per logical operation. |
| 413 | ApiError | The body is over 2 MiB (payload_too_large). Split the batch. |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
POST /api/v1/imports
The batch door: hand us the same CSV/XLSX the app accepts and poll GET /api/v1/imports/{id} for a line-by-line verdict. Answers 202 with an id — the writing happens after the response, because ingestion costs a round trip per SKU and a few thousand references do not fit in one request. What is decided BEFORE the 202, so an accepted import never fails for a reason you could have been told up front: the file parses, a table was found, sku_code and name map to columns, and the whole file fits your plan. source in the response says which sheet, which header row and which column we read each field from — check it first when a value did not land where you expected. Send the file as a file part of a multipart body, or as the raw bytes with ?filename= naming it. Up to 10 MiB and 50 000 rows. An optional second sheet named components replaces the component graph of the SKUs it names, and only those. A field you omit is left exactly as it is; a field you send as null is cleared. So an ERP exporting ten columns never wipes the twenty a colleague filled in the app. components is the one array field: sending it replaces the SKU’s whole component graph, omitting it keeps the existing one, and [] clears it.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| filename | query | No | Names a raw-body upload. The extension is how we tell CSV from XLSX before reading the bytes. Ignored for multipart, which carries its own. |
| dry_run | query | No | Validate and grade the payload, report what would happen, write nothing. The way to test an ERP export end to end before trusting it. |
| Idempotency-Key | header | No | Repeat the same key to make a retry safe: an identical request replayed within 24 hours returns the first response instead of writing twice. Any string up to 255 characters — a UUID per logical operation works well. |
Request body: object as JSON. Unknown fields are rejected rather than ignored, so a typo in a field name is an error you see at once instead of data that silently never arrived.
| Status | Body | When |
|---|---|---|
| 202 | ImportAccepted | Accepted and queued. Poll poll_url; a few hundred rows are usually finished before your first poll. |
| 400 | ApiError | The file could not be read, no table was found, or no column maps to sku_code / name (invalid_body). details says which. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 403 | ApiError | The file would push you past your plan's SKU limit (plan_limit_reached). A dry_run is never refused on quota — it is the tool for finding out. |
| 409 | ApiError | The Idempotency-Key was already used for a different payload (idempotency_key_reuse). Use a fresh key per logical operation. |
| 413 | ApiError | The file is over 10 MiB (payload_too_large). Split it. |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
| 501 | ApiError | The import store is not provisioned on this environment yet (not_implemented). The synchronous write endpoints are unaffected. |
GET /api/v1/imports/{id}
Poll until status is completed or failed. completed means every row has an outcome — including the rejected ones, which is what errors[] is for: each carries the line number of your own file so you can fix the export rather than guess. The report is paginated with limit / offset. Polling also nudges an import that stalled, so there is no state in which asking for the status leaves it stuck.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| id | path | Yes | The id returned by POST /api/v1/imports. |
| limit | query | No | Page size. Default 50, maximum 200; out-of-range values are clamped. |
| offset | query | No | Row offset. Default 0; negative values are clamped to 0. |
| Status | Body | When |
|---|---|---|
| 200 | ImportStatusResponse | Status, counts and the rejected rows. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 404 | ApiError | No such import in your company (not_found) — another company's id answers the same way. |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
| 501 | ApiError | The import store is not provisioned on this environment yet (not_implemented). |
GET /api/v1/docs
Metadata only — reference, supplier, status and validity dates. v1 returns no file contents and no download URLs; the PDF export stays in the app.
| Parameter | In | Required | Meaning |
|---|---|---|---|
| limit | query | No | Page size. Default 50, maximum 200; out-of-range values are clamped. |
| offset | query | No | Row offset. Default 0; negative values are clamped to 0. |
| Status | Body | When |
|---|---|---|
| 200 | DocListResponse | A page of DoC metadata. |
| 401 | ApiError | Missing, unknown or revoked API key (missing_api_key / invalid_api_key). |
| 429 | ApiError | Rate limit exceeded (rate_limited). Honour the Retry-After header. |
| 500 | ApiError | Unexpected server error (internal_error). Quote the correlationId to support. |
Every error has the same body: a stable error code to branch on and a correlationId identifying that exact failure. Quote the correlation id in a support request and we can pull the server-side trace without asking you what time it happened.
{ "error": "invalid_api_key", "correlationId": "5f2c…" }| Code | HTTP | Meaning |
|---|---|---|
| missing_api_key | 401 | No X-API-Key header, or it does not start with ppwr_. |
| invalid_api_key | 401 | The key is unknown or has been revoked. The two cases answer identically on purpose — a revoked key looks like a key that never existed. |
| rate_limited | 429 | Over 120 requests in a minute. Wait Retry-After seconds and retry. |
| invalid_token | 401 | The Authorization: Bearer token is malformed, expired, or was signed for a key that has since been revoked. Ask /api/v1/oauth/token for a fresh one. |
| insufficient_scope | 403 | The token is valid but was issued without the scope this operation needs — a read token cannot write. Request scope=write (or omit scope) at the token endpoint. |
| invalid_request | 400 | Token endpoint only (RFC 6749 §5.2): a required parameter is missing or the request is otherwise malformed. |
| invalid_client | 401 | Token endpoint only (RFC 6749 §5.2): client_id / client_secret are unknown, do not belong together, or the client has been revoked. The three cases answer identically on purpose. |
| unsupported_grant_type | 400 | Token endpoint only (RFC 6749 §5.2): the only grant type v1 serves is client_credentials. |
| invalid_scope | 400 | Token endpoint only (RFC 6749 §5.2): a requested scope is unknown, or is not one this client holds. Valid scopes are read and write. |
| not_found | 404 | No such record in your company. Another company’s id answers the same way — the API never confirms that an id exists elsewhere. |
| invalid_body | 400 | The request body failed validation. details lists the offending fields. |
| payload_too_large | 413 | The request body is over 2 MiB. Split the batch — a single call that big cannot finish inside a function timeout, and half-written is worse than refused. |
| idempotency_key_reuse | 409 | This Idempotency-Key was already used for a different payload. Use a fresh key per logical operation — replaying the same one is only safe for the same call. |
| plan_limit_reached | 403 | The write would push your workspace past its plan limit. The API enforces exactly the limits the app does. |
| not_implemented | 501 | A documented operation that is not served yet (see x-status: beta in the spec). |
| internal_error | 500 | Our fault. Quote the correlationId and we can find the trace. |
120 requests per minute per key. Over that you get 429 with a Retry-Afterheader in seconds — honour it rather than retrying immediately. A one-off bulk migration can have a higher ceiling for a window; ask us before you start rather than after you are throttled. Tokens share their key’s allowance rather than adding to it, and the token endpoint has its own tighter limit of 30 requests a minute — a healthy integration needs one per 15 minutes.
v1 is additive-only. Fields get added; none is ever renamed or removed. Ignore fields you do not recognise instead of validating responses strictly, and your integration will keep working across our releases. A change that would break that promise ships as /api/v2, alongside v1, with notice.
Field tables below are generated from the specification — what you read here is what the endpoint returns.
SkuListItem
| Field | Type | Always present | Meaning |
|---|---|---|---|
| id | uuid | Yes | PPWR Connect identifier. Stable for the life of the SKU. |
| sku_code | string | Yes | Your own reference. Unique within your company — the upsert key. |
| name | string | Yes | Human-readable packaging name. |
| description | string · null | Yes | — |
| material | plastic · paper · glass · metal · composite · wood · textile · other · null | Yes | Dominant material of the packaging as a whole. |
| material_subtype | string · null | Yes | Free text, e.g. "PET mono-material", "corrugated board". |
| weight_grams | number · string · null | Yes | Total packaging weight in grams. |
| recyclability_grade | A · B · C · not_recyclable · not_assessed · null | Yes | PPWR Annex II design-for-recycling grade, computed by us. |
| recyclability_score | number · null | Yes | 0–100 score behind the grade. Same evaluator as the in-app report. |
| recycled_content_percent | number · string · null | Yes | Declared recycled content (Art. 7). |
| food_contact | boolean · null | Yes | — |
| product_category | string · null | Yes | — |
| markets | array of string · null | Yes | ISO 3166-1 alpha-2 codes of the markets this SKU is placed on. |
| doc_status | missing · draft · valid · expired · null | Yes | Declaration of Conformity coverage for this SKU. |
| packaging_level | string · null | Yes | primary · secondary · tertiary · … |
| packaging_format | string · null | Yes | — |
| gtin | string · null | Yes | GS1 barcode number, when declared. |
| ppwr_role | string · null | Yes | Your PPWR responsibility role for this SKU: manufacturer · importer · supplier. |
| created_at | string | Yes | — |
| updated_at | string | Yes | — |
SkuComponent
| Field | Type | Always present | Meaning |
|---|---|---|---|
| id | uuid | Yes | — |
| component_type | body · cap · closure · label · liner · seal · sleeve · ink · adhesive · coating · varnish · tie_layer | Yes | Part of the packaging this row represents. |
| position_in_sku | integer · null | Yes | Display/assembly order. |
| material_family | plastic · paper · glass · metal · wood · textile · composite · bio_based · other | Yes | PPWR Annex II material family. |
| polymer_or_grade | string · null | Yes | e.g. "PET", "HDPE", "kraft 120 g". |
| weight_g | number · string · null | Yes | — |
SkuCompliance
| Field | Type | Always present | Meaning |
|---|---|---|---|
| recyclability_grade | A · B · C · not_recyclable · not_assessed · null | Yes | — |
| recyclability_score | number · null | Yes | — |
| doc_status | missing · draft · valid · expired · null | Yes | — |
| annex_v_flags | array of AnnexVFlag | Yes | Empty when the SKU trips no Annex V format ban. |
DocListItem
| Field | Type | Always present | Meaning |
|---|---|---|---|
| id | uuid | Yes | — |
| sku_id | uuid · null | Yes | SKU this DoC covers, when scoped to one. |
| reference | string · null | Yes | Your document reference. |
| supplier_name | string · null | Yes | — |
| status | draft · pending · valid · expired · rejected · null | Yes | — |
| issued_on | string · null | Yes | ISO date. |
| expires_on | string · null | Yes | ISO date. |
| created_at | string | Yes | — |
| updated_at | string | Yes | — |
SkuInput
| Field | Type | Always present | Meaning |
|---|---|---|---|
| sku_code | string | Yes | Upsert key within your company. |
| name | string | Yes | — |
| description | string · null | No | — |
| material | plastic · paper · glass · metal · composite · wood · textile · other · null | No | — |
| material_subtype | string · null | No | — |
| weight_grams | number · null | No | — |
| recycled_content_percent | number · null | No | — |
| food_contact | boolean | No | — |
| pfas_intentional | boolean | No | Intentionally added PFAS. Combined with food_contact and markets this can refuse the write outright from 12 August 2026 (Art. 5 §5). |
| product_category | string · null | No | — |
| markets | array of string | No | ISO 3166-1 alpha-2 codes. |
| gtin | string · null | No | — |
| ppwr_role | manufacturer · importer · supplier | No | — |
| packaging_level | string · null | No | — |
| packaging_format | string · null | No | — |
| sorting_label_scheme | triman_fr · info_tri_fr · harmonised_eu · none · null | No | — |
| material_marking_applied | boolean | No | — |
| svhc_declared | boolean · null | No | — |
| bpa_present | boolean · null | No | — |
| heavy_metals_sum_ppm | number · null | No | — |
| components | array of SkuComponentInput | No | REPLACES this SKU’s whole component graph. Sending three components and later sending one leaves exactly one — components are never appended, because a graph that accumulated across writes would double the packaging weight and change the Annex II grade. Same semantics as re-uploading the XLSX: the file you download is the file you can re-upload. Omit the field to leave the existing graph untouched; send [] to clear it. |