# API documentation

Everything you need to take a client from first question to a paid order in France, Germany and Spain, and to test it in the sandbox first.

## Quickstart

The API is JSON over HTTPS at `https://epreurep.com/api/v1`. It needs no key, and it allows any origin, so you can call it from a browser. An integration has four steps:

| Step | Call | What you get |
|---|---|---|
| 1. Ask the questions | `GET /france/questionnaire` | The French questions, streams and plan rules |
| 2. Price the answers | `POST /quote` | The plan, the yearly total and the German licence estimate |
| 3. Send the client to order | `POST /onboarding-links` | A checkout link tied to your partner code |
| 4. Test it | `POST /sandbox/onboardings` | A simulated run of each country's steps |

Every order is completed by the client at the checkout link, because the client has to accept the terms, pay and sign the mandate. Use the demo partner code `northstar` while you build. Paid orders count for a partner only under that partner's own code.

Limit: 30 requests a minute per IP. Prices are in euro and exclude VAT. Scheme fees and eco-contributions are billed separately at cost. All partner and API orders are billed by EPRFranceRep SAS, whichever countries they cover.

## How an order runs

After the client pays, each country runs these steps in this order. They are the same steps the sandbox plays back.

| Country | Steps | The client does |
|---|---|---|
| France | `order_paid`, `mandate_signed`, `registered` | Signs the mandate |
| Germany | `order_paid`, `mandate_signed`, `dual_system_paid`, `appointed_in_lucid`, `registered` | Signs the mandate, names us in LUCID |
| Spain | `order_paid`, `mandate_signed`, `power_of_attorney_verified`, `registered` | Signs the mandate |

The client works in their own portal at portal.epreurep.com, which opens once they have paid. You follow their progress and your commission in the partner dashboard.

## France

France needs three answers, the same three our checkout asks. All three are required. A request without one returns `invalid_request` and names the missing field.

| Field | Values | Question |
|---|---|---|
| `france.sells_to` | `consumers`, `mixed`, `businesses` | Who do you sell to in France? |
| `france.streams` | Stream keys, see below | Which product streams do you place on the French market? |
| `france.tonnes` | `up_to_1`, `1_to_10`, `over_10` | How many tonnes of packaging a year? |

```bash
curl https://epreurep.com/api/v1/france/questionnaire
```

### The plan follows from the answers

You never choose a plan. `/quote` works it out, and the checkout re-prices it the same way. The rules run from the top; the first match wins.

| Answers | Plan | Price a year |
|---|---|---|
| Over 10 tonnes, or more than 3 streams | Enterprise | We quote it |
| `sells_to` is `mixed` or `businesses`, or professional packaging is listed | Plus Pro | €990, up to 3 streams |
| 1 to 10 tonnes, or any Plus stream (WEEE, batteries) | Plus | €990, up to 3 streams |
| Any Enterprise stream | Enterprise | Quoted by us first |
| Everything else | Standard | €190 per stream, up to 3 |

Two streams are added for the client, and both count towards the 3. Household packaging is added unless they sell only to businesses. Professional packaging is added when they sell to businesses.

### Streams

| Key | Lowest plan |
|---|---|
| `household-packaging` | Standard |
| `textiles-clothing-and-footwear-tlc` | Standard |
| `electrical-and-electronic-equipment` | Plus |
| `batteries` | Plus |
| `furniture` | Standard |
| `toys` | Standard |
| `sports-and-leisure-goods` | Standard |
| `diy-and-garden-tools` | Standard |
| `professional-packaging` | Plus Pro |
| `building-products-pmcb` | Enterprise |
| `tyres` | Enterprise |
| `chemical-products` | Enterprise |
| `vehicles` | Enterprise |
| `lubricants-and-mineral-oils` | Enterprise |
| `medical-sharps` | Enterprise |
| `medicines` | Enterprise |
| `tobacco-products` | Enterprise |
| `fishing-gear` | Enterprise |

### Example

```bash
curl -X POST https://epreurep.com/api/v1/quote \
  -H 'content-type: application/json' \
  -d '{"france":{"sells_to":"consumers","streams":["toys"],"tonnes":"up_to_1"}}'
```

```json
{
  "ok": true,
  "custom_quote": false,
  "total": 380,
  "currency": "EUR",
  "lines": [{ "country": "FR", "item": "France Standard", "eur": 380, "per": "year" }],
  "france": {
    "plan": "Standard",
    "streams": ["household-packaging", "toys"],
    "stream_count": 2,
    "tonnes": "up_to_1",
    "why": []
  },
  "note": "Indicative. Scheme fees and eco-contributions are billed separately at cost. Prices exclude VAT."
}
```

`why` lists what raised the plan, so you can show it to the client. It is empty on Standard.

### Enterprise

Over 10 tonnes or more than 3 streams cannot be ordered through the API. `/quote` returns `custom_quote: true` and no price:

```json
{
  "ok": true,
  "custom_quote": true,
  "total": null,
  "currency": "EUR",
  "lines": [],
  "france": {
    "plan": "Enterprise",
    "streams": ["household-packaging", "toys"],
    "stream_count": 2,
    "tonnes": "over_10",
    "why": ["Over 10 tonnes a year"]
  },
  "note": "Enterprise: more than 3 French streams or over 10 tonnes into France. We quote it first, so it cannot be ordered directly."
}
```

`/onboarding-links` still returns a `url`, with `custom_quote: true`. It opens a quote request and has no payment step. We send a written quote, and the client pays through a link from us. If the order also includes Germany or Spain, the whole order waits for the quote.

## Germany

Germany's licence is priced on this year's packaging in kilos, by material, at Noventiz, our dual system. Kilos are optional in the order: the client enters them in their portal. The licence is passed on at cost, and we accept the LUCID appointment only once it is paid.

| Field | Type | Meaning |
|---|---|---|
| `germany.packaging_kg` | object | Kilos by material. Required unless `already_licensed` is true |
| `germany.already_licensed` | boolean | Another dual system already licenses all of it |
| `germany.weee` | boolean | Adds German WEEE, €500 a year |
| `germany.batteries` | boolean | Adds German batteries, €500 a year |

| Material key | Material |
|---|---|
| `paper` | Paper, board, carton |
| `plastic` | Plastic |
| `glass` | Glass |
| `ferrous` | Ferrous metals |
| `alu` | Aluminium |
| `bevcarton` | Beverage cartons |
| `composite` | Other composites |
| `other` | Other materials |

```bash
curl -X POST https://epreurep.com/api/v1/quote \
  -H 'content-type: application/json' \
  -d '{"germany":{"packaging_kg":{"paper":120,"plastic":40}}}'
```

```json
{
  "ok": true,
  "custom_quote": false,
  "total": 149,
  "currency": "EUR",
  "lines": [{ "country": "DE", "item": "Germany packaging", "eur": 149, "per": "year" }],
  "note": "Indicative. Scheme fees and eco-contributions are billed separately at cost. Prices exclude VAT.",
  "germany": {
    "dual_system_estimate": { "eur": 121.43, "year": 2026, "vat_included": true, "charged": "in your client portal, before we accept the LUCID appointment" }
  }
}
```

`dual_system_estimate` is Noventiz's live price, VAT included, and it is separate from `total`. It is `null` if Noventiz gives no price at that moment; the quote itself still works.

### When the licence is paid

Nothing is paid for the licence at checkout. Kilos you send with the order prefill the client's portal, where they can change them, see the live price and pay. They can sign the mandate and name us in LUCID first, but we accept the appointment only once the licence is paid.

With `already_licensed: true`, the client gives the licence details in the portal, and pays for any volume above what their other dual system covers.

## Spain

| Field | Values | Meaning |
|---|---|---|
| `spain.plan` | `essential`, `standard` | €199 or €249 a year. Defaults to `essential` |
| `spain.nif` | boolean | Adds the Spanish tax number, €249 one-off, for clients who have none |

The NIF normally takes 5 to 6 days after the tax office appointment, and takes longer in busy periods. Foreign companies do not get a provisional NIF.

## Checkout links

`POST /onboarding-links` takes the same body as `/quote`, plus your partner code. `company` and `email` are optional.

```bash
curl -X POST https://epreurep.com/api/v1/onboarding-links \
  -H 'content-type: application/json' \
  -d '{"partner":"northstar","france":{"sells_to":"consumers","streams":["toys"],"tonnes":"up_to_1"},"company":"Example Ltd","email":"owner@example.com"}'
```

```json
{
  "ok": true,
  "custom_quote": false,
  "url": "https://epreurep.com/checkout/?fr=0&frs=toys&fr_seg=cons&ref=northstar#company=Example+Ltd&email=owner%40example.com",
  "note": "The client confirms the details, signs and pays at this link. Paid orders are tracked to the partner."
}
```

The link opens our checkout with the answers already filled in, and the client confirms them. Nothing is stored until the client starts. `company` and `email` sit in the URL fragment, so they never reach a server through the link.

The checkout prices the order again, so a changed link can't lower a price. Your code stays on the order, which is how it reaches your partner dashboard. Your partner code is public, so it is safe to use from a browser.

## Sandbox

The sandbox plays back the steps from [How an order runs](#flow). It creates no order, mandate, payment or registration, and it needs no partner code. The id holds the whole state, so nothing is stored.

```bash
curl -X POST https://epreurep.com/api/v1/sandbox/onboardings \
  -H 'content-type: application/json' \
  -d '{"countries":["DE"]}'
```

One step moves on every 5 seconds. Read the status with `GET /sandbox/onboardings/{id}`:

```json
{
  "ok": true,
  "sandbox": true,
  "id": "sbx_...",
  "countries": [{
    "country": "DE",
    "status": "mandate_signed",
    "completed": ["order_paid", "mandate_signed"],
    "next": { "step": "dual_system_paid", "client_action_required": false },
    "registration_number": null
  }],
  "complete": false,
  "note": "Simulated. No order, mandate, payment or registration exists. Test numbers are not valid anywhere."
}
```

`client_action_required` is true for a step the client has to do personally, such as signing the mandate. When `complete` is true, each country has a test `registration_number`, for example `DE-TEST-0000000000000`. Test numbers are not valid anywhere.

The checks, quotes and links are live even while you test: `/quote` returns real prices and `/onboarding-links` returns a real checkout. Use `northstar` so that no test counts as a partner's lead. You can also run all of this from the [sandbox on the developers page](/developers/#sandbox).

## Partner codes

Apply on the [partner page](/partners/). Once approved, you get your own code, your dashboard, and 20% commission on what your clients pay us, renewals included. `northstar` is a demo code that earns nothing.

## Errors

Errors return an HTTP status and a body with `ok: false`, an `error` code and a `message` you can show.

```json
{ "ok": false, "error": "invalid_request", "message": "france.tonnes is required: up_to_1, 1_to_10 or over_10." }
```

| Status | `error` | Meaning |
|---|---|---|
| 400 | `invalid_json` | The body isn't a JSON object |
| 400 | `invalid_request` | A field is missing or has a value we don't accept. `message` names it |
| 400 | `unknown_partner` | The partner code doesn't exist |
| 404 | `not_found` | Unknown path or sandbox id |
| 405 | `method_not_allowed` | Use POST on this path |
| 429 |  | Over the limit of 30 requests a minute. Wait and try again |

## Endpoint reference

OpenAPI file: https://epreurep.com/developers/openapi.json

### POST /api/v1/check

Which of your countries we cover, and a first guess at the product streams. The streams can go straight into `/quote`.

```bash
curl -X POST https://epreurep.com/api/v1/check \
  -H 'content-type: application/json' \
  -d '{"countries":["FR","DE"],"products":["cotton t-shirt"]}'
```

### GET /api/v1/france/questionnaire

The questions our French checkout asks, with every stream, the lowest plan for each and the plan rules. Build your form from it.

```bash
curl https://epreurep.com/api/v1/france/questionnaire
```

### POST /api/v1/quote

The yearly price, calculated by the same code as our checkout. France needs the three answers from the questionnaire and returns the plan they lead to. Germany needs this year's kilos and returns the licence estimate. `custom_quote` is true for Enterprise, which we quote first.

```bash
curl -X POST https://epreurep.com/api/v1/quote \
  -H 'content-type: application/json' \
  -d '{"france":{"sells_to":"consumers","streams":["toys"],"tonnes":"up_to_1"},"germany":{"packaging_kg":{"paper":120,"plastic":40}},"spain":{"plan":"essential","nif":true}}'
```

### POST /api/v1/onboarding-links

A checkout link tied to your partner code, with the answers already in it and company and email filled in. Company and email stay in the URL fragment. Takes the same body as `/quote`. For Enterprise, the link opens the quote request and cannot be paid. `northstar` is a demo code for testing: use your own once you are a partner.

```bash
curl -X POST https://epreurep.com/api/v1/onboarding-links \
  -H 'content-type: application/json' \
  -d '{"partner":"northstar","germany":{"packaging_kg":{"paper":120,"plastic":40}},"company":"Example Ltd","email":"owner@example.com"}'
```

### POST /api/v1/sandbox/onboardings

Starts a simulated onboarding for FR, DE or ES and returns an id. One step moves on every 5 seconds.

```bash
curl -X POST https://epreurep.com/api/v1/sandbox/onboardings \
  -H 'content-type: application/json' \
  -d '{"countries":["DE"]}'
```

### GET /api/v1/sandbox/onboardings/{id}

The simulated status per country. `client_action_required` marks the steps a client has to do personally.

```bash
curl https://epreurep.com/api/v1/sandbox/onboardings/sbx_...
```

## What no API can do

- A client appoints the representative directly. The mandate names the real legal entity in each country.
- Germany: the client registers in LUCID personally, and the mandate needs a handwritten or qualified electronic signature. The dual-system licence is paid in the portal, before we accept the appointment.
- Spain: the appointment can need a power of attorney that the authorities verify.
- Registrations and declarations are done by our team, so they run in days, not seconds.
