Your first sandbox test
Create a test customer, watch it settle, and send an invoice on its behalf — free, no call, no agreement.
What this proves — and what it doesn't
Sandbox is a real integration against real registries, on the Peppol test network. It is honest about its limits, so you know what still needs proving before you go live.
| Sandbox proves | Sandbox does not prove |
|---|---|
| Your full integration — create customers over the API, receive lifecycle webhooks, send on a customer's behalf | Real Peppol delivery. Documents travel the test network only |
| Identity verification against the real business registry — KBO/CBE, INSEE, Bolagsverket, VIES, DanskCVRAPI and Overheid.io — the same engine production uses | The customer attestation step. Sandbox never emails your customers; attestation exists only in production |
The sandbox lifecycle: pending, verifying, verified, no_registry, unsupported_scheme, verification_failed, registration_failed, archived | The attestation states. Production adds six more that never occur here — see Sub-tenant Lifecycle |
Your customers are never involved and never contacted. Sandbox platform accounts are capped at 10 active test customers; archived ones no longer count.
Before you start
POST /v1/legal-entities answers 403 master_key_required. Production platform access is set up with our team.- 1. A console account — sign up at console.getpeppr.dev. Every account starts in sandbox.
- 2. The platform sandbox trial — chosen at signup, or started later by an organisation admin from the console overview. Nothing below works before that.
- 3. A master key — only once the trial is on, and starting it does not create the key for you: an organisation admin opens API Keys → Create master key in the console and picks
sandbox. The secret is shown once, so copy it then. A standard key returns403 master_key_requiredon the endpoints below. - 4. The SDK — Node.js 18+ and
npm install @getpeppr/sdk(≥ 2.6.0). - 5. A test identity — scheme
9915and a value you pick yourself. The Peppol code list publishes9915as having no entity behind it, so there is no registry to satisfy and no real company to borrow. That is the default route, and the next section explains it. Using a real registered company instead is supported, but it must still be active at its registry.
Choosing a test identity
Verification runs against real business registries, in sandbox exactly as in production. That leaves you two honest options, and one that does not work.
Option A — scheme 9915 (recommended)
9915 is published in the Peppol code list with the note “No entity behind id”. Nothing is registered behind it by design, so there is no registry to query and no one else's tax number involved. It is what the ecosystem uses for test participants, DG DIGIT included. The value is yours to choose: an uppercase letter followed by uppercase letters and digits.
const customer = await peppol.legalEntities.create({
// Same externalId used at Step 3 below.
externalId: "customer_be_001",
companyName: "Acme Test Ltd",
country: "GB",
address: { line1: "1 Test Street", city: "London", zip: "EC1A 1BB" },
identifier: {
scheme: "9915",
// Uppercase letters and digits only, starting with a letter — so drop
// the dash: account PEP-85848 becomes ACME85848TEST.
value: "ACME85848TEST",
},
});
// 202 straight away — verification is asynchronous.
// Once it settles: status "no_registry", and ready to send.Verification settles on its own, usually within seconds — it runs in the background, and a five-minute sweep picks up anything that did not start. The status you will then read is no_registry, not verified: it is its own word on purpose, because nothing was proven about an entity, and calling it verified would make the sandbox promise something production will refuse. Both statuses send in sandbox.
NL-R-003), a Danish one a CVR number (DK-R-002), and a Norwegian one both an organisation number and a Norwegian VAT number — and scheme 9915 is none of those. Such a sub-tenant verifies and becomes send-ready all the same, then its invoices are refused at send with a 422. For a test customer in any of the three, take Option B below.Option B — a real registered company
Use this when what you want to exercise is registry verification itself — the name comparison, the failure modes, the timing. Pick a company that is still active: when a registry reports one as struck off, in liquidation, or not yet active, we refuse it, and re-sending the same details will not change that until the registry changes its own answer. Copy the company name from the registry entry rather than typing it from memory; it is compared against what the registry holds.
Step 1 — Create a test customer
On scheme 9915, this is the call shown in Choosing a test identity and there is nothing else to prepare. The example below takes the other route, a real Belgian company — copy the company name from the registry entry rather than typing it from memory, since it is compared against what the registry holds.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_sandbox_your_master_key" });
const customer = await peppol.legalEntities.create({
// Your stable reference. Echoed as data.subTenantId on every
// Legal Entity lifecycle webhook, so you can map it straight back.
externalId: "customer_be_001",
companyName: "Exact Registered Legal Name",
country: "BE",
address: { line1: "Rue de la Loi 16", city: "Brussels", zip: "1000" },
identifier: { scheme: "0208", value: "0685660237" },
});
console.log(customer.id, customer.status); // "7c9a1b34-…", "pending"You get 202 immediately; verification runs in the background.
Re-sending the same externalId: an identical name and normalized identifier returns the existing record (200) without another provider create. Reusing that identifier under a different externalId returns 409 identifier_already_in_use; use the owned reference in existingLegalEntity instead. Changing the scheme or number on the original reference is refused with 422 identifier_immutable — a different identifier is a different company, so archive the entity and create it again. The externalId is free to reuse once archived.
The example is Belgian, the flow is not. For your own market, swap externalId, companyName, the legal address, country and identifier — then reuse that same externalId in sender.externalSubTenantId at Step 3. The name and address are registered as you send them, so they must match the register. The schemes we verify today, with the exact format each one accepts:
9915.| Country | scheme | What the value is |
|---|---|---|
| Belgium | 0208 | KBO/CBE enterprise number — exactly 10 digits |
| Denmark | 0184 | CVR number — exactly 8 digits |
| United Kingdom | GB:VAT | GB then 9 digits (or 12 for VAT groups). The GB prefix is required — e.g. GB123456789 |
| Ireland | 9935 | IE then 7 digits and 1-2 letters. The IE prefix is required — e.g. IE1234567T |
| France | 0002 | SIREN (9 digits) or SIRET (14 digits) |
| France | 0009 | SIRET — 14 digits only |
| France (CTC) | 0225 | SIREN — 9 digits only |
| Germany | 9930 | DE then 9 digits (USt-IdNr). The DE prefix is required — e.g. DE123456788 |
| Netherlands | 0106 | KVK number — exactly 8 digits, including any leading zero |
| Sweden | 0007 | Organisationsnummer — 10 digits |
| Testing (not NL, DK or NO) | 9915 | A value you choose: an uppercase letter, then uppercase letters and digits. No registry stands behind this scheme, so none is queried — see Choosing a test identity |
Two country specifics worth knowing before you start. Germany (9930): VIES confirms the VAT number is valid but masks the company name, so we cannot compare your declared name against the register — verification rests on the VAT number alone. United Kingdom (GB:VAT): the VAT number is the only UK identifier the Peppol network routes — a Companies House number is not a Peppol identifier and cannot receive anything. We cannot currently verify UK VAT numbers against HMRC automatically. Contact support to arrange identity verification before production sending.
A scheme we don't verify yet doesn't fail loudly — the customer simply sits at pending. If that happens, email us rather than waiting.
Step 2 — Watch it verify
Subscribe a webhook endpoint, or poll. Webhooks are the cheaper path.
const check = await peppol.legalEntities.get(customer.id);
// Two terminal statuses are ready to send in sandbox:
// "verified" — a registry confirmed the company (option B)
// "no_registry" — scheme 9915: there was no registry to ask (option A)
const ready = check.status === "verified" || check.status === "no_registry";legal_entity.* prefix wildcard. Subscribe to the five event names explicitly — legal_entity.registered, legal_entity.unsupported_scheme, legal_entity.verification_failed, legal_entity.awaiting_authz, legal_entity.registration_failed — or use the global wildcard *, which covers the lifecycle and invoice events. A couple of specialised event types are opt-in by name only; see Webhooks.If you poll, poll no more often than once every 10 seconds and honour Retry-After on 429: sandbox allows 10 requests per minute per key and 50 per minute per account, and this GET spends that budget.
The webhook arrives as legal_entity.registered, with data.subTenantId set to your externalId — map it straight to your customer record, no lookup needed.
Step 3 — Send on their behalf
The same POST /v1/invoices as single-tenant sending — you just add sender.
await peppol.invoices.send({
number: "INV-2026-001",
// Required by network rule R003. Storecove has always injected one
// when missing, but that is not contractual — set it yourself.
buyerReference: "INV-2026-001",
sender: { externalSubTenantId: "customer_be_001" },
to: {
// SPF Economie — the standard Peppol test receiver. Sandbox
// delivers ONLY to test recipients: send to a real company and
// the submission is accepted, then fails asynchronously.
name: "SPF Economie",
peppolId: "9925:BE0314595348",
// Buyer VAT is read from this field — never derived from peppolId.
vatNumber: "BE0314595348",
street: "Rue du Progrès 50",
city: "Brussels",
postalCode: "1000",
country: "BE",
},
lines: [
// The registryless 9915 test identity has no VAT identifier, so this
// diagnostic invoice is explicitly outside scope (O), not zero-rated (Z).
// For a real transaction, use the category the invoice actually requires.
{ description: "Sandbox platform test", quantity: 1, unitPrice: 90, vatRate: 0, vatCategory: "O" },
],
});The supplier identity — name, country, Peppol ID — is taken from the selected send-ready Legal Entity. Any from you pass is stripped, so you cannot mis-state the sender even by accident.
sender and the invoice goes out under your own identity.If something fails
| Symptom | What it means |
|---|---|
403 master_key_required | Standard key instead of a master key, or Platform mode isn't enabled yet |
401 Invalid API key on a key that used to work | Platform mode was switched off — master keys are revoked with it |
| The invoice sends, but under your identity | You used a standard key for Step 3. sender is ignored silently for standard keys |
name_mismatch | Copy the company name from the registry entry and re-send the same externalId. If it still fails, send us the externalId |
not_found | Check verificationDetail.registryStatus first. If it reads inactive, the company exists but the registry doesn't consider it active (struck off, in liquidation, or not yet active) — re-sending won't change that, so use a company that is trading today. If the field is absent we have no such finding to show, which is not the same as none existing: the registry may have no entry, may have been unreachable, our team may have reviewed an earlier finding, or there may simply be no inactive finding on record — a valid VAT registration can verify a company on its own. So confirm at the national registry that the company is trading, then check for a typo and that the scheme matches the number |
already_registered | Read it from registrationDetail.reason when status is registration_failed. The identifier already lives on another access point. This blocks Step 3 — pick a company that isn't registered elsewhere |
invalid_format / provider_error | These are the other stable registrationDetail.reason values. Fix the identifier format, or retry later for a provider error; raw provider text is never exposed |
409 identifier_already_in_use | The same normalized participant already belongs to another active externalId in your account. Reuse the returned existingLegalEntity; no provider create ran |
422 identifier_immutable | You changed the scheme or number on an existing externalId. Archive it, then recreate |
422 on send | Customer not send-ready, or a VAT-bearing invoice (including vatRate: 0) missing to.vatNumber |
429 | 10 requests per minute per key, 50 per account. Slow the polling and honour Retry-After |
Stuck for more than ten minutes? Email hello@getpeppr.dev with your externalId and what you expected. You'll get the founder, not a ticket queue.
When you move to production
Same API, same SDK, same send call. What changes:
- 1. A production master key, which requires an active platform contract — a sandbox key never reaches the live network.
- 2. Attestation. Each customer confirms, on a getpeppr-signed page, that they authorise you to invoice on their behalf. You trigger it with
POST /v1/legal-entities/:id/attestation; we email the co-branded link and keep the audit trail. It doesn't exist in sandbox. - 3. More statuses, around that authorisation flow. Don't hard-code a fixed sequence between them — read the current status and react to it. See Sub-tenant Lifecycle.
- 4. Real recipients. Sandbox only delivers to the test receiver above; production reaches your customers' actual buyers.
Commercially, production platform access is set up with us directly — plan, country scope, customer cap. Email us and we'll size it with you.