You can add EPR for France, Germany and Spain to a SaaS or VAT platform in five steps, with no key. Your product asks the questions, and we do the regulated work behind it.
By Razvan Toma · Founder · Last checked 1 November 2026
Your software checks who needs EPR, shows a price and sends the client to a checkout link tied to your partner code. The client signs and pays, and our representative in each country does the registration and filing.
A VAT number or a VAT registration doesn't cover EPR. They are separate duties, with separate registers and deadlines, and a VAT registration doesn't make a company established for EPR.
| Your product does | We do | The client does |
|---|---|---|
| Spots who needs EPR | Acts as the legal representative | Signs the mandate |
| Asks the questions | Registers and joins the schemes | Pays, and takes the personal steps |
| Shows the price and sends the link | Files the declarations | Provides the figures |
You probably already hold what EPR starts from: the legal entity, the sales countries and the marketplaces. A seller shipping packaged goods to consumers in France, Germany or Spain has packaging EPR there, and needs a representative if it isn't established in that country.
curl -X POST https://epreurep.com/api/v1/check \
-H 'content-type: application/json' \
-d '{"countries":["FR","DE","ES"],"products":["cotton t-shirt","toy car"]}'/check confirms the countries we cover and gives a first guess at the French streams. Show it as a prompt to the client, not as a legal answer. See does a warehouse make a seller established?.
| Country | What you need | Where it comes from |
|---|---|---|
| France | Who they sell to, the streams, tonnes of packaging a year | GET /france/questionnaire |
| Germany | This year's kilos per material, or already_licensed | germany.packaging_kg |
| Spain | The plan, and whether they need a Spanish tax number | spain.plan, spain.nif |
Build your form from the questionnaire so it stays in step with our checkout.
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}}'/quote uses the same code as our checkout, so the price the client sees is the price they pay. Scheme fees and eco-contributions are billed separately at cost. For Germany, the response also estimates the dual-system licence, which the client pays in their portal before we accept the LUCID appointment.
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"}'Paid orders from that link are tracked to your partner code. The client doesn't pay more through you. For Enterprise, the same link opens the quote request and can't be paid. Apply to get your own code.
/sandbox/onboardings runs each country's real steps with test numbers, so you can build your status screens before a real client goes through them. One step moves on every 5 seconds.
Save it as epr.mjs, then run node epr.mjs.
const API = 'https://epreurep.com/api/v1';
const post = async (path, body) => {
const r = await fetch(API + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json();
if (!j.ok) throw new Error(j.message);
return j;
};
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
// Client's answers, from your own form
const answers = { france: { sells_to: 'consumers', streams: ['toys'], tonnes: 'up_to_1' } };
const quote = await post('/quote', answers);
if (quote.custom_quote) console.log('Enterprise: ask us for a quote');
else console.log('Yearly total', quote.total, quote.currency);
// Test the steps in the sandbox before you go live
let run = await post('/sandbox/onboardings', { countries: ['FR'] });
while (!run.complete) {
await sleep(5000);
run = await (await fetch(`${API}/sandbox/onboardings/${run.id}`)).json();
}
console.log(run.countries[0].status, run.countries[0].completed);
// Use your own partner code
const link = await post('/onboarding-links', { partner: 'northstar', ...answers, company: 'Example Ltd', email: 'owner@example.com' });
console.log(link.url);import json, time, urllib.request
API = 'https://epreurep.com/api/v1'
def call(path, body=None):
req = urllib.request.Request(API + path, json.dumps(body).encode() if body else None, {'content-type': 'application/json', 'user-agent': 'my-platform'})
with urllib.request.urlopen(req) as r:
return json.load(r)
# Client's answers, from your own form
answers = {'france': {'sells_to': 'consumers', 'streams': ['toys'], 'tonnes': 'up_to_1'}}
quote = call('/quote', answers)
print('Enterprise: ask us' if quote['custom_quote'] else (quote['total'], quote['currency']))
# Test the steps in the sandbox before you go live
run = call('/sandbox/onboardings', {'countries': ['FR']})
while not run['complete']:
time.sleep(5)
run = call('/sandbox/onboardings/' + run['id'])
print(run['countries'][0]['status'], run['countries'][0]['completed'])
# Use your own partner code
link = call('/onboarding-links', {'partner': 'northstar', **answers, 'company': 'Example Ltd', 'email': 'owner@example.com'})
print(link['url'])Registration is a status that moves over days, not a call that returns a number. Design your screens for pauses, tell the client which step is theirs, and never show a registration before the register does.
| Country | Steps | What holds it up |
|---|---|---|
| France | Order, mandate, registered | Eco-organisme membership issues the IDU |
| Germany | Order, mandate, licence, LUCID, registered | The client registers in LUCID personally |
| Spain | Order, mandate, power of attorney, registered | A Spanish tax number and verifying the power |
You follow real clients, their fees and yours in the partner dashboard.
| What happens | What to do |
|---|---|
custom_quote is true | Show that we quote it, and send the link. It opens the quote request |
invalid_request | Show the message, which names the field |
| HTTP 429 | You went over 30 requests a minute. Wait and retry |
| The client doesn't finish | Keep the link and remind them. Nothing is ordered until they pay |
| The client already has a dual-system licence | Send germany.already_licensed instead of kilos |
Start with a referral page and add the API when volume justifies it. If you want to own more of the journey, that's a white-label agreement. For the comparison, see EPR API vs white label vs referral. AI agents have their own quickstart.