Legal Entities API
Provision, list, authorise, and off-board your customers as Peppol legal entities. The multi-tenant surface for platforms.
legal_entities:* scope. A standard key receives 403. Customers you
don't own return 404, never another platform's data.
Looking for your own account's identity instead? That is GET /v1/identity — it works
with any API key and needs no platform activation.
Getting a master key. In the sandbox, an organisation admin starts the platform sandbox trial from the console overview, then creates a sandbox master key from the console. Production platform access is set up with our team — email hello@getpeppr.dev to request it. Onboarding your own company needs none of this: that is done in the console, on the Peppol identity page.
Create a customer as a draft Peppol legal entity. getpeppr validates the identifier format (for example, a Luhn check), then kicks off registry verification asynchronously. Returns 202 on creation, or 200 if the same externalId is replayed with the same normalized Peppol participant (the call is idempotent, including scheme aliases and identifier case).
externalId returns 409 with code: "identifier_already_in_use" before any provider creation. When the owner is already committed, existingLegalEntity contains its getpeppr id and your externalId; no other account's data is ever exposed.
An overlapping request can temporarily return 409 legal_entity_creation_in_progress; retry the same payload.
422 with code: "platform_billing_suspended" and nothing is created. Customers you already have keep receiving documents,
and replaying the externalId of an existing customer still returns it. The restriction ends when the suspension does.
202 while the identifier later settles as unsupported_scheme, verification_failed, or registration_failed if automatic verification is unavailable, the registry check fails, or the identifier is already registered elsewhere or not attachable on
the test network. Poll GET /v1/legal-entities/:id or subscribe to legal_entity.* webhooks before treating the customer as send-ready — and for a Norwegian customer, read vatRegistration too.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
externalId | string | Required | Your stable reference for this customer (1–64 chars). Echoed on every response and webhook. |
companyName | string | Required | Legal company name (2–64 chars). Compared with the returned name when the relevant verification source provides one. |
country | string | Required | ISO 3166-1 alpha-2 country code (e.g. GB). |
address.line1 | string | Required | Street address (2–192 chars). |
address.city | string | Required | City (2–64 chars). |
address.zip | string | Required | Postal / ZIP code (2–32 chars). |
identifier.scheme | string | Optional | Peppol identifier scheme (1–16 chars, e.g. GB:VAT, 0208). Send identifier or identifiers, never both. |
identifier.value | string | Optional | The identifier value (1–64 chars). Format-checked (e.g. Luhn) before verification. |
identifiers | array | Optional | Sandbox only for now. One to three { scheme, value } entries, primary first — see French customers below. The order is registered on the network as declared and cannot be changed. |
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_your_master_key" });
const customer = await peppol.legalEntities.create({
externalId: "customer_be_001",
companyName: "Your Customer SRL",
country: "BE",
address: { line1: "Customer street and number", city: "Brussels", zip: "1000" },
identifier: { scheme: "0208", value: "YOUR_10_DIGIT_KBO" },
});
console.log(customer.id, customer.status, customer.networkDiscovery.state);
// "7c9a1b34-…", "pending", "pending"// 202 Accepted — verification runs asynchronously
{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"companyName": "Your Customer SRL",
"country": "BE",
"identifier": { "scheme": "0208", "value": "YOUR_10_DIGIT_KBO" },
"status": "pending",
"networkDiscovery": { "state": "pending", "attempts": 0 },
"environment": "production",
"createdAt": "2026-06-01T10:00:00.000Z",
"identifiers": [
{ "scheme": "0208", "value": "YOUR_10_DIGIT_KBO", "status": "pending", "networkDiscovery": { "state": "pending", "attempts": 0 } }
]
}Country requirements
Each country has its own identifier scheme, and some add a tax identifier for your customer. Read the guide for every country you onboard customers in:
- BelgiumScheme 0208 with the 10-digit BCE/KBO number; BE:VAT is added when VAT registration is confirmed.
- EstoniaScheme 9931 with the VAT number (EE + 9 digits) for a VAT-registered company, verified against VIES; scheme 0191 with the registry code otherwise, verified against the e-Business Register.
- FranceTwo identifiers in order: the SIREN (0002), then the annuaire address (0225).
- GermanyScheme 9930 with the USt-IdNr (DE + 9 digits); German-to-German invoices must carry the seller contact in from.
- IcelandScheme 0196 with the kennitala, first; verified against Skatturinn. No VAT number on this endpoint, so a sub-tenant sends outside the scope of VAT only.
- NorwayScheme 0192 only; the MVA number is added for VAT-registered companies, read vatRegistration before sending.
- SwedenScheme 0007 with the 10-digit organisation number; SE:VAT is added once confirmed against VIES.
List the customers you manage, newest first. Results are isolated to your platform account. When offset exceeds the total, data is empty and has_more is false.
Your identifier, not ours. Two ids name every customer: the externalId you chose, and the getpeppr id we returned when you created them. Your own code almost certainly holds the first one, so this endpoint accepts it: GET /v1/legal-entities?externalId=your-ref returns that customer, and data[0].id is our id for them. You do not need to have stored it, and you never need to page through your whole portfolio to find one.
The other read, GET /v1/legal-entities/:id, takes our id only — passing one of your own references there returns 404. Sending works the same way: sender.legalEntityId takes our id, and your own reference goes in sender.externalSubTenantId. A reference in the wrong field answers 404 with a message that names both. The match on externalId is exact: no prefix search, no case folding, no trimming. A reference you never used, or one belonging to a customer you have archived, gives an empty data with total_count: 0 — that is an answer, not an error, and it says nothing about whether another platform uses the same string. A reference that is empty, longer than 64 characters, or supplied twice is a 400: a malformed reference is refused rather than answered with an empty list, which would read as “no such customer”.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
externalId | string | Optional | Return only the customer you registered under this reference of yours (1–64 chars). Matched exactly — no prefix search, no case folding, no trimming. Unknown or archived yields an empty data array; empty, over 64 chars, or repeated is a 400. |
limit | number | Optional | Max results (default 50, max 100). |
offset | number | Optional | Pagination offset (default 0, max 100000). |
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_your_master_key" });
// One page
const page = await peppol.legalEntities.list({ limit: 20, offset: 0 });
console.log(page.data, page.meta.hasMore);
// Find ONE customer by your own reference — no need to have stored our id
const { data } = await peppol.legalEntities.list({
externalId: "customer_be_001",
});
const customer = data[0]; // undefined if you have no live customer under it
console.log(customer?.id);
// …or iterate every customer — pagination handled for you
for await (const c of peppol.legalEntities.listAll()) {
console.log(c.externalId, c.status, c.networkDiscovery.state);
}{
"data": [
{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"companyName": "Your Customer SRL",
"country": "BE",
"identifier": { "scheme": "0208", "value": "YOUR_10_DIGIT_KBO" },
"status": "active",
"networkDiscovery": {
"state": "verified",
"attempts": 0,
"checkedAt": "2026-06-01T10:02:00.000Z",
"nextAttemptAt": "2026-06-02T10:02:00.000Z"
},
"environment": "production",
"createdAt": "2026-06-01T10:00:00.000Z",
"identifiers": [
{ "scheme": "0208", "value": "YOUR_10_DIGIT_KBO", "status": "active", "networkDiscovery": { "state": "verified", "attempts": 0, "checkedAt": "2026-06-01T10:02:00.000Z", "nextAttemptAt": "2026-06-02T10:02:00.000Z" } }
]
}
],
"pagination": { "total_count": 42, "offset": 0, "limit": 20, "has_more": true }
}{
"data": [
{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"companyName": "Your Customer SRL",
"country": "BE",
"identifier": { "scheme": "0208", "value": "YOUR_10_DIGIT_KBO" },
"status": "active",
"networkDiscovery": {
"state": "verified",
"attempts": 0,
"checkedAt": "2026-06-01T10:02:00.000Z",
"nextAttemptAt": "2026-06-02T10:02:00.000Z"
},
"environment": "production",
"createdAt": "2026-06-01T10:00:00.000Z",
"identifiers": [
{ "scheme": "0208", "value": "YOUR_10_DIGIT_KBO", "status": "active", "networkDiscovery": { "state": "verified", "attempts": 0, "checkedAt": "2026-06-01T10:02:00.000Z", "nextAttemptAt": "2026-06-02T10:02:00.000Z" } }
]
}
],
"pagination": { "total_count": 1, "offset": 0, "limit": 50, "has_more": false }
}Retrieve a single customer by their getpeppr id. On production this surfaces the full attestation lifecycle status (see the status reference); in sandbox it returns the verification status. When status is registration_failed, or provisioning_failed with a recorded cause, registrationDetail.reason is one of already_registered, invalid_format, or provider_error. When status is awaiting_release (production: another Peppol access point still holds the address), registrationDetail also carries heldBy — that access point's SMP host, when known — and waitingUntil, the end of getpeppr's 30-day wait. The same additive field appears in LIST results and is absent for every other status. Unknown, unowned and archived ids all return 404 with legal_entities.not_found — deliberately indistinguishable, so no caller can probe another platform's entities. An id that is not in the getpeppr id format also returns 404, but with a different result code, request.resource_id_malformed: pass the id exactly as we returned it, not your own customer reference. A well-formed id we never issued is not in that branch — it answers legal_entities.not_found like any other id we hold nothing for.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_your_master_key" });
const customer = await peppol.legalEntities.get(
"7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
);
console.log(customer.status, customer.networkDiscovery.state);
// "active", "verified" once cross-AP receive readiness is proven{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"companyName": "Your Customer SRL",
"country": "BE",
"identifier": { "scheme": "0208", "value": "YOUR_10_DIGIT_KBO" },
"status": "active",
"networkDiscovery": {
"state": "verified",
"attempts": 0,
"checkedAt": "2026-06-01T10:02:00.000Z",
"nextAttemptAt": "2026-06-02T10:02:00.000Z"
},
"environment": "production",
"createdAt": "2026-06-01T10:00:00.000Z",
"identifiers": [
{ "scheme": "0208", "value": "YOUR_10_DIGIT_KBO", "status": "active", "networkDiscovery": { "state": "verified", "attempts": 0, "checkedAt": "2026-06-01T10:02:00.000Z", "nextAttemptAt": "2026-06-02T10:02:00.000Z" } }
]
}{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"companyName": "Your Customer SRL",
"country": "BE",
"identifier": { "scheme": "0208", "value": "YOUR_10_DIGIT_KBO" },
"status": "registration_failed",
"registrationDetail": { "reason": "already_registered" },
"networkDiscovery": { "state": "pending", "attempts": 0 },
"environment": "sandbox",
"createdAt": "2026-06-01T10:00:00.000Z",
"identifiers": [
{ "scheme": "0208", "value": "YOUR_10_DIGIT_KBO", "status": "registration_failed", "registrationDetail": { "reason": "already_registered" }, "networkDiscovery": { "state": "pending", "attempts": 0 } }
]
}Off-board a customer. The legal entity is archived: the customer drops out of active lists and can no longer send, while audit records, attestations, and history are retained for compliance. Returns 200 with the archived entity. You can also archive a customer from the console (Legal Entities → the row's Archive action), which applies the same retention rules — production archives require typing the customer reference to confirm.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_your_master_key" });
const result = await peppol.legalEntities.archive(
"7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
);
console.log(result.status); // "archived"{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"status": "archived"
}Request (or resend) the customer's authorisation. getpeppr emails a co-branded, getpeppr-signed attestation link to the contact; once they confirm, the customer advances toward active. Production only — calling this with a sandbox key returns 400.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
contactEmail | string | Required | Email of the person who will authorise invoicing for this customer (max 254 chars). |
contactName | string | Optional | Display name shown in the authorisation email (max 128 chars). |
language | string | Optional | Language of the email, the confirmation page and the wording confirmed, as a BCP 47 tag (e.g. en). Omit it for English. Matched without regard to case, never approximated: nl or en-GB is refused, not mapped to a neighbour. |
409 if the customer isn't verified yet or has already attested — except after a failed registration (provisioning_failed): the same call then answers 202 with attestation.registration_rearmed and status: "provisioning" (no expiresAt, since no link is sent) and retries the registration for up to 30 days (a definitive refusal by the provider ends it earlier), with no new email and no new confirmation. The body is validated as for a first request, so still send contactEmail. A re-arm acknowledges the failure: if its legal_entity.registration_failed event had not been queued for delivery yet, it is not sent. An event already queued may still arrive after the 202; compare its occurredAt with the time of your re-arm. It returns 422 with code: "platform_billing_suspended" while your platform billing is suspended (no link is created and no email is sent),
and 502 if the email couldn't be sent (retry to mint a fresh link). Issuing an
attestation emits a legal_entity.awaiting_authz webhook.
awaiting_release and getpeppr sends legal_entity.awaiting_release (an endpoint that lists event names explicitly must add it; one subscribed to * receives it).
getpeppr then checks the network every minute and registers the address as soon as it is free. France (0225) is not covered by this automatic wait.
Reply-To).
The branding is fixed when the request is issued: a pending request keeps the branding it was sent with, and a resend applies your current branding and invalidates the previous link.
language) and whether you chose it (languageSource: explicit) or it defaulted to English (default).
A translation is used on production only once a native reader has approved its wording; the table below says which languages production uses today.
A language that is not available returns 400 with the code attestation.language_unsupported and a supportedLanguages list — the request is never sent in another language instead.
The confirmation page lets your customer switch to another available language for the same wording, and records the language they confirmed in.
A resend may use a different language.
Supported languages
| Language | language | Production |
|---|---|---|
| English (default) | en | Available |
| Dutch (Belgium) | nl-BE | Available |
| French (Belgium) | fr-BE | Available |
Need another language? We assess and add languages on request: write to hello@getpeppr.dev. A new language goes through the same native review before production uses it.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_your_master_key" });
const attestation = await peppol.legalEntities.requestAttestation(
"7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
{
contactEmail: "owner@customer.example",
contactName: "Customer Owner",
language: "en",
},
);
console.log(attestation.status, attestation.language); // "awaiting_authz", "en"// 202 Accepted — co-branded email sent to the customer
{
"id": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalId": "customer_be_001",
"status": "awaiting_authz",
"expiresAt": "2026-06-08T10:00:00.000Z",
"language": "en",
"languageSource": "explicit"
}