This is the full developer documentation for EuroPlaybook
# Data model
> EuroPlaybook's data model as plain Markdown for people and LLMs - entities, fields, enums and units.
This page is written for machines as well as people: plain Markdown, no components. The same text is served raw at [`/data-model.md`](/data-model.md). The field tables are generated from the [OpenAPI document](/openapi.json), so they match the API.
## Conventions
[Section titled “Conventions”](#conventions)
* IDs: UUID strings.
* Weights: integer milligrams, field names end in `_mg`. 1 kg = 1000000 mg.
* Money: integer cents, field names end in `_cents`, plus `currency` (ISO 4217, e.g. `EUR`).
* Countries: ISO 3166-1 alpha-2 (`NL`, `BE`, `DE`, `FR`, `AT`).
* Dates: ISO 8601 (`2026-10-03`); timestamps with time zone.
* Validity: `valid_from` is the first day (inclusive), `valid_to` the end day (**exclusive**); `null` means open-ended.
* Packaging quantities: decimal strings (`"1"`, `"0.5"`), parts per sales unit.
* Codes from the knowledge pack (materials, layers, regimes, countries) are short text. Their lists come from `GET /reference/{materials|layers|regimes|countries}`.
* Tenancy: every record belongs to exactly one account. An API key belongs to one account.
* Errors: `{detail, code, params?}`; see [errors](/developers/errors/).
## Account and companies
[Section titled “Account and companies”](#account-and-companies)
`GET /account` returns the account of the key, the key’s name and prefix, and the scopes in force now.
Fields of `AccountOut`:
| Field | Type | Notes |
| -------------- | --------- | --------------------------------------------------------------------------------------- |
| `id` | uuid | The account (brand) this API key belongs to. |
| `name` | string | Account name. |
| `plan` | string | Subscription plan. |
| `token_name` | string | Name of the API key making this call. |
| `token_prefix` | string | Public prefix of the API key (`ep__…`). |
| `scopes` | string\[] | Scopes in force for this key now: its own scopes limited by its creator’s current role. |
A **legal entity** is a company inside the account. Obligations, periods and sales are per legal entity.
Fields of `LegalEntityOut`:
| Field | Type | Notes |
| --------------- | ----------------- | --------------------------------------------- |
| `id` | uuid | |
| `name` | string | Registered company name. |
| `legal_form` | string | Legal form. |
| `country` | string | Country of registration (ISO 3166-1 alpha-2). |
| `registry_type` | string | Company registry (kvk, kbo, …). |
| `registry_id` | string | Number in that registry. |
| `vat_number` | string | VAT number. |
| `is_default` | boolean | The company used when a sale names none. |
| `archived_at` | date-time \| null | Set when the company was archived. |
Team roles, low to high: `readonly` < `accountant` < `member` < `admin` < `owner`. Owners, admins and members can use every API scope; accountants and readers only the `:read` scopes. Keys: `ep__`, shown once, effective rights = scopes ∩ the creator’s current role.
## Products and groups
[Section titled “Products and groups”](#products-and-groups)
A product is one SKU. `confirmation.state` is `none`, `confirmed` or `changed` (confirmed before, packaging differs now).
Fields of `ProductOut`:
| Field | Type | Notes |
| --------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | uuid | |
| `sku` | string | Your stock keeping unit; unique per account. |
| `title` | string | Product name. |
| `brand_name` | string | Brand on the product. |
| `gtin` | string \| null | GTIN/EAN (8-14 digits, check digit validated). |
| `hs_code` | string | Customs tariff code. |
| `country_of_origin` | string | ISO 3166-1 alpha-2. |
| `net_weight_mg` | integer \| null | Net product weight in milligrams. |
| `status` | “active” \| “archived” | Archived products are kept for history. |
| `is_kit` | boolean | A bundle of other products (bill of materials). |
| `is_electrical` | boolean | Electrical/electronic equipment (WEEE). |
| `is_textile` | boolean | Textile product (textiles EPR). |
| `packaging_confirmed` | boolean | Gate 1: a person confirmed this product’s packaging. Changing packaging (also through a proposal) clears it until someone confirms again in the portal. |
| `confirmed_at` | date-time \| null | When the packaging was confirmed. |
| `confirmation` | ConfirmationOut | Gate 1 in detail (complements `packaging_confirmed`, which equals state confirmed when the packaging is unchanged). |
| `group_id` | uuid \| null | The product group whose packaging this product inherits (per layer), if any. |
| `archived_at` | date-time \| null | |
| `created_at` | date-time | |
| `updated_at` | date-time | |
Fields of `ConfirmationOut`:
| Field | Type | Notes |
| ------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state` | “none” \| “confirmed” \| “changed” | none = never confirmed; confirmed = the packaging is what a person confirmed; changed = it was confirmed, but the packaging (own or inherited) differs now. |
| `at` | date-time \| null | When the current confirmation was given. |
| `by` | string \| null | Who confirmed (display name). |
`PUT /products/by-sku/{sku}` creates or updates a product directly (`201` / `200`):
Fields of `ProductUpsertIn`:
| Field | Type | Notes |
| ------------------- | --------------- | --------------------------------------- |
| `title` | string | **Required.** Product name. |
| `brand_name` | string \| null | Brand on the product. |
| `gtin` | string \| null | GTIN/EAN; null clears it. |
| `hs_code` | string \| null | Customs tariff code. |
| `country_of_origin` | string \| null | ISO 3166-1 alpha-2. |
| `net_weight_mg` | integer \| null | Net weight in milligrams. |
| `is_electrical` | boolean \| null | Electrical/electronic equipment (WEEE). |
| `is_textile` | boolean \| null | Textile product. |
A **product group** holds variants that share packaging, e.g. one Shopify product with several variants. `GET /product-groups/{id}` adds the members (with the layers they override) and the group’s packaging.
Fields of `ProductGroupOut`:
| Field | Type | Notes |
| ----------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `id` | uuid | |
| `name` | string | |
| `code` | string | Unique per account: a Shopify product gid or a SKU stem. |
| `source` | “manual” \| “csv” \| “odoo” \| “shopify” \| “picqer” \| “supplier_spec” \| “photo” \| “api” \| “ai” | |
| `source_ref` | string | |
| `member_count` | integer | Products in the group. |
| `packaging_count` | integer | Packaging lines of the group (all versions). |
| `archived_at` | date-time \| null | |
| `created_at` | date-time | |
| `updated_at` | date-time | |
## Packaging
[Section titled “Packaging”](#packaging)
Layers: `sales` (Dutch app: Productverpakking; what the customer receives), `grouped` (Omverpakking; around several sales units), `transport` (Transportverpakking; pallets, stretch film). Packaging of web-shop parcels is kept apart, as parcel profiles per size class (`lt40`, `40to90`, `gt90` litres); it is not in the public API.
**Effective packaging**, per layer: if the product has its own lines on a layer they apply; otherwise the group’s lines apply (`inherited: true`, `group_id`).
`GET /products/{id}/packaging` returns the tree `layers[] > lines[] > item > materials[]`:
Fields of `PackagingTreeOut`:
| Field | Type | Notes |
| --------------------- | -------------------- | ------------------------------------------------------------------------------------ |
| `product_id` | uuid | |
| `sku` | string | |
| `group_id` | uuid \| null | The product’s group, if any. |
| `on` | date | The day the totals are for (query parameter `on`, default today). |
| `packaging_confirmed` | boolean | Gate 1 passed (see ProductOut.packaging_confirmed). |
| `confirmed_at` | date-time \| null | |
| `confirmation` | ConfirmationOut | |
| `layers` | PackagingLayerOut\[] | |
| `totals_by_material` | MaterialMassOut\[] | Packaging per sales unit by material on `on` (lines with in_totals), heaviest first. |
| `total_mass_mg` | integer | Sum of totals_by_material. |
| `findings` | FindingOut\[] | Automatic plausibility checks on today’s lines. |
Fields of `PackagingLayerOut`:
| Field | Type | Notes |
| --------- | ----------------------------------- | ------------------------------------------------------------------- |
| `layer` | “sales” \| “grouped” \| “transport” | sales (primary), grouped (secondary) or transport (tertiary). |
| `lines` | PackagingLineOut\[] | |
| `mass_mg` | integer | Mass per sales unit of this layer’s lines that count in the totals. |
Fields of `PackagingLineOut`:
| Field | Type | Notes |
| ------------------ | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `id` | uuid | |
| `state` | “proposed” \| “confirmed” | proposed = not yet checked by a person; confirmed = part of a gate-1 confirmation. |
| `quantity` | string | Parts per sales unit (may be a fraction, e.g. 1 film per 1440). |
| `household` | boolean | Ends up with households (counts as household packaging). |
| `scope_channel` | string \| null | Only for sales in this channel; null = all. |
| `scope_fulfilment` | string \| null | Only for this fulfilment; null = all. |
| `valid_from` | date \| null | First day (inclusive); null = from the start. |
| `valid_to` | date \| null | End day (exclusive); null = open-ended. |
| `mass_mg` | integer | Packaging mass per sales unit for this line (item mass x quantity). |
| `in_totals` | boolean | Counted in `totals_by_material`: valid on `on` and not limited to a channel/fulfilment. |
| `source` | “manual” \| “csv” \| “odoo” \| “shopify” \| “picqer” \| “supplier_spec” \| “photo” \| “api” \| “ai” | Where the line came from (provenance). |
| `source_ref` | string | Which record of that source, e.g. `api:token:`. |
| `inherited` | boolean | The line is the product group’s: the product has no own line on that layer. |
| `group_id` | uuid \| null | The group the line belongs to (inherited lines). |
| `item` | PackagingItemOut | |
Fields of `PackagingItemOut`:
| Field | Type | Notes |
| ----------------------- | --------------------- | --------------------------------------------------------------------- |
| `id` | uuid | |
| `name` | string | Name of the packaging part. |
| `code` | string | Your code for the part (unique per account when set). |
| `role_default` | string | Usual role: sales, grouped, transport, shipping or filler. |
| `household_default` | boolean | Usually ends up with households (vs. businesses). |
| `separable` | boolean | Can be separated from the other parts by hand. |
| `rigidity` | “rigid” \| “flexible” | |
| `recycled_content_pct` | string \| null | Recycled content in percent. |
| `recyclability_grade` | string \| null | A-E, when known. |
| `custom_for_product_id` | uuid \| null | Set for a part made for one product only (not in the shared library). |
| `materials` | MaterialMassOut\[] | Material breakdown of ONE part. |
| `total_mass_mg` | integer | Sum of the materials, milligrams. |
| `archived_at` | date-time \| null | |
| `created_at` | date-time | |
| `updated_at` | date-time | |
Fields of `MaterialMassOut`:
| Field | Type | Notes |
| ---------- | ------- | ----------------------------------------------------------------- |
| `material` | string | Material code from the knowledge pack (see /reference/materials). |
| `mass_mg` | integer | Mass in milligrams. |
Fields of `FindingOut`:
| Field | Type | Notes |
| ----------- | ------------ | --------------------------------------------------- |
| `code` | string | Stable check code. |
| `severity` | string | info, warning or error. |
| `params` | object | Values for the message, per code. |
| `line_id` | uuid \| null | The packaging line the finding is about, if any. |
| `dismissed` | boolean | A person marked this finding as right (‘klopt zo’). |
`POST /products/{id}/packaging/proposals` adds lines with `state: proposed`; a person confirms them in the app. Each line names an existing `item_id` or a `new_item`:
Fields of `ProposalLineIn`:
| Field | Type | Notes |
| ------------------ | ----------------------------------------------------------- | ------------------------------------------------------ |
| `item_id` | uuid \| null | An existing packaging item. |
| `new_item` | NewPartIn \| null | A new part for this product only. |
| `layer` | “sales” \| “grouped” \| “transport” | |
| `quantity` | number \| string | Parts per `per_units` sales units. |
| `per_units` | integer \| null | E.g. 1 film per 1440 jars: quantity 1, per_units 1440. |
| `household` | boolean | |
| `scope_channel` | “webshop” \| “bol” \| “amazon” \| “odoo” \| “other” \| null | Only for this sales channel. |
| `scope_fulfilment` | “own” \| “fba” \| “lvb” \| “3pl” \| null | Only for this fulfilment. |
| `valid_from` | date \| null | First day (inclusive); null = from the start. |
| `valid_to` | date \| null | End (exclusive); null = open-ended. |
Fields of `NewPartIn`:
| Field | Type | Notes |
| ----------- | --------------------- | --------------------------------------------- |
| `name` | string | **Required.** |
| `materials` | MaterialMassIn\[] | **Required.** Material breakdown of one part. |
| `rigidity` | “rigid” \| “flexible” | |
| `separable` | boolean | |
## Evidence
[Section titled “Evidence”](#evidence)
Files that back up packaging data (photos, spec sheets): jpeg/png/webp/heic/pdf, max 15 MB, uploaded in the app. `GET /products/{id}/evidence` lists them with a short-lived signed `download_url` (GET it without the Authorization header).
Fields of `EvidenceOut`:
| Field | Type | Notes |
| ------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
| `id` | uuid | |
| `kind` | “photo” \| “spec_sheet” \| “invoice” \| “other” | |
| `note` | string | |
| `target` | “product” \| “item” \| “packaging_line” | What the file is about. |
| `product_id` | uuid \| null | |
| `item_id` | uuid \| null | |
| `packaging_line_id` | uuid \| null | |
| `filename` | string | |
| `mime` | string | |
| `size_bytes` | integer | |
| `sha256` | string | |
| `created_at` | date-time | |
| `download_url` | string | Short-lived signed URL; GET it without the Authorization header. |
## Regime profiles
[Section titled “Regime profiles”](#regime-profiles)
Product data for a regime other than packaging, validated by the knowledge pack’s JSON Schema. `epr.weee` data: `in_scope`, `category` (1-6), `weight_mg`, `has_battery`, `battery_weight_mg`, `notes`. `epr.textiles` data: `in_scope`, `weight_mg`, `notes`. `GET /products/{id}/profiles` lists own and inherited profiles, all versions, with `in_force` per regime today. `PUT /products/{id}/profiles/{regime}` stores a **proposal** (`state: proposed`, `source: api`).
Fields of `RegimeProfileOut`:
| Field | Type | Notes |
| ---------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `id` | uuid | |
| `regime` | string | |
| `schema_version` | string | |
| `data` | object | |
| `state` | “proposed” \| “confirmed” | proposed (e.g. from the API) or confirmed by a person. |
| `inherited` | boolean | The group’s profile (the product has no own one for the regime). |
| `group_id` | uuid \| null | |
| `valid_from` | date \| null | |
| `valid_to` | date \| null | Exclusive; null = open-ended. |
| `source` | “manual” \| “csv” \| “odoo” \| “shopify” \| “picqer” \| “supplier_spec” \| “photo” \| “api” \| “ai” | |
| `source_ref` | string | |
| `confirmed_at` | date-time \| null | |
Fields of `ProfileProposalIn`:
| Field | Type | Notes |
| ------------ | ------------ | ------------------------------------------------------------------------ |
| `data` | object | **Required.** Profile data; must match the regime’s JSON Schema. |
| `valid_from` | date \| null | Start a new version on this day; omit to change the version valid today. |
## Reference codes
[Section titled “Reference codes”](#reference-codes)
Fields of `ReferenceOut`:
| Field | Type | Notes |
| ------------------- | -------------- | ---------------------------------------------- |
| `code` | string | |
| `name` | object | Name per language (nl, en). |
| `verified` | boolean | False until checked against the cited sources. |
| `sources` | string\[] | URLs of the sources the fact comes from. |
| `parent` | string \| null | Parent material code (materials only). |
| `scheme_categories` | object | Scheme code -> that scheme’s category. |
## Sales
[Section titled “Sales”](#sales)
`POST /sales` takes up to 1000 lines and writes them directly, idempotent on `external_id`. No consumer personal data is ever stored: a country is all EuroPlaybook needs.
Fields of `SalesLineIn`:
| Field | Type | Notes |
| ------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `external_id` | string | **Required.** Your unique id for this line; sending it again updates the line. |
| `sku` | string | **Required.** Your SKU; matched to a product. |
| `sold_on` | date | **Required.** Sale (or return) date. |
| `quantity` | integer | **Required.** Units (positive; use kind=return for returns). |
| `kind` | “sale” \| “return” | |
| `ship_from_country` | string | **Required.** ISO 3166-1 alpha-2. |
| `ship_to_country` | string | **Required.** ISO 3166-1 alpha-2. |
| `legal_entity_id` | uuid \| null | Selling company; default: the account’s default. |
| `channel` | “webshop” \| “bol” \| “amazon” \| “odoo” \| “other” | |
| `marketplace` | string \| null | Marketplace code, e.g. amazon.de. |
| `buyer_type` | “consumer” \| “business_end_user” \| “reseller” \| “unknown” | |
| `fulfilment` | “own” \| “fba” \| “lvb” \| “3pl” | |
| `net_amount_cents` | integer \| null | Net line amount in cents (optional). |
| `currency` | string | |
Fields of `SalesPushOut`:
| Field | Type | Notes |
| -------------- | --------- | --------------------------------------------------------------------------------------- |
| `created` | integer | New lines. |
| `updated` | integer | Lines whose values changed. |
| `unchanged` | integer | Lines sent before with the same values. |
| `held` | string\[] | external_ids not stored because a closed or filed period covers them (reopen it first). |
| `unknown_skus` | string\[] | SKUs without a product (stored; map or create the product). |
`GET /sales` lists stored lines, from every source:
Fields of `SalesLineOut`:
| Field | Type | Notes |
| ------------------- | ------------------ | ---------------------------------------------------------- |
| `id` | uuid | |
| `external_id` | string \| null | Your id (lines pushed through the API). |
| `source` | string | Where the line came from: api, csv, odoo, shopify, picqer. |
| `sku` | string | |
| `product_id` | uuid \| null | Matched product; null = unknown SKU. |
| `legal_entity_id` | uuid | |
| `sold_on` | date | |
| `kind` | “sale” \| “return” | |
| `quantity` | integer | |
| `ship_from_country` | string | |
| `ship_to_country` | string | |
| `channel` | string | |
| `marketplace` | string \| null | |
| `buyer_type` | string | |
| `fulfilment` | string | |
## Obligations
[Section titled “Obligations”](#obligations)
Per legal entity × jurisdiction (country) × regime (× scope), derived by the knowledge pack’s rules unless a person overrode the status.
Fields of `ObligationOut`:
| Field | Type | Notes |
| ----------------- | -------------------- | ------------------------------------------------ |
| `id` | uuid | |
| `legal_entity_id` | uuid | |
| `jurisdiction` | string | |
| `regime` | string | |
| `scope` | string | |
| `status` | string | Shown status (see years for every derived year). |
| `status_source` | string | rule = derived; user = overridden by a person. |
| `status_reason` | string | |
| `status_year` | integer \| null | |
| `since` | date \| null | |
| `pack_version` | string | |
| `years` | ObligationYearOut\[] | |
Fields of `ObligationYearOut`:
| Field | Type | Notes |
| --------------- | ------- | ------------------------------------------------------ |
| `year` | integer | |
| `status` | string | needed, not_needed, unknown, … for that year. |
| `reason_code` | string | Stable code of the rule outcome. |
| `reason_params` | object | |
| `status_reason` | string | The reason in English. |
| `rule_key` | string | Knowledge-pack rule the status rests on. |
| `verified` | boolean | The rule and its figures are verified against sources. |
## Reporting periods and totals
[Section titled “Reporting periods and totals”](#reporting-periods-and-totals)
A period is built from sales (and parcel) facts × effective packaging into ledger entries per material. A person locks it (`closed`); later uploads do not change a locked period.
Fields of `PeriodOut`:
| Field | Type | Notes |
| ----------------- | ----------------- | ---------------------------------------- |
| `id` | uuid | |
| `legal_entity_id` | uuid | |
| `jurisdiction` | string | |
| `scheme` | string | |
| `report_type` | string | |
| `cadence` | string | |
| `starts_on` | date | |
| `ends_on` | date | |
| `status` | string | open, review, closed, filed or reopened. |
| `closed_at` | date-time \| null | |
Fields of `PeriodTotalsOut`:
| Field | Type | Notes |
| ---------------------- | ----------------- | -------------------------------------------------------------- |
| `period` | PeriodOut | |
| `built_at` | date-time \| null | When the ledger was last built; null = never. |
| `stale` | boolean | Facts or packaging changed since the last build. |
| `blockers` | string\[] | Why the period could not be closed right now (codes). |
| `unknown_skus` | integer | |
| `unconfirmed_products` | integer | |
| `totals` | TotalOut\[] | |
| `declared` | any | What a pack made from this build would declare (scheme lines). |
Fields of `TotalOut`:
| Field | Type | Notes |
| ----------------- | -------------- | --------------------------------------------------- |
| `kind` | string | product, packaging, parcel_packaging or adjustment. |
| `material` | string \| null | |
| `household` | boolean | |
| `obligated_party` | string | |
| `exclusion` | string | |
| `fact_kind` | string | |
| `quantity` | integer | |
| `mass_mg` | integer | |
| `size_class` | string \| null | |
| `parcels` | integer | |
**Lineage.** `GET /periods/{id}/totals/{key}/explain` (MCP `explain_total`; `key` from `declared.lines[].key` of the totals) traces one figure back to its ledger entries, the products with the packaging lines used (own or inherited, with `source` and evidence count), and the sales per month and channel. `traced: false` means the period was built before lineage was recorded: rebuild it.
Fields of `ExplainTotalOut`:
| Field | Type | Notes |
| --------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `period_id` | uuid | |
| `period_status` | “open” \| “review” \| “closed” \| “filed” \| “reopened” | |
| `line` | ExplainLineOut | |
| `entry_match` | string | How entries were matched to the line: indexes (by the calculator) or material. |
| `traced` | boolean | Lineage was recorded at the build (false: built before; rebuild the period). |
| `entries` | ExplainEntryOut\[] | |
| `products` | ExplainProductOut\[] | Products behind the line, largest share first. |
| `parcels` | ExplainParcelsOut\[] | Parcel facts behind parcel packaging entries. |
## Filing packs
[Section titled “Filing packs”](#filing-packs)
The filing for a period and scheme, approved by a person.
Fields of `PackOut`:
| Field | Type | Notes |
| ------------- | ----------------- | -------------------------------- |
| `id` | uuid | |
| `period_id` | uuid | |
| `scheme` | string | |
| `report_type` | string | |
| `kind` | string | original or amendment. |
| `status` | string | draft, ready, approved or filed. |
| `format` | string | |
| `has_file` | boolean | |
| `approved_at` | date-time \| null | |
| `filed_at` | date-time \| null | |
| `filing_ref` | string | |
| `created_at` | date-time | |
Fields of `PackLineOut`:
| Field | Type | Notes |
| --------- | ---------------------------- | ------------------------------------------------ |
| `line_no` | integer | |
| `key` | string | |
| `label` | string | |
| `value` | string | kg with three decimals; counts as whole numbers. |
| `unit` | “kg” \| “units” \| “parcels” | |
`GET /packs/{id}/download` returns a short-lived URL to the file:
Fields of `DownloadOut`:
| Field | Type | Notes |
| ------------ | ------- | ---------------------------------------------------------------- |
| `url` | string | Short-lived signed URL; GET it without the Authorization header. |
| `filename` | string | |
| `expires_in` | integer | Seconds until the URL stops working. |
## Flow
[Section titled “Flow”](#flow)
1. Sources (Shopify, Picqer, Odoo, CSV uploads, the API) produce sales facts and parcel counts.
2. Products have effective packaging (own or inherited lines), confirmed by a person.
3. The knowledge pack derives obligations per legal entity.
4. A period build multiplies facts by effective packaging into ledger entries, per material.
5. A person locks the period, an owner or admin approves the pack, someone marks it filed with the scheme’s reference.
## What the API never does
[Section titled “What the API never does”](#what-the-api-never-does)
Confirm packaging or regime profiles, lock a period, approve a pack or mark it filed. Those are human gates.
# Concepts
> How accounts, products, packaging, sales facts, obligations and filings fit together.
EuroPlaybook turns two kinds of input into filings: **what you sell** (sales facts per country) and **how it is packed** (packaging per product). Rules from the knowledge pack decide where you have obligations.
The same structure as text:
```text
Account tenant root; every record belongs to one account
├─ LegalEntity a company (country, VAT); obligations are per entity
├─ ProductGroup ─┐ e.g. one Shopify product with several variants
├─ Product ──────┘ (group optional) one SKU
│ └─ packaging layer: sales | grouped | transport
│ └─ packaging line quantity per sales unit, household, state, source
│ └─ PackagingItem reusable item, e.g. "Box S"
│ └─ materials material code + mass_mg
├─ Connection (shopify | csv | odoo | picqer)
│ └─ SyncRun / ImportBatch
│ └─ facts: SalesFact (units per country), ParcelFact
└─ Obligation (entity × country × regime)
└─ FilingObligation (scheme, cadence)
└─ ReportingPeriod
├─ LedgerEntry facts × effective packaging, per material
└─ Pack › PackLine the filing; lineage points back to entries
```
## Key ideas
[Section titled “Key ideas”](#key-ideas)
**Tenancy.** Every record belongs to one account. An API token belongs to one account, so you never pass an account id.
**Effective packaging.** A product’s packaging is read per layer: if the product has its own lines on a layer, they apply; otherwise it inherits the lines of its group. API responses mark inherited lines with `inherited: true` and `group_id`; list groups with `GET /product-groups`.
**What the API writes.** Product data (`PUT /products/by-sku/{sku}`) and sales lines (`POST /sales`) are written directly. Packaging lines and regime profiles are only ever **proposals** (`state: proposed`, `source: api`): a person confirms them in the app. A product’s `confirmation.state` is `none`, `confirmed` or `changed` (confirmed before, but its packaging changed since). Packaging proposals clear that confirmation.
**Provenance.** Packaging lines and materials carry `source` (`manual`, `csv`, `odoo`, `shopify`, `picqer`, `supplier_spec`, `photo`, `api`, `ai`) and a free-text `source_ref`. Evidence (photos, spec sheets) can be attached to a product, item or line; read them (with a signed download URL) at `GET /products/{id}/evidence`.
**Regimes.** A regime is a type of EPR, such as `epr.packaging`, `epr.weee` or `epr.textiles`. Regime-specific product data lives in regime profiles (`GET /products/{id}/profiles`), each with `valid_from` / `valid_to`.
**Periods and gates.** Figures for a period are built into ledger entries, then **locked** (closed) by a person, then a pack is **approved** by an owner or admin, then marked **filed**. The API reads periods, totals and packs; it does not lock, approve or file.
**Lineage.** Every figure of a period can be traced back: `GET /periods/{id}/totals/{key}/explain` (MCP: `explain_total`) returns the ledger entries behind a pack line, the products, the packaging lines used (own or inherited, with `source` and evidence count) and the sales per month and channel. The trail is recorded when the ledger is built and kept when the period is locked.
## Pagination
[Section titled “Pagination”](#pagination)
List endpoints return a page of items and a cursor for the next page:
```json
{
"items": [ { "id": "…", "sku": "DG-HOSE-25" } ],
"next_cursor": "eyJpZCI6IjAxOTIifQ"
}
```
* Pass `?cursor=` to get the next page. Keep the other query parameters the same.
* `next_cursor: null` means this was the last page.
* `?limit=` sets the page size: 50 by default, at most 200.
* Cursors are opaque. Do not build or parse them; a broken cursor is `400 invalid_cursor`.
* Paging is by key, so items added while you page do not shift the pages. There are no total counts.
The full list of fields and enums is in the [data model](/developers/data-model/).
# Authentication and scopes
> API keys, the Bearer header, scopes and how they combine with team roles.
## API keys
[Section titled “API keys”](#api-keys)
An owner or admin creates keys in the app under **Instellingen › API-sleutels** (Settings › API keys). A key looks like this:
```text
ep_1a2b3c4d_0123456789abcdef0123456789abcdef
└prefix8┘ └──────────── secret32 ────────────┘
```
* The full key is shown **once**. EuroPlaybook stores only a SHA-256 hash.
* The prefix stays visible in the app and in the audit log, so you can tell keys apart.
* A key can have an expiry date and can be revoked at any time. Revoked or expired keys get `401`.
## Sending the key
[Section titled “Sending the key”](#sending-the-key)
Every request sends the key as a Bearer token:
```http
GET /v1/account HTTP/1.1
Host: api.europlaybook.com
Authorization: Bearer ep_1a2b3c4d_0123456789abcdef0123456789abcdef
```
The account is taken from the key. There is no account id in the path. Keep keys out of source control and front-end code; use an environment variable such as `EUROPLAYBOOK_API_KEY`.
## Scopes
[Section titled “Scopes”](#scopes)
| Scope | Allows |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `catalogue:read` | Products, product groups, packaging, packaging items, evidence, regime profiles, reference codes |
| `catalogue:write` | Create and update products by SKU; propose packaging and regime profiles |
| `sales:read` | Read sales lines |
| `sales:write` | Send sales lines (bulk, idempotent on `external_id`) |
| `compliance:read` | Legal entities, obligations, reporting periods and their totals |
| `reports:read` | Filing packs (read and download) |
`GET /account` works with any scope; use it to check a key.
## Scopes ∩ role
[Section titled “Scopes ∩ role”](#scopes--role)
A key’s effective rights are its scopes **intersected with the current role** of the person who created it:
* Owners, admins and members can use every scope. Accountants and readers (`readonly`) only the `:read` scopes, so a key with `catalogue:write` whose creator is now a reader can only read.
* If the creator leaves the account, all their keys stop working.
## What a key can change
[Section titled “What a key can change”](#what-a-key-can-change)
* **Written directly:** product data (`PUT /products/by-sku/{sku}`) and sales lines (`POST /sales`).
* **Proposed only:** packaging lines and regime profiles are stored with `state: proposed` and `source: api`. They show up in the app (“via API”) for a person to confirm.
* **Never:** confirming packaging, locking figures, approving packs and marking them filed. This keeps a person responsible for every filing.
## Errors
[Section titled “Errors”](#errors)
| Status | Meaning |
| ------ | -------------------------------------------------------- |
| `401` | Missing, malformed, expired or revoked key |
| `403` | The key’s scopes or the creator’s role do not allow this |
See [errors](/developers/errors/) for the full format. Exact codes are in the [API reference](/developers/api/).
# Developers
> Build on EuroPlaybook with the public API v1 and the MCP server; machine-readable entry points.
EuroPlaybook has a public REST API and an MCP server for AI assistants. Both use the same API keys and the same rules, and both call the same services as the web app.
## What you can do
[Section titled “What you can do”](#what-you-can-do)
* **Read** your catalogue: products, product groups, packaging (layers, lines, items, materials), evidence, regime profiles and reference codes.
* **Create or update** products by SKU. This writes directly.
* **Send sales** in bulk (up to 1000 lines per request), idempotent on your own line id. This writes directly.
* **Propose** packaging lines and regime profiles. A person confirms them in the app.
* **Read** legal entities, obligations, reporting periods with their totals, and filing packs.
## Basics
[Section titled “Basics”](#basics)
| | |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Base URL | `https://api.europlaybook.com/v1` |
| MCP endpoint | `https://mcp.europlaybook.com/mcp` (streamable HTTP) |
| Auth | `Authorization: Bearer ep__` ([details](/developers/authentication/)) |
| Format | JSON, UTF-8. IDs are UUID strings. Dates ISO 8601. |
| Units | Weights integer milligrams (`*_mg`), money integer cents (`*_cents`) + ISO 4217 currency, countries ISO 3166-1 alpha-2 |
| Lists | Cursor pagination, 50 items by default, at most 200 ([details](/developers/concepts/#pagination)) |
| Limits | 120 requests per minute per key ([details](/developers/errors/#rate-limits)) |
The account comes from the key, never from the URL. One key belongs to one account.
## For machines
[Section titled “For machines”](#for-machines)
The documentation is written to be read by AI agents and tools as well as people.
| URL | What |
| ------------------------------------ | -------------------------------------------------------------------------------- |
| [`/llms.txt`](/llms.txt) | Index of the English documentation for LLMs ([llmstxt.org](https://llmstxt.org)) |
| [`/llms-full.txt`](/llms-full.txt) | All English pages in one text file |
| [`/llms-small.txt`](/llms-small.txt) | A shorter version of the above |
| [`/openapi.json`](/openapi.json) | OpenAPI 3.1 document of the public API v1 (stable URL) |
| [`/data-model.md`](/data-model.md) | The [data model](/developers/data-model/) as raw Markdown |
| `https://mcp.europlaybook.com/mcp` | The [MCP server](/developers/mcp/) (needs an API key) |
## Next steps
[Section titled “Next steps”](#next-steps)
1. Read the [concepts](/developers/concepts/).
2. [Create a key and choose scopes](/developers/authentication/).
3. Make your first call with the [quick start](/developers/quickstart/).
4. Connect an AI assistant with [MCP](/developers/mcp/).
5. Look up every endpoint in the [API reference](/developers/api/).
# Errors and rate limits
> Error format, status codes, the per-key rate limit and how to handle them.
Errors are JSON with a human-readable `detail` and a stable machine-readable `code`. When the message contains values, they are also in `params`, so you can translate the message by `code`.
```json
{
"detail": "Too many requests for this API key (limit 120 per minute); retry after 12s",
"code": "rate_limited",
"params": { "limit": 120, "retry_after": 12 }
}
```
* Match on `code`, never on `detail`. `detail` may change wording.
* Invalid input is `422` with `code: validation_error` and an `errors` list naming the fields.
* Every response has an `X-Request-Id` header. Include it when you contact support.
## Status codes
[Section titled “Status codes”](#status-codes)
| Status | `code` (examples) | When | Retry? |
| ------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `400` | `bad_request`, `invalid_cursor`, `invalid_idempotency_key` | The request cannot be used as sent | No, fix the request |
| `401` | `unauthenticated` | Missing, malformed, expired or revoked key | No |
| `403` | `insufficient_scope` | The key’s scopes or the creator’s role do not allow this | No |
| `404` | `not_found` | Not found, or not in your account | No |
| `409` | `conflict`, `idempotency_key_reused` | Conflict with the current state, or an `Idempotency-Key` reused for a different request | No, unless you change the request |
| `422` | `validation_error` | Missing field, wrong type, unknown enum value, more than 1000 sales lines | No, fix the request |
| `429` | `rate_limited` | Rate limit reached | Yes, after `Retry-After` seconds |
| `5xx` | | Server error | Yes, with backoff and the same `Idempotency-Key` |
Records of another account always answer `404`, never `403`, so ids do not leak across accounts.
## Rate limits
[Section titled “Rate limits”](#rate-limits)
Each API key may make **120 requests per minute** (a fixed one-minute window). Above that the API answers `429` with code `rate_limited` and a `Retry-After` header in seconds, until the next minute starts.
* Wait `Retry-After` seconds, then retry. Do not retry in a tight loop.
* Batch writes: send up to 1000 sales lines per request instead of one per line.
* Use one key per integration, so one busy job does not slow down another.
* Rely on `429` and `Retry-After` rather than on the number: the limit may change.
# Idempotency
> Retry POST and PUT safely with the Idempotency-Key header.
Networks fail. To retry a write without doing it twice, send an `Idempotency-Key` header on `POST` and `PUT`:
```http
POST /v1/sales HTTP/1.1
Host: api.europlaybook.com
Idempotency-Key: 6f1c2d0e-sales-2026-10
```
* The key is 1 to 200 printable characters; anything else is `400 invalid_idempotency_key`. Use a unique value per logical operation, for example a UUID or your batch id.
* The first successful (2xx) response is kept for **24 hours**, per API key. A retry with the same key and the same method, path and body within that time gets the same response again, with the header `Idempotent-Replayed: true`, without doing the work again.
* The same key with a different request is `409 idempotency_key_reused`.
* Errors are not kept, so a request that failed can be retried with the same key.
* After 24 hours the same key starts a new operation.
## Natural keys
[Section titled “Natural keys”](#natural-keys)
Some endpoints are idempotent by design, also without the header:
| Endpoint | Natural key |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `POST /sales` | `external_id` per line: a line sent again is updated or left unchanged, never counted twice |
| `PUT /products/by-sku/{sku}` | The SKU: creates the product once (`201`), then updates it (`200`) |
| `PUT /products/{id}/profiles/{regime}` | The product and regime: creates or changes the product’s own profile for that regime (as a proposal) |
Use the header anyway on `POST`. It also covers packaging proposals, which have no natural key: without it, a retried proposal adds its lines twice.
# MCP set-up
> Connect Claude, ChatGPT, Cursor and other AI assistants to EuroPlaybook over MCP.
EuroPlaybook runs a [Model Context Protocol](https://modelcontextprotocol.io) server. AI assistants use it to read your catalogue and obligations and to propose packaging, with the same API key and rules as the REST API.
Status
You connect with an API key as a Bearer token. One-click connect with OAuth 2.1 is planned (phase 2).
| | |
| --------- | ---------------------------------------------------------------------------- |
| Endpoint | `https://mcp.europlaybook.com/mcp` |
| Transport | Streamable HTTP |
| Auth | `Authorization: Bearer ep_...` ([create a key](/developers/authentication/)) |
Give the key only the scopes the assistant needs. `catalogue:read` + `compliance:read` is a good start.
## Configuration
[Section titled “Configuration”](#configuration)
* Claude Code
```sh
claude mcp add --transport http europlaybook https://mcp.europlaybook.com/mcp \
--header "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
```
* Claude Desktop
Claude Desktop’s custom connectors need OAuth, which is planned. Until then, bridge with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) in `claude_desktop_config.json` (Settings › Developer › Edit config):
```json
{
"mcpServers": {
"europlaybook": {
"command": "npx",
"args": [
"-y", "mcp-remote@0.14.3",
"https://mcp.europlaybook.com/mcp",
"--header", "Authorization:${EUROPLAYBOOK_AUTH}"
],
"env": { "EUROPLAYBOOK_AUTH": "Bearer ep_1a2b3c4d_..." }
}
}
}
```
Restart Claude Desktop after saving.
* Cursor
In `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project; do not commit the key):
```json
{
"mcpServers": {
"europlaybook": {
"url": "https://mcp.europlaybook.com/mcp",
"headers": { "Authorization": "Bearer ep_1a2b3c4d_..." }
}
}
}
```
* ChatGPT
ChatGPT connects to remote MCP servers through **connectors**, which sign in with OAuth. EuroPlaybook’s OAuth support is planned. When it is live you add a connector with the endpoint `https://mcp.europlaybook.com/mcp` and sign in with your EuroPlaybook account; no key to copy.
* Other clients
Any MCP client that supports streamable HTTP and custom headers works: point it at `https://mcp.europlaybook.com/mcp` and send `Authorization: Bearer `. Clients that only speak stdio can use `mcp-remote` as above.
## Tools
[Section titled “Tools”](#tools)
| Tool | Does | Scope |
| ----------------------- | ----------------------------------------------------------------------------------------------------- | ----------------- |
| `find_products` | Search products by SKU, title, GTIN or identifier; with `group_id` and `confirmation` | `catalogue:read` |
| `get_product_packaging` | Effective packaging tree of a product: own and inherited lines with `source`, `inherited`, `group_id` | `catalogue:read` |
| `list_product_groups` | Product groups; with `group_id` one group, its members and its packaging tree | `catalogue:read` |
| `propose_packaging` | Create a packaging proposal (a person confirms it) | `catalogue:write` |
| `list_findings` | Packaging checks that need attention | `catalogue:read` |
| `list_obligations` | Obligations per country and regime | `compliance:read` |
| `explain_obligation` | Why an obligation applies, with its sources | `compliance:read` |
| `get_period_totals` | Totals of a reporting period | `compliance:read` |
| `explain_total` | Trace a total (`period_id`, pack line `key`) back to entries, products, packaging lines and sales | `compliance:read` |
| `push_sales` | Send sales lines | `sales:write` |
## Resources
[Section titled “Resources”](#resources)
* `europlaybook://reference/materials`
* `europlaybook://reference/layers`
* `europlaybook://reference/regimes`
* `europlaybook://reference/data-model` (a short version of the [data model](/developers/data-model/); assistants read it first)
## Safety
[Section titled “Safety”](#safety)
* The assistant can only do what the key allows, and never more than the person who created it.
* Packaging from an assistant is always a proposal with `source: api`; it counts only after a person confirms it. Sales lines sent with `push_sales` are stored directly, so give a key `sales:write` only when you want that.
* Revoke the key in the app to disconnect the assistant at once.
# Quick start
> Your first calls to the EuroPlaybook API with curl, Python and JavaScript.
1. Create a key under **Settings › API keys** (Dutch app: **Instellingen › API-sleutels**). `catalogue:read` is enough to start; the write examples need `catalogue:write` and `sales:write`.
2. Put it in an environment variable:
```sh
export EUROPLAYBOOK_API_KEY='ep_1a2b3c4d_...'
export API_BASE='https://api.europlaybook.com/v1'
```
## Check the key
[Section titled “Check the key”](#check-the-key)
* curl
```sh
curl -s "$API_BASE/account" \
-H "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
```
* Python
```python
import os
import httpx
api = httpx.Client(
base_url=os.environ["API_BASE"],
headers={"Authorization": f"Bearer {os.environ['EUROPLAYBOOK_API_KEY']}"},
)
print(api.get("/account").json())
```
* JavaScript
```js
const base = process.env.API_BASE;
const headers = { Authorization: `Bearer ${process.env.EUROPLAYBOOK_API_KEY}` };
const res = await fetch(`${base}/account`, { headers });
console.log(await res.json());
```
## List all products
[Section titled “List all products”](#list-all-products)
Lists use a cursor. Keep calling with `next_cursor` until it is `null`.
* curl
```sh
curl -s "$API_BASE/products?limit=100" \
-H "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
# then: ...&cursor=
```
* Python
```python
products, cursor = [], None
while True:
page = api.get("/products", params={"limit": 100, "cursor": cursor}).json()
products += page["items"]
cursor = page["next_cursor"]
if not cursor:
break
```
* JavaScript
```js
const products = [];
let cursor = null;
do {
const url = new URL(`${base}/products`);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (await fetch(url, { headers })).json();
products.push(...page.items);
cursor = page.next_cursor;
} while (cursor);
```
## Read a product’s packaging
[Section titled “Read a product’s packaging”](#read-a-products-packaging)
```sh
curl -s "$API_BASE/products?sku=DG-HOSE-25" -H "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
curl -s "$API_BASE/products//packaging" -H "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
```
The response is a tree: `layers[] > lines[] > item > materials[]`, plus `totals_by_material` and `confirmation`. Weights are integer milligrams. Lines the product inherits from its group have `inherited: true` and `group_id`; every line says where it came from (`source`, `source_ref`).
## Explain a total
[Section titled “Explain a total”](#explain-a-total)
Each figure of a period can be traced back. Take a `key` from `declared.lines` of the period totals and ask where it comes from:
```sh
curl -s "$API_BASE/periods//totals" -H "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
curl -s "$API_BASE/periods//totals//explain" -H "Authorization: Bearer $EUROPLAYBOOK_API_KEY"
```
You get the ledger entries behind the line, the products (largest share first) with the packaging lines used and their evidence count, and the units sold per month and channel.
## Send sales
[Section titled “Send sales”](#send-sales)
Up to 1000 lines per request. `external_id` is your own id for the line; sending it again does not count it twice.
* curl
```sh
curl -s -X POST "$API_BASE/sales" \
-H "Authorization: Bearer $EUROPLAYBOOK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2026-10-batch-1" \
-d '{"lines":[{"external_id":"order-1001-1","sku":"DG-HOSE-25","sold_on":"2026-10-03",
"quantity":2,"ship_from_country":"NL","ship_to_country":"BE","channel":"webshop"}]}'
```
* Python
```python
lines = [{
"external_id": "order-1001-1", "sku": "DG-HOSE-25", "sold_on": "2026-10-03",
"quantity": 2, "ship_from_country": "NL", "ship_to_country": "BE", "channel": "webshop",
}]
r = api.post("/sales", json={"lines": lines}, headers={"Idempotency-Key": "2026-10-batch-1"})
print(r.json())
```
* JavaScript
```js
const lines = [{
external_id: 'order-1001-1', sku: 'DG-HOSE-25', sold_on: '2026-10-03',
quantity: 2, ship_from_country: 'NL', ship_to_country: 'BE', channel: 'webshop',
}];
const res = await fetch(`${base}/sales`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': '2026-10-batch-1' },
body: JSON.stringify({ lines }),
});
console.log(await res.json());
```
The answer counts what happened to the lines:
```json
{ "created": 1, "updated": 0, "unchanged": 0, "held": [], "unknown_skus": [] }
```
| Field | Meaning |
| -------------- | ---------------------------------------------------------------------------------------------------------- |
| `created` | New lines |
| `updated` | Lines sent before (same `external_id`) whose values changed |
| `unchanged` | Lines sent before with the same values |
| `held` | `external_id`s not stored because a locked or filed period covers them; reopen the period in the app first |
| `unknown_skus` | SKUs without a product. The lines are stored; create or map the product and they count |
Open periods that overlap the dates are recalculated in the background. Never send personal data of consumers. A country code is all EuroPlaybook needs.
## Create or update a product by SKU
[Section titled “Create or update a product by SKU”](#create-or-update-a-product-by-sku)
`PUT` with the SKU in the path creates the product (`201`) or updates the fields you send (`200`). `title` is required. This writes directly; it does not touch the product’s packaging or its confirmation.
```sh
curl -s -X PUT "$API_BASE/products/by-sku/DG-SPRINK-01" \
-H "Authorization: Bearer $EUROPLAYBOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Garden sprinkler","net_weight_mg":350000,"country_of_origin":"NL","is_electrical":false}'
```
## Propose a regime profile
[Section titled “Propose a regime profile”](#propose-a-regime-profile)
Regime profiles hold product data for other EPR regimes, such as `epr.weee`. A `PUT` stores a **proposal** (`state: proposed`, `source: api`); a person confirms it in the app. `data` must match the regime’s schema (`422` otherwise). Send `valid_from` to start a new version on that day; leave it out to change the version valid today.
```sh
curl -s -X PUT "$API_BASE/products//profiles/epr.weee" \
-H "Authorization: Bearer $EUROPLAYBOOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"data":{"in_scope":true,"category":5,"weight_mg":420000,"has_battery":true,"battery_weight_mg":45000}}'
```
`epr.weee` data: `in_scope`, `category` (1-6), `weight_mg`, `has_battery`, `battery_weight_mg`, `notes`. `epr.textiles`: `in_scope`, `weight_mg`, `notes`. Read them back with `GET /products/{id}/profiles`.
Next: the [API reference](/developers/api/) lists every endpoint and field.
# Versioning and changelog
> How the public API is versioned and how changes are announced.
The version is in the path: `https://api.europlaybook.com/v1`.
## Within v1 (no notice needed)
[Section titled “Within v1 (no notice needed)”](#within-v1-no-notice-needed)
These changes can happen at any time. Write clients that tolerate them:
* New endpoints, new optional query parameters, new optional request fields.
* New fields in responses.
* New values in enums that are documented as open (for example `source`, material codes, regimes). Treat an unknown value as “other”.
* New error `code` values.
## Breaking changes
[Section titled “Breaking changes”](#breaking-changes)
Removing or renaming a field, changing a type or meaning, or making an optional field required is a breaking change. Breaking changes go into a new version (`https://api.europlaybook.com/v2`). The previous version keeps working for a transition period that is announced in the changelog.
The MCP server follows the same rules for tool names and arguments.
## Roadmap
[Section titled “Roadmap”](#roadmap)
Webhooks (signed event deliveries, so you do not need to poll) are planned; until then, poll the list endpoints, for example once an hour.
## Changelog
[Section titled “Changelog”](#changelog)
Changes are listed below, newest first. The OpenAPI document at [`/openapi.json`](/openapi.json) always describes the current version.
| Date | Change |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 2026-10 | OpenAPI paths are now relative to the server URL (`/account` instead of `/api/public/v1/account`); readable `operationId`s (`list_products`). The API itself did not change. |
| 2026-10 | Added: product `group_id` and `confirmation`; packaging lines `source`, `source_ref`, `inherited`, `group_id`; `product-groups`; product `evidence` and `profiles` (+ `PUT` proposal); `periods/{id}/totals/{key}/explain`. MCP: `list_product_groups`, `explain_total`. |
| 2026-10 | v1 contract published. |
# User guide
> Short guides for people who use EuroPlaybook.
EuroPlaybook helps you with extended producer responsibility (EPR). You see in which countries you have to report packaging, electrical equipment or textiles. EuroPlaybook calculates the figures and prepares your report.
The app is in Dutch and English. This guide names the English menu items; the [Dutch guide](/nl/gids/) names the Dutch ones.
New here? Follow these in order:
1. [What is EPR?](/guide/what-is-epr/) Is this for you?
2. [Getting started](/guide/getting-started/): account, companies and team.
3. [Connect Shopify](/guide/connect-shopify/) or [import files](/guide/importing/): products and sales.
4. [Products and packaging](/guide/products-and-packaging/): the basis of every report.
5. [Shipping packaging](/guide/shipping-packaging/): what goes into your web-shop parcels.
6. [Electrical and textiles](/guide/electrical-and-textiles/): if you sell those too.
7. [Obligations](/guide/obligations/) and [registrations](/guide/registrations/): where you must do what.
8. [Filing](/guide/filing/): from figures to a filed report.
9. [API key and AI assistant](/guide/api-key-and-ai-assistant/): for those who want more.
Rules per country
Rules differ per country and regime. In the app, every obligation explains why it applies and what it is based on. This guide does not repeat those rules; see **Reporting › Obligations** in the app.
# Create an API key and connect an AI assistant
> Create an API key to connect EuroPlaybook to an AI assistant or your own software.
With an API key other programs can talk to EuroPlaybook. For example an AI assistant such as Claude or ChatGPT, or your developer’s software.
## Create a key
[Section titled “Create a key”](#create-a-key)
Only owners and admins create keys.
1. Go to **Settings › API keys**.
2. Give the key a name, for example “Claude on my laptop”.
3. Choose what the key may do (its scopes). Give no more than needed. Read-only is often enough.
4. Optionally choose an expiry date.
5. Copy the key. **You see it only once.** Keep it like a password.
## What a key may do
[Section titled “What a key may do”](#what-a-key-may-do)
* A key never has more rights than the person who created it. If that person leaves the team, the key stops working.
* With write rights, a key can add or update products and send sales data directly. Packaging and product data for other regimes (electrical, textiles) it can only **propose**: someone in your team confirms it in the app (“via the API”). Locking figures and approving reports stay with people.
* You revoke a key with **Revoke**. It stops working at once.
## Connect an AI assistant
[Section titled “Connect an AI assistant”](#connect-an-ai-assistant)
Your AI assistant needs two things: the address of EuroPlaybook’s server for assistants (MCP) and your key. [MCP set-up](/developers/mcp/) shows per assistant what to fill in where. The **API keys** page in the app also has an example you can copy.
Then you can ask the assistant things like:
* “Which products have no checked packaging yet?”
* “Propose packaging for DG-SPRINK-01 based on this product description.”
* “What do I need to do in Belgium for packaging?”
# Connect Shopify
> Connect your Shopify store to fetch products and sales. Read-only.
With the connection, EuroPlaybook reads your products and orders from Shopify. From those it knows how many units you sold and got back, per month and per country.
Read-only
EuroPlaybook never changes anything in your store. It does not read customer names, e-mail addresses or street addresses, only the country an order was shipped to.
## Connect
[Section titled “Connect”](#connect)
Only owners and admins of the account can connect a store.
1. Go to **Settings › Connections › Shopify**.
2. Enter your store’s address: the `myshopify.com` address, for example `example-shop.myshopify.com`. You find it in Shopify under **Settings › Domains**. Use it even if your store has its own domain.
3. Click **Connect Shopify**. You go to Shopify and approve there.
4. You come back to EuroPlaybook with a message that your store is connected.
5. Click **Sync**.
## What a sync gives you
[Section titled “What a sync gives you”](#what-a-sync-gives-you)
* **Products**: every variant with a SKU becomes a product. A product with several variants becomes a group, so the variants can share packaging.
* **Sales data**: units sold and returned per month and country, under **Reporting › Sales data**.
* Cancelled and test orders do not count. Neither do gift cards and other lines without shipping.
* Orders without a shipping country (pickup) are listed with the connection for review.
## Shopify’s 60-day limit
[Section titled “Shopify’s 60-day limit”](#shopifys-60-day-limit)
By default Shopify only gives an app the orders of the **last 60 days**. Older orders need extra permission from Shopify. Need figures for a longer period, such as a full year? Upload them as a file (see [import files](/guide/importing/)), or ask us for help.
Tip
Connect your store early. Every next sync fetches new and changed orders, so you get no gaps.
## Problems
[Section titled “Problems”](#problems)
| You see | What to do |
| --------------------------------------- | -------------------------------------------------------------------------- |
| “Shopify did not grant all permissions” | Grant all requested permissions and connect again. |
| “Connecting took too long” | Start again. |
| Status “Needs key” | The app was removed in Shopify or access was revoked. Click **Reconnect**. |
# Electrical and textiles
> Mark per product whether it is also electrical (WEEE) or textile, so you see those obligations.
Electrical equipment (WEEE) and textiles have their own rules in many countries, next to those for packaging. EuroPlaybook needs to know which products they apply to.
## Mark it on the product page
[Section titled “Mark it on the product page”](#mark-it-on-the-product-page)
1. Open the product under **Products**.
2. The block **Other kinds of product** shows **Electrical too?** and **Textile too?**, with the answer or “Not filled in yet”. Click **Edit**.
3. Choose **Yes** or **No** per question. With **Yes**, add what you know:
* **Category (WEEE)**, electrical only: for example Small equipment or Small IT and telecom equipment. Not sure? Choose **Don’t know**.
* **Weight per unit (g)**, optional.
* **With a battery** and **Battery weight (g)**, electrical only.
4. Click **Save**.
If the product is in a group, you see the group’s answer with “Group: …”. Change it and the product gets its own answer; the group stays as it is.
## What EuroPlaybook does with it
[Section titled “What EuroPlaybook does with it”](#what-europlaybook-does-with-it)
* Under **Reporting › Obligations** you then also see the regimes for electrical equipment or textiles, per country.
* EuroPlaybook does not calculate these rules yet, and the categories are not checked yet. You do get a warning and a task, so you know something needs doing.
# Filing
> From sales data and packaging to an approved and filed report.
A report goes in steps, so you always know who checked what.
## Before you start
[Section titled “Before you start”](#before-you-start)
* Your sales data for the period is in (through a connection or an upload).
* Your products’ packaging is checked. Under **Reporting › Reports** you see how many products have no confirmed packaging yet.
## Step 2 · Lock the figures
[Section titled “Step 2 · Lock the figures”](#step-2--lock-the-figures)
Open the period under **Reporting › Reports**. EuroPlaybook calculates the figures: units sold times packaging, per material. Check the calculation and click **Lock the figures**.
* An owner, admin or accountant locks the figures.
* Uploads that arrive later do not change locked figures.
* Period not over yet? EuroPlaybook asks for a reason.
## Step 3 · Approve the report
[Section titled “Step 3 · Approve the report”](#step-3--approve-the-report)
EuroPlaybook prepares the report in the format of the producer responsibility organisation, for example a CSV file or a checklist.
1. Check the lines. Click a line to see where the figure comes from: which products, which packaging lines (own or from the group, with their source) and how many units per month.
2. The owner or an admin clicks **Approve the report**. The report is then fixed.
3. File the report with the producer responsibility organisation, on their website.
4. Click **Mark as filed** and enter the scheme’s reference and, if you like, the proof.
## Forgot something?
[Section titled “Forgot something?”](#forgot-something)
* **Not filed yet**: the owner clicks **Reopen**. The report lapses; after locking again you make a new one.
* **Already filed**: the filed report is kept. After locking again you make a correction (**Correct**) and file it again.
# Getting started
> Create an account, add your companies and invite your team.
## Signing in
[Section titled “Signing in”](#signing-in)
EuroPlaybook has no passwords. You enter your e-mail address and receive a code by e-mail. Owners and admins also turn on a second step with an authenticator app.
## Add your company
[Section titled “Add your company”](#add-your-company)
1. Create an account. The account is your workspace, for example “Demo Garden”.
2. Add your company under **Organisation › Companies & brands**: name, country, registry number (for example the Dutch KvK number) and VAT number.
3. More than one company (for example one in the Netherlands and one in Belgium)? Add them all. Obligations apply per company.
## Invite your team
[Section titled “Invite your team”](#invite-your-team)
Under **Settings › Team** you invite colleagues or your accountant. Everyone gets a role:
| Role | Can |
| ---------- | ----------------------------------------------------------------------------- |
| Owner | Everything, including reopening locked figures. |
| Admin | Edit data, approve reports, manage members, export, connections and API keys. |
| Member | Edit and upload products, packaging and sales data. No export. |
| Accountant | View, lock figures and export the account. |
| Read-only | View only. |
## Next
[Section titled “Next”](#next)
Bring in your products and sales. Shopify is the quickest: [connect Shopify](/guide/connect-shopify/). You can also upload files: [import files](/guide/importing/).
# Import files
> Upload products, packaging, sales and parcels as a CSV file, with the columns per kind.
Not on Shopify, or want to add older figures? Upload your data as a CSV file.
## Where to upload what
[Section titled “Where to upload what”](#where-to-upload-what)
* Go to **Products** and click **Connect data** (Dutch app: **Gegevens koppelen**). Choose Shopify, Picqer, Odoo or CSV. CSV takes you to **Products › Import**, for **products** and **packaging**.
* **Monthly sales** and **monthly parcels** are uploaded under **Reporting › Sales data**.
Members, admins and owners can upload. Every kind has a **Download template** button: a CSV with all columns and made-up example rows. Start from that.
## The file
[Section titled “The file”](#the-file)
* **CSV only.** Excel files are refused. In Excel choose **File › Save as › CSV UTF-8**.
* Encoding UTF-8 (otherwise Windows-1252). Separator comma, semicolon, tab or `|`: EuroPlaybook detects it.
* Column names may be English or Dutch (for example `artikelnummer` for `sku`, `aantal` for `quantity`); case and accents do not matter.
* At most 10 MB and 200,000 rows. Split a bigger file by period.
* No personal data. Columns that look like it are removed in your browser before the upload, and you are told which. An e-mail address in a cell blocks the upload.
* If any row has an error, nothing is imported. You see which rows to fix.
## Values
[Section titled “Values”](#values)
* **Decimals**: `12.5` or `12,5`.
* **Weight**: in grams (`net_weight_g`, `mass_g`) or milligrams (`*_mg`, whole number). No kilograms.
* **Yes/no**: `yes`/`no`, `true`/`false`, `1`/`0`, `ja`/`nee`, or `x` for yes.
* **Countries**: two-letter code (`NL`, `BE`, `DE`); country names work too.
* **Month**: `2026-03` or any date in that month (`2026-03-15`, `15-03-2026`).
## Columns per kind
[Section titled “Columns per kind”](#columns-per-kind)
**Required** columns are bold. Columns marked (form) may be left out: you then fill in the value in the upload form.
| Kind | Columns |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Products | **`sku`**, **`title`**, `brand_name`, `gtin`, `hs_code`, `country_of_origin`, `net_weight_g` or `net_weight_mg`, `is_kit`, `is_electrical`, `is_textile`, `components` (`SKU:qty;SKU:qty`), `asin`, `bol`, `shopify` |
| Packaging (one row per material) | **`item_code`**, **`material`**, **`mass_g` or `mass_mg`**, `sku`, `item_name`, `role`, `layer` (`sales`, `grouped`, `transport`), `quantity`, `per_units`, `household`, `channel`, `fulfilment`, `rigidity` (`rigid`, `flexible`), `separable`, `recycled_content_pct`, `supplier`, `valid_from`, `valid_to` |
| Monthly sales | **`month`** (form), **`sku`**, **`ship_from_country`** (form), **`ship_to_country`**, **`channel`** (form), **`quantity`**, `seller_vat`, `marketplace`, `buyer_type`, `fulfilment`, `kind` (`sale`, `return`), `orders`, `net_amount`, `currency`, `exclusion` |
| Monthly parcels | **`month`** (form), **`ship_from_country`** (form), **`ship_to_country`**, **`size_class`** (`lt40`, `40to90`, `gt90`), **`parcels`**, `household` |
Fixed values: `channel` is `webshop`, `bol`, `amazon`, `odoo` or `other`; `buyer_type` is `consumer`, `business_end_user`, `reseller` or `unknown`; `fulfilment` is `own`, `fba`, `lvb` or `3pl`.
## Period and uploading again
[Section titled “Period and uploading again”](#period-and-uploading-again)
* Products and packaging have no period. Rows are added or updated; nothing is deleted. Leave **Packaging valid from** empty and the packaging applies to all sales, also earlier ones.
* For sales and parcels you choose the **Period**: from the file, one month, quarter, year or from – to. If the file has no month column, all rows count in the month you choose.
* Uploading the same kind of data for the same period again **replaces** the earlier upload. A period that only partly overlaps an earlier upload is refused, and so is an upload into a locked or filed period.
# Obligations
> Read what you need to do per country and regime, and why.
Under **Reporting › Obligations** you see per company, country and regime what you need to do. A regime is for example packaging, electrical equipment or textiles.
## What you see per obligation
[Section titled “What you see per obligation”](#what-you-see-per-obligation)
* **Status**: Needed, Not needed, Registered, Paused or Not sure yet.
* **Why**: the reason, based on your company and your sales.
* **What to do and when**: register, declare, pay, with the deadlines.
* **Source**: where the rule comes from. If a rule is not yet confirmed by a source, the app says so.
## Change a status
[Section titled “Change a status”](#change-a-status)
Know something EuroPlaybook does not? For example that a partner files for you? Change the status with **Override status** and give a reason. Your reason is kept.
## “Not sure yet”
[Section titled ““Not sure yet””](#not-sure-yet)
Sometimes EuroPlaybook does not have enough data yet, for example because sales data is missing. You then see “Not sure yet” for that country. Fill in the missing data and it becomes clear by itself.
Once you have joined a scheme, record it under [registrations](/guide/registrations/).
Caution
This guide does not explain laws. Read the explanation with the obligation in the app; it names the source. In doubt? Ask your producer responsibility organisation or an adviser.
# Check products and packaging
> Check which packaging belongs to each product, handle warnings and confirm.
Your declaration is only as good as your packaging data. So you check per product which packaging belongs to it, and confirm that it is right.
## Three layers of packaging
[Section titled “Three layers of packaging”](#three-layers-of-packaging)
Every product has packaging in layers:
| Layer | What it is | Example |
| --------------------------------------------- | ------------------------------------------- | -------------------------------------- |
| **Sales packaging** (Productverpakking) | What the customer gets with the product. | Box around a garden hose, label, film. |
| **Grouped packaging** (Omverpakking) | Packaging around several products together. | Box of 6 sprinklers for a shop. |
| **Transport packaging** (Transportverpakking) | Packaging for transport and storage. | Pallet, stretch film. |
Shipping boxes for web shop parcels are separate, under **Products › Shipping packaging** (see [shipping packaging](/guide/shipping-packaging/)).
Each line in a layer names a packaging item (for example “Box S”), the number per product and the materials with their weight. EuroPlaybook adds up the weights per material. All parts are listed under **Products › Packaging**, so you can reuse them for other products.
## Groups and variants
[Section titled “Groups and variants”](#groups-and-variants)
A product with variants (say a garden hose of 25 m and of 50 m) can be in a **group**. The group’s packaging applies to all variants, so you fill it in once.
* On the product page each layer shows “inherits from group …” when the packaging comes from the group.
* One variant is different? Choose **own packaging for this product** for that layer. Its own lines then replace the group’s, for that layer only.
## Warnings: “This is right”
[Section titled “Warnings: “This is right””](#warnings-this-is-right)
EuroPlaybook automatically looks for values that seem odd, such as a box that is much heavier than for similar products. You then see “points to check in the packaging” at the top.
* Wrong? Fix the line.
* Right after all? Click **This is right**. The warning goes away and your choice is kept.
## Where does a value come from?
[Section titled “Where does a value come from?”](#where-does-a-value-come-from)
Each line has a small source label, for example “entered by hand”, “from Shopify” or “via the API”. Packaging that comes in through the API or an AI assistant is a **proposal** first. It only counts once someone in your team confirms the packaging.
## A photo as evidence
[Section titled “A photo as evidence”](#a-photo-as-evidence)
You can add a photo or document as evidence, for example a photo of the box on a scale or a supplier’s specification. Later you can show what your figures are based on.
* Allowed: JPEG, PNG, WebP, HEIC and PDF, up to 15 MB.
* You add evidence to the product, a packaging item or a line.
## Confirm
[Section titled “Confirm”](#confirm)
All correct? Under **Step 1 · Check packaging** click **Is this right? Packaging checked**. The product is then “Checked” and counts in your declarations without a warning.
Change the packaging later? EuroPlaybook notices. The product goes back to “To check”, so you confirm the change.
# Registrations
> Your scheme memberships and the marketplaces that pay on your behalf.
Under **Organisation › Registrations** you keep track, per company, of where you are registered. Everything has dates; anything a report already used is ended rather than deleted.
## Scheme memberships
[Section titled “Scheme memberships”](#scheme-memberships)
Joined a producer responsibility organisation (a scheme)? Click **Add registration** and fill in:
* **Company and obligation**
* **Membership number**
* **Valid from** and **Valid until (inclusive)** (empty = still running)
Not yet with Fost Plus (Belgium)? **Sign up with Fost Plus** walks you through the steps.
## Marketplaces that pay on your behalf
[Section titled “Marketplaces that pay on your behalf”](#marketplaces-that-pay-on-your-behalf)
Does a marketplace pay the fee for your sales there? Record it with **Add mandate**: the marketplace, the country if needed, the **Mandate reference** and the dates. When it stops, click **End**.
Whether a marketplace pays on your behalf depends on the country and the marketplace. Check the obligation in the app or ask the marketplace.
# Shipping packaging
> Record per parcel size what goes into your web-shop parcels.
Do you send orders to customers in boxes or bags? That shipping packaging counts too. You record once per size what goes into a parcel. EuroPlaybook multiplies it by the number of parcels per size.
## Set it up
[Section titled “Set it up”](#set-it-up)
Go to **Products › Shipping packaging** and create a parcel per size:
* **Size class**: Small (< 40 l), Medium (40–90 l) or Large (> 90 l). You may add the volume in litres.
* **For which company?**
* **Goes to consumers (household)**: on when your parcels end up with consumers.
* **Default for this size**: this parcel counts for all parcels of that size.
* **Contents**: the parts with their **Quantity**, for example a box, filling and tape. Choose **Add part**. Create the parts first under **Products › Packaging**.
## Number of parcels
[Section titled “Number of parcels”](#number-of-parcels)
The number of parcels per month and size comes from Picqer or from an upload of **monthly parcels** (see [import files](/guide/importing/)). Parcels without contents count as 0 grams; you then see the warning “Parcels without contents”.
Shipping packaging that a locked or filed period has used cannot be changed. Create a new one instead.
# What is EPR? Is this for me?
> Extended producer responsibility in plain words, and how to tell whether EuroPlaybook is for you.
**EPR** stands for extended producer responsibility. The idea: whoever puts a product on the market in a country helps pay for collecting and recycling it once it is thrown away. For packaging that usually means you join a producer responsibility organisation (a scheme) in that country, report how much packaging you put on the market, by material, and pay a fee for it. Some countries have similar rules for electrical equipment, batteries and textiles.
## Is this for me?
[Section titled “Is this for me?”](#is-this-for-me)
EuroPlaybook is made for small brands that sell physical products, often through a web shop, to customers in one or more EU countries. If you send products in packaging to customers in a country, you may have obligations there, also when your company is based somewhere else.
Whether an obligation applies depends on the country, the regime, who counts as the producer (you, a reseller or a marketplace) and sometimes on quantities. EuroPlaybook works this out from your companies and sales and shows the result per country under **Reporting › Obligations**, with the reason and the source.
## What EuroPlaybook does for you
[Section titled “What EuroPlaybook does for you”](#what-europlaybook-does-for-you)
* Collects your products and sales (from Shopify, Picqer, Odoo or files).
* Keeps your packaging per product, checked by you.
* Shows your obligations per country, with sources.
* Calculates the figures per period and prepares the report for the scheme.
Not legal advice
EuroPlaybook and this guide help you organise your data and your filings. They are not legal advice. Where a rule is not yet confirmed by a source, the app says so. In doubt, ask the scheme or an adviser.
# EuroPlaybook Docs
> Everything about EuroPlaybook, the EPR compliance portal for small brands. Pick where you start.
Users
Short steps: [what EPR is](/guide/what-is-epr/), [getting started](/guide/getting-started/), [connect Shopify](/guide/connect-shopify/), [check packaging](/guide/products-and-packaging/) and [file a report](/guide/filing/). Ook in het [Nederlands](/nl/gids/).
Developers
The [public API](/developers/), [authentication](/developers/authentication/), [quick start](/developers/quickstart/) and the [API reference](/developers/api/).
AI assistants and machines
[MCP server](/developers/mcp/), [`/llms.txt`](/llms.txt), [`/openapi.json`](/openapi.json) and the [data model](/developers/data-model/) in plain Markdown.