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": "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"
      }
    ]
  }'
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. 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, and 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.
  • It follows the same disclosure rules as name and e-mail: if a customer objects under the GDPR, the row falls back to pseudonymous and external_ref is omitted.
  • CSV imports accept it as a column named external_ref, customer_ref, klantnummer or referentie.

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, and 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.

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 signs in and finishes the full registration (address, charger, authorization signed by an authorized representative, plus a KvK extract and energy contract they upload themselves • those documents cannot be supplied through this API). 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 reported in skipped, never silently dropped.

Response

{
  "ok": true,
  "added": 2,
  "batch_id": "…",
  "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
  },
  "cap": { "rolling_days": 30, "rolling_cap": 50000, "remaining_before": 49998 }
}
  • claim_urls is only present when send_invite is false.
  • 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 address that did not become a draft, with a stable reason code: invalid, invalid_type, invalid_ean, reserved_domain, missing_kvk, already_drafted, existing_user, suppressed, conflict, over_request_limit, over_cap.
  • 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.
  • 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 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 +tags on Gmail/Googlemail addresses, so jan.de.vries@gmail.com and jandevries@gmail.com count as the same customer.
  • skipped keeps the per-reason totals for the same rows.
  • 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 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": "…" }        // or { "email": "jan@devries.nl" }
{ "ok": true, "auth_url": "https://joulo.nl/claim-auth/…", "expires_in": 300 }

Load auth_url straight into the webview. Joulo signs the customer in server-side and lands them on the claim page — no OAuth, no email round-trip.

  • 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 uploaded 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.
  • Works only for drafts in invited or claimed status; activated, expired or conflicted drafts return an error.

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 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; you can invite again.
conflictCannot proceed: the address hard-bounced, or the EAN is already claimed and active elsewhere.

registration_status then runs conceptingediendin_reviewgoedgekeurd (or afgekeurd). A position only shows up in GET /partner/ere-positions once the registration is running and eligible sessions exist. As a reading model: draft active means onboarding started, registration_status: goedgekeurd means onboarding finished, and a row in ere-positions means ERE is building up.

GET /partner/drafts is paginated with limit (1–500, default 500) and offset. The response carries limit, offset and count (the total across all statuses) so you can page through everything you ever uploaded • the rolling upload cap is 50.000 rows per 30 days, well past a single page.

  • GET /partner/drafts • uploaded rows and their claim status. A row moves to conflict on its own when the address hard-bounces: the claim mail can never arrive, so it leaves your open work instead of sitting on invited forever. Re-uploading that address 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.