# EuroPlaybook Data model

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

- 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

`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_<prefix>_…`). |
| `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_<prefix8>_<secret32>`,
shown once, effective rights = scopes ∩ the creator's current role.

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

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:<prefix>`. |
| `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

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

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

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

`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

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

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

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

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

Confirm packaging or regime profiles, lock a period, approve a pack or mark it filed. Those are human gates.
