JouloDocs

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": "[email protected]", "external_ref": "KLANT-8842", "referral_code": "AB12CD34" },
      {
        "type": "zakelijk",
        "company_name": "Bedrijf B.V.",
        "name": "Piet Pietersen",
        "email": "[email protected]",
        "kvk": "12345678",
        "btw": "NL001234567B01"
      }
    ]
  }'
FieldRequiredNotes
consentyesMust be true: you confirm you may supply these customers and pre-register them with Joulo. Otherwise the request fails with 400 consent_required.
customers[]yesNon-empty array of { name, email }. Optional per row: type (particulier • default, zakelijk, vve), company_name, ean, kvk, btw, referral_code, external_ref, label (see Labels), postal_code + house_number + house_number_addition + city (see Sending the address), account_holder_name + supply_start_date + move_in_date (see Tenaamstelling, contract start and move-in date), 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_invitenoDefault 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_ref is accepted as an input alias. On the way out the field is always called external_ref • in GET /partner/customers the name customer_ref is already taken by Joulo's own pseudonymous reference.
  • GET /partner/customers fills it only on rows with source: partner_upload. Every other row carries external_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 to self_signup and external_ref becomes null. GET /partner/ere-positions then leaves the customer out altogether.
  • CSV imports accept it as a column named external_ref, customer_ref, klantnummer or referentie.

Labels

One contract can cover several labels: the trade names one energy supplier sells under. You stay one partner: the fee, the payout and the attribution are the same for every label. The label is only there so you can show customers per label in your own systems. A label is not the brand of the laadstation: that is charger_brand.

  • GET /partner/labels lists the labels you may send, with the code to use. A partner with one label gets that one.
  • Send the code as label on a row. It comes back on the draft, in GET /partner/drafts, GET /partner/customers and GET /partner/ere-positions.
  • An unknown label never blocks the row: the draft goes in without a label. The response reports the match per row in labels.
  • If Joulo cannot check the labels, the whole request fails with HTTP 500 and stores nothing. Send it again.
  • Without an upload, a customer gets a label only when their choice of energy supplier on joulo.nl links them to you. That supplier is then the label. Not every label you may send links customers to you yet: ask Joulo which ones do.

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": "[email protected]",
  "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_code is normalised to 1234AB; spaces and case do not matter. house_number takes 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, toevoeging and plaats.

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 through POST /partner/drafts/import with "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": "[email protected]", "external_ref": "BBP-8842",
  "ean_status": "resolved", "ean_candidates": 1 }
ean_statusMeaning
resolvedOne connection on this address. The form fills it in.
choice_neededSeveral connections. The customer picks one.
not_foundThe EAN register knows no connection here, usually a new build. The customer types the EAN.
lookup_failedThe EAN register did not answer. Nothing is lost: the form looks it up again.
skippedOne 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-positions once the customer has confirmed it in the registration form.
  • GET /partner/drafts repeats ean_status and ean_candidates on every row.

Tenaamstelling, contract start and move-in date

The NEa and our verifier ask for four facts per connection: the EAN, the address, the tenaamstelling and the start date of the energy contract. An energy supplier already holds all four. Send them with every customer, so the customer does not have to look anything up.

The EAN is required for an energy partner. A row without it is rejected with missing_ean. Without an EAN you do not vouch for the customer, so they would get the iDIN step.

{
  "name": "Jan de Vries",
  "email": "[email protected]",
  "ean": "871687120000000000",
  "postal_code": "6971 LB",
  "house_number": "12",
  "account_holder_name": "J. de Vries",
  "supply_start_date": "2026-03-01",
  "move_in_date": "2026-02-15"
}
  • account_holder_name is the tenaamstelling: the name on the connection in your books. We cut it to 200 characters.
  • supply_start_date is the day the energy contract on this EAN started. Send it for every customer, also when nobody moved. Send YYYY-MM-DD. Day first (01-03-2026) also works. We accept dates from 1990-01-01 up to one year ahead.
  • move_in_date is the day the customer moved to this address. Send it when you have it. Same format and rules as supply_start_date.
  • The API does not refuse a row without a tenaamstelling or a contract start date. For an energy partner it names that row in missing_recommended, with the fields that were missing. A date we cannot read never costs you the row either. The draft is created without that date, and dates_ignored names the row and the field.
  • We store them as you sent them, and do not verify them. The customer's own registration still decides the tenaamstelling.
  • GET /partner/drafts returns all three fields per draft.
  • Aliases: tenaamstelling, leveringsstart and verhuisdatum. CSV imports accept the columns tenaamstelling, leveringsstart and verhuisdatum.

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": "[email protected]",
  "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:

  • kvk is required for zakelijk (exactly 8 digits) • a business row without it is rejected with missing_kvk. For vve the KvK registration is voluntary, so kvk is optional there • but validated when present.
  • company_name carries the legal entity; name stays the contact person. On activation company_name becomes the customer's registered business name and btw is stored as the entity's VAT number. Rows without company_name fall back to name as the business name (the pre-2026-08 behaviour).
  • The fee regime follows automatically. Once the customer activates their claim, the account is stamped zakelijk/vve and 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": "[email protected]", "external_ref": "KLANT-8842", "ean_status": "resolved", "ean_candidates": 1 },
    { "draft_id": "b41a…", "email": "[email protected]", "external_ref": null }
  ],
  "address_ignored": [
    { "email": "[email protected]", "reason": "invalid_postal_code" }
  ],
  "claim_urls": [
    { "email": "[email protected]", "claim_url": "https://joulo.nl/claim/…" }
  ],
  "referral_codes": [
    { "email": "[email protected]", "code": "AB12CD34", "matched": true }
  ],
  "rejected": [
    { "email": "[email protected]", "reason": "existing_user" }
  ],
  "skipped": {
    "invalid": 0,
    "reserved_domain": 0,
    "missing_kvk": 0,
    "missing_ean": 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 }
}
  • created names every row that became a draft, in the order you sent them. draft_id is what POST /partner/drafts/auth-link wants, so you no longer need a GET /partner/drafts round-trip after an upload. When send_invite is false, each entry also carries its claim_url. Rows with an address carry ean_status and ean_candidates (see What we find at the address).
  • address_ignored is only present when a row carried an address we could not read (invalid_postal_code or invalid_house_number). The draft is created regardless • an address is a head start, not a condition.
  • missing_recommended is only present for an energy partner, when a created row had no account_holder_name or no supply_start_date. It is a warning: the draft exists.
  • dates_ignored is only present when a row carried a supply_start_date or move_in_date we could not read. Each entry names the row and the field. The draft is created without that date.
  • claim_urls is only present when send_invite is false. It repeats what created already carries and stays for existing integrations.
  • referral_codes is only present when at least one row carried a referral_code; matched: false means the code is unknown (the draft is still created).
  • rejected names every row that did not become a draft, with a stable reason code. One exception follows below. The codes: invalid, invalid_type, invalid_ean, missing_ean, reserved_domain, missing_kvk, already_drafted, existing_user, suppressed, conflict, over_request_limit, over_cap, charger_brand_unsupported, charger_not_linked, charger_claimed.
  • conflict no longer comes back, and skipped.conflict is always 0. The EAN never refuses a row: an upload does not tell you whether someone at Joulo uses that EAN. When another customer holds it, the activation ends in ean_conflict (see the app flow) and the draft moves to conflict. Another partner's draft of the same customer never blocks you.
  • The exception: a row that repeats the address of an earlier row in the same request is dropped without an entry in rejected or skipped. The earlier row decides the outcome for that customer. We compare addresses the way existing_user does, so Gmail dots and +tags do not count. A row rejected as invalid, invalid_type, invalid_ean, missing_ean, reserved_domain or missing_kvk does not count as the earlier row.
  • invalid_type means the row's type was not one of particulier, zakelijk, vve. invalid_ean means the supplied EAN was not exactly 18 digits. missing_ean means an energy partner sent the row without an EAN.
  • reserved_domain means the address is on a Joulo-owned domain. Those are staff addresses, never customers, so they can't be pre-registered.
  • existing_user means the address belongs to a Joulo account that holds something: a charger, charging sessions, a signed authorisation, a submitted registration, or anything else the customer would lose. An existing customer keeps that account and never gets a claim invitation for it. You only get this code with send_invite: true. With send_invite: false the row becomes a draft, and the customer signs in with their own account in your app. See the app flow. Matching ignores dots and +tags on Gmail/Googlemail addresses, so [email protected] and [email protected] count as the same customer. A Gmail variant of an existing address gets existing_user too.
  • An account that holds none of that does not refuse the row. Customers often opened one once and never finished it. The upload creates the draft as usual. When the customer opens your auth-link, we replace the empty account with a new one and the flow continues. If the account gained something in between, the link logs nobody in, as described in the app flow.
  • suppressed means nothing we send reaches the address: it bounced and no later mail got through, it is invalid, or the recipient blocked all mail from us. A customer who only unsubscribed from our marketing mail is refused with send_invite: true, because the claim invitation is a marketing mail. With send_invite: false we send nothing, so that row goes through.
  • skipped keeps the per-reason totals for the same rows. invalid_type and invalid_ean count under invalid.
  • cap shows 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.

The claim link is valid for 14 days. A customer who does not claim gets reminders from us on day 3 and day 10. On day 14 and day 21 we extend the link and remind them once more. That also holds when you deliver the link yourself (send_invite: false). A link can therefore work for about four weeks. A claimed draft does not expire.

Withdrawing a draft

A customer can change their mind before the claim is complete. Withdraw the draft, so the claim link stops working and our reminders stop:

curl -X POST https://api.joulo.nl/functions/v1/api/partner/drafts/withdraw \
  -H "Authorization: Bearer jpk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"draft_id": "8f2c1d4e-5b6a-4c3d-9e8f-0a1b2c3d4e5f"}'
  • You can withdraw a draft with status invited, claimed, expired or conflict. We delete it, and the answer is {"ok": true}.
  • Find the draft_id with GET /partner/drafts?external_ref=, or keep it from the response to POST /partner/customers.
  • The email address is free again afterwards. A new POST /partner/customers for it creates a new draft, also with the same external_ref.
  • A claimed draft may belong to a Joulo login the customer made. That login stays, without a link to you, and the customer can delete it in their account.
  • An active draft is a customer linked to you. Withdrawing it gives 409 with code: "draft_active". That customer turns off sharing with you in their Joulo account, and their row leaves GET /partner/ere-positions.
  • An unknown draft_id, or one of another partner, gives 404 with code: "draft_not_found".

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": "[email protected]" }
{ "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_id from the created array of your upload response. email works 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_url is 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 invited or claimed status. An active draft 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 return 409, 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. An energy supplier keeps the customer there only while it is their energy company in Joulo, see ERE positions.

Draft statusMeaning
invitedDraft created, claim link sent (or handed back to you when send_invite is false).
claimedThe customer opened the link and linked an account, but has not finished the claim.
activeClaim completed: account active, attribution to you final. End state.
expiredThe link expired unclaimed. No side effects. Withdraw the draft to invite the address again.
conflictCannot proceed: our claim mail hard-bounced (only with send_invite: true), or at activation the EAN turned out to be active for another customer. An active draft of the same customer at another partner does not count.

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. When we mail the claim link (send_invite: true), a row moves to conflict on its own when the address hard-bounces. The claim mail can never arrive, so the row leaves your open work instead of sitting on invited forever. When we later deliver a mail to that address, the bounce lifts and the row returns to invited. With send_invite: false a bounce never moves the row: you deliver the flow, so our mail plays no part in the claim. An upload of an address that still carries a bounce is refused with suppressed.
  • GET /partner/customers • attributed customers, including the effective fee and your partner share per customer. Every row carries a source that decides how much identity is disclosed:
sourceOriginIdentity fields
partner_uploadYou pre-registered them herenaam, email, phone, address, registration_status, type, laadstation, external_ref
partner_inviteYou invited the address from Uitnodigen in the portalnaam, email, registration_status
self_signupRegistered at Joulo, merely attributed to younone • 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.