Upload customers
POST /partner/customers • push customers as claimable drafts, with or without Joulo sending the invite
POST /partner/customers turns each entry into a customer_draft through the
same validation, dedupe, cap and attribution path as the portal's CSV import.
Name and email are enough; the customer completes the rest (address, charger,
authorization) in the claim flow.
Request
curl -X POST https://api.joulo.nl/functions/v1/api/partner/customers \
-H "Authorization: Bearer jpk_live_..." \
-H "Content-Type: application/json" \
-d '{
"consent": true,
"send_invite": true,
"customers": [
{ "name": "Jan de Vries", "email": "jan@devries.nl", "external_ref": "KLANT-8842", "referral_code": "AB12CD34" },
{
"type": "zakelijk",
"company_name": "Bedrijf B.V.",
"name": "Piet Pietersen",
"email": "inkoop@bedrijf.nl",
"kvk": "12345678",
"btw": "NL001234567B01"
}
]
}'| Field | Required | Notes |
|---|---|---|
consent | yes | Must be true: you confirm you may supply these customers and pre-register them with Joulo. Otherwise the request fails with 400 consent_required. |
customers[] | yes | Non-empty array of { name, email }. Optional per row: type (particulier • default, zakelijk, vve), company_name, ean, kvk, btw, referral_code, external_ref, postal_code + house_number + house_number_addition + city (see Sending the address), charger_brand + charger_id (see Pre-coupling the charger). An unknown type value is rejected with invalid_type • it never silently falls back to particulier. |
send_invite | no | Default true: Joulo emails each customer their claim link. Set to false to deliver the links yourself • they are returned in the response. |
Your own customer reference (external_ref)
Pass your own customer id as external_ref (max 120 characters, free-form) and
it comes back unchanged on every read route: GET /partner/drafts,
GET /partner/customers and GET /partner/ere-positions. That is the stable
key to reconcile against your own system.
Prefer it over the alternatives. An e-mail address can change. An EAN belongs
to the connection, so after a change of occupant the same EAN carries a
different customer. external_ref survives both.
- We never interpret the value and do not enforce uniqueness. Deduplication stays on e-mail, so a duplicate reference never breaks a batch.
customer_refis accepted as an input alias. On the way out the field is always calledexternal_ref• inGET /partner/customersthe namecustomer_refis already taken by Joulo's own pseudonymous reference.GET /partner/customersfills it only on rows withsource: partner_upload. Every other row carriesexternal_ref: null. That includes invited customers, whose name and e-mail you do see. If a customer objects under the GDPR, the row falls back toself_signupandexternal_refbecomesnull.- CSV imports accept it as a column named
external_ref,customer_ref,klantnummerorreferentie.
Friend referral codes
If your customer holds a Joulo referral code from a friend (an existing
Joulo customer), pass it as referral_code on that row. When the customer
completes the claim flow, the friend referral is attributed exactly as if the
code had been entered on joulo.nl. The referring friend earns their referral
discount, on top of your partner attribution.
- An unknown code never blocks the row: the draft is still created. The
response reports the code as unmatched in
referral_codes, so you can ask the customer to double-check. - Validate a code live (for instance on your own signup form) with
GET /partner/referral-codes/check?code=AB12CD34:
{ "ok": true, "code": "AB12CD34", "valid": true, "referrer_first_name": "Sanne" }The response discloses only the referrer's first name, the same as the public signup banner on joulo.nl.
Sending the address
If you already asked the customer where they live, send it along:
{
"name": "Jan de Vries",
"email": "jan@example.nl",
"external_ref": "BBP-8842",
"postal_code": "6971 LB",
"house_number": "12",
"house_number_addition": "B",
"city": "Brummen"
}This is worth more than a filled-in form. The registration form resolves the EAN from postcode and house number against the Dutch EAN register, and corrects the street and city against the BAG. With the address on the draft, the form opens with that done. The customer confirms an EAN instead of hunting for one on an energy bill. That is the step where sign-ups stall.
- Send the address of the connection where they charge, not a billing address. The authorization binds that connection.
- One address can carry more than one EAN. The customer picks and confirms, and always signs the authorization themselves.
postal_codeis normalised to1234AB; spaces and case do not matter.house_numbertakes the digits, and an addition packed onto it (12B) is split off automatically.- All four fields are optional and independent. Postcode plus house number is what drives the EAN lookup.
- The address is a head start, never a source of truth. It seeds the registration only while the customer has entered no address of their own. Whatever they type wins.
- A field we cannot read never costs you the row. The draft is created without
the address and the row is named in
address_ignored. - CSV imports accept the columns
postcode,huisnummer,toevoegingandplaats.
What we find at the address (ean_status)
For a small upload we look the EAN up while the request runs. Each row in
created then carries what we found. That tells you at once whether the
customer can finish, before they open anything. We look up when all three hold:
- The request creates five drafts or fewer. We count the drafts it creates, not the rows you send, so rejected rows do not count.
- The upload comes through
POST /partner/customers, or throughPOST /partner/drafts/importwith"source": "api". A CSV import never looks up. - Your partner stays within 1.000 lookups in 24 hours, this request included.
{ "draft_id": "8f2c…", "email": "jan@example.nl", "external_ref": "BBP-8842",
"ean_status": "resolved", "ean_candidates": 1 }ean_status | Meaning |
|---|---|
resolved | One connection on this address. The form fills it in. |
choice_needed | Several connections. The customer picks one. |
not_found | The EAN register knows no connection here, usually a new build. The customer types the EAN. |
lookup_failed | The EAN register did not answer. Nothing is lost: the form looks it up again. |
skipped | One of the three conditions above does not hold. |
ean_candidates is the number of connections we found, or null when we did
not look or got no answer. The field is absent on rows without postcode and
house number, and on rows where you sent the EAN yourself. It is also absent
when we could not read the address (see address_ignored).
- It is a signal, never a rejection. The draft is created either way, and the customer always confirms the EAN in the form.
- We do not say whether the connection is taken. You can upload any address, so that answer would tell you which households are Joulo customers. The claim screen handles it with the customer. Do not keep your own list of taken EANs either: the rule is one registration per connection per calendar year.
- The lookup does not hand you the EAN numbers. An EAN belongs to the
connection, not to your customer record. It shows up in
GET /partner/ere-positionsonce the customer has confirmed it in the registration form. GET /partner/draftsrepeatsean_statusandean_candidateson every row.
Pre-coupling the charger
If you already know which charger the customer owns, send it with the row and they never have to type it themselves:
{
"name": "Jan de Vries",
"email": "jan@example.nl",
"external_ref": "BBP-8842",
"charger_brand": "bluebird",
"charger_id": "ec:64:c9:6d:29:40"
}charger_brand is required whenever you send charger_id, and bluebird is
the only accepted value. BlueBird Power is the one brand where Joulo holds a
fleet operator key. A charger can then be attached without the customer
logging in with the manufacturer. Every other brand needs the customer to
complete an OAuth step in person, and a partner cannot do that for them. Those
rows are rejected with charger_brand_unsupported rather than guessed at.
The id is normalised, so EC-64-C9-6D-29-40, EC64C96D2940 and
ec:64:c9:6d:29:40 all resolve to the same charger.
Checked at upload, not at claim
We validate the id against the brand backend while your request is running.
A charger the manufacturer has not linked to Joulo comes back as
charger_not_linked in the same response. That is the point of pre-coupling:
you find out immediately, per row, instead of the customer hitting a dead end
days later.
We also refuse an id that already sits on another Joulo account or another open
draft, as charger_claimed. Validation proves a charger is on the Joulo fleet,
never that it belongs to this particular customer. So the first claim wins, and
a contested id goes to support.
If our check cannot run at all, the row is accepted with the id stored unvalidated. That happens when the brand backend is down or refuses our operator key. That failure is ours, not yours, and it never costs you a row. The claim re-checks before attaching anything.
What the customer sees
The charger is attached when the customer activates their claim, not at upload: it needs an account to hang off. They still confirm their details and sign the authorization, they just never see the charger step. In your app the flow goes straight on to the registration form (Onboarding inside your app). From an emailed claim link, they land on their dashboard instead of the coupling screen.
A charger can be taken between your upload and their claim. The claim still completes: the authorization is far too valuable to throw away over a charger. The customer then types the charger id in the flow, and our check names the problem. From an emailed claim link, they land on the BlueBird Power coupling screen.
Business customers (zakelijk / vve)
A business row differs from a consumer row in three ways:
kvkis required forzakelijk(exactly 8 digits) • a business row without it is rejected withmissing_kvk. Forvvethe KvK registration is voluntary, sokvkis optional there • but validated when present.company_namecarries the legal entity;namestays the contact person. On activationcompany_namebecomes the customer's registered business name andbtwis stored as the entity's VAT number. Rows withoutcompany_namefall back tonameas the business name (the pre-2026-08 behaviour).- The fee regime follows automatically. Once the customer activates their
claim, the account is stamped
zakelijk/vveand the flat business fee applies • you never set fees through this API.
The claim itself does not complete a business registration. After claiming,
the customer finishes the full registration: address, charger, and the
authorization signed by an authorized representative. They also upload a KvK
extract and an energy contract, which this API cannot supply. Track progress
via registration_status on GET /partner/customers.
Limits: at most 1.000 rows per request and a rolling cap of 50.000 rows
per 30 days per partner. Rows over either limit are named in rejected and
counted in skipped.
Response
{
"ok": true,
"added": 2,
"batch_id": "…",
"created": [
{ "draft_id": "8f2c…", "email": "jan@devries.nl", "external_ref": "KLANT-8842", "ean_status": "resolved", "ean_candidates": 1 },
{ "draft_id": "b41a…", "email": "inkoop@bedrijf.nl", "external_ref": null }
],
"address_ignored": [
{ "email": "inkoop@bedrijf.nl", "reason": "invalid_postal_code" }
],
"claim_urls": [
{ "email": "jan@devries.nl", "claim_url": "https://joulo.nl/claim/…" }
],
"referral_codes": [
{ "email": "jan@devries.nl", "code": "AB12CD34", "matched": true }
],
"rejected": [
{ "email": "al.klant@gmail.com", "reason": "existing_user" }
],
"skipped": {
"invalid": 0,
"reserved_domain": 0,
"missing_kvk": 0,
"already_drafted": 0,
"existing_user": 1,
"suppressed": 0,
"conflict": 0,
"over_request_limit": 0,
"over_cap": 0,
"charger_brand_unsupported": 0,
"charger_not_linked": 0,
"charger_claimed": 0
},
"cap": { "rolling_days": 30, "rolling_cap": 50000, "remaining_before": 49998 }
}creatednames every row that became a draft, in the order you sent them.draft_idis whatPOST /partner/drafts/auth-linkwants, so you no longer need aGET /partner/draftsround-trip after an upload. Whensend_inviteisfalse, each entry also carries itsclaim_url. Rows with an address carryean_statusandean_candidates(see What we find at the address).address_ignoredis only present when a row carried an address we could not read (invalid_postal_codeorinvalid_house_number). The draft is created regardless • an address is a head start, not a condition.claim_urlsis only present whensend_inviteisfalse. It repeats whatcreatedalready carries and stays for existing integrations.referral_codesis only present when at least one row carried areferral_code;matched: falsemeans the code is unknown (the draft is still created).rejectednames every row that did not become a draft, with a stablereasoncode. One exception follows below. The codes:invalid,invalid_type,invalid_ean,reserved_domain,missing_kvk,already_drafted,existing_user,suppressed,conflict,over_request_limit,over_cap,charger_brand_unsupported,charger_not_linked,charger_claimed.- The exception: a row that repeats the address of an earlier row in the same
request is dropped without an entry in
rejectedorskipped. The earlier row decides the outcome for that customer. We compare addresses the wayexisting_userdoes, so Gmail dots and+tagsdo not count. A row rejected asinvalid,invalid_type,invalid_ean,reserved_domainormissing_kvkdoes not count as the earlier row. invalid_typemeans the row'stypewas not one ofparticulier,zakelijk,vve.invalid_eanmeans the supplied EAN was not exactly 18 digits.reserved_domainmeans the address is on a Joulo-owned domain. Those are staff addresses, never customers, so they can't be pre-registered.existing_usermeans the address already belongs to a Joulo account, so the upload is refused for that row. An existing customer keeps whatever attribution they already have and never gets a claim invitation for an account they already own. Matching ignores dots and+tagson Gmail/Googlemail addresses, sojan.de.vries@gmail.comandjandevries@gmail.comcount as the same customer.skippedkeeps the per-reason totals for the same rows.invalid_typeandinvalid_eancount underinvalid.capshows the rolling upload window so your integration can pace batches.
The claim flow
Each draft resolves to a personal link on joulo.nl/claim/…. The customer
confirms who they are, connects their charger and signs the ERE authorization
themselves.
Only the holder of the connection (EAN) can sign the authorization • an upload never creates an authorization by itself. Drafts that are never claimed expire without side effects.
Embedding in a webview (auth links)
The claim link identifies the draft, not the user: opening it still requires a sign-in. Inside an embedded webview that is a problem. Google blocks OAuth in webviews, and an emailed magic link forces the user out to their mail client.
For that case, mint a short-lived auth link just-in-time, the moment the user opens the screen:
POST /partner/drafts/auth-link
{ "draft_id": "…", "return_url": "bluebird://joulo/done" } // or { "email": "jan@devries.nl" }{ "ok": true, "auth_url": "https://joulo.nl/claim-auth/…", "expires_in": 300, "resume": false, "return_url": "bluebird://joulo/done" }Load auth_url straight into the webview. Joulo signs the customer in
server-side and opens the in-app flow: no OAuth, no email round-trip. The flow
runs up to a submitted registration and then sends the customer to your
return_url. The whole journey, the statuses and the browser advice are in
Onboarding inside your app.
- Take
draft_idfrom thecreatedarray of your upload response.emailworks just as well and saves you from storing the id at all. - The link is valid for 5 minutes and is a login credential: never store it, never email it, request a fresh one per screen-open. Re-minting invalidates the previous link.
- If the customer is new to Joulo, an account is created on the uploaded email address (confirmed, passwordless).
- If the email or the EAN already belongs to an existing Joulo account, the auth link deliberately does not sign in. The customer lands on the normal claim page and signs in with their own account. A partner-delivered link can never grant access to an existing account.
return_urlis optional and must start with the return URL you registered in the partner portal. Without a registered URL, the link opens the classic claim page instead of the in-app flow.- Works for drafts in
invitedorclaimedstatus. Anactivedraft gets a link only to resume (resume: true). That needs the account the link itself created, a concept registration, and no stored IBAN or verified identity. Expired or conflicted drafts return409, and so do accounts past that point.
Reading back status
Two lifecycles run one after the other. The draft status covers the claim,
from upload to an activated account. Once a draft is active, the customer
appears in GET /partner/customers and registration_status takes over.
| Draft status | Meaning |
|---|---|
invited | Draft created, claim link sent (or handed back to you when send_invite is false). |
claimed | The customer opened the link and linked an account, but has not finished the claim. |
active | Claim completed: account active, attribution to you final. End state. |
expired | The link expired unclaimed. No side effects; you can invite again. |
conflict | Cannot proceed: the address hard-bounced, or the EAN is already claimed and active elsewhere. |
registration_status then runs concept → ingediend → in_review →
goedgekeurd (or afgekeurd). A row in
GET /partner/ere-positions appears as soon as
the customer's EAN is on their profile. That can be while the registration is
still concept. Its amounts stay 0 until ERE builds up. As a reading model: draft
active means onboarding started, registration_status: goedgekeurd means
onboarding finished, and ere_eligible: true in ere-positions means ERE is
building up.
GET /partner/drafts is paginated with limit (1–500, default 500) and
offset. The response repeats limit and offset, and adds count. Page
through everything you ever uploaded: the rolling cap of 50.000 rows per 30
days runs well past one page.
To look up one customer, filter with ?external_ref= or ?email=. Both match
exactly, within your own partner:
curl "https://api.joulo.nl/functions/v1/api/partner/drafts?external_ref=KLANT-8842" \
-H "Authorization: Bearer jpk_live_..."count then reports what the filter leaves, so paging stays correct. stats
keeps counting all your drafts by status • it feeds a dashboard, not a page.
Each row repeats what you uploaded • external_ref, ean and the address
fields • so you can reconcile without keeping a local copy.
GET /partner/drafts• uploaded rows and their claim status. A row moves toconflicton its own when the address hard-bounces. The claim mail can never arrive, so the row leaves your open work instead of sitting oninvitedforever. Re-uploading that address is refused withsuppressed.GET /partner/customers• attributed customers, including the effective fee and your partner share per customer. Every row carries asourcethat decides how much identity is disclosed:
source | Origin | Identity fields |
|---|---|---|
partner_upload | You pre-registered them here | naam, email, phone, address, registration_status, type, laadstation, external_ref |
partner_invite | You invited the address from Uitnodigen in the portal | naam, email, registration_status |
self_signup | Registered at Joulo, merely attributed to you | none • pseudonymous customer_ref, city + month granularity |
An invited customer is identified because the e-mail address came out of your
own customer base; contact and technical details stay masked unless you
pre-registered them. A customer who objects under the AVG is demoted back to
self_signup on both routes.