Your first sandbox test
Create a test customer, watch it verify against a real business registry, 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 and VIES — 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, 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.- 1. A console account — sign up at console.getpeppr.dev. Every account starts in sandbox.
- 2. Platform mode — ask us to enable it. This is the one step that is not self-serve. Email us and we switch it on for your sandbox account the same working day. Nothing below works before that.
- 3. A master key — only once we've enabled platform mode, and enabling 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 company — a real registered company that is still active at its registry, because verification runs against the real registry even in sandbox. Fake numbers will (correctly) fail, and so will a company that has been dissolved, is in liquidation, or is not yet active — the registry answers, but it answers that the company isn't trading.
Step 1 — Create a test customer
Copy the company name from the registry entry rather than typing it from memory — 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 identifier returns the existing record (200). Changing the scheme or number 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:
| Country | scheme | What the value is |
|---|---|---|
| Belgium | 0208 | KBO/CBE enterprise number — exactly 10 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. DE123456789 |
| Sweden | 0007 | Organisationsnummer — 10 digits |
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 do not verify UK VAT numbers against HMRC automatically yet, so a UK registration is reviewed by hand before it can send.Tell us and we'll clear it.
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);
console.log(check.status); // "verified" once it is ready to sendlegal_entity.* prefix wildcard. Subscribe to the four event names explicitly — legal_entity.registered, 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: [
// vatCategory is spelled out on purpose: a 0 rate without it maps
// to "Z" (zero-rated) anyway, so make the intent explicit rather
// than rely on the default. Not tax advice — use the category your
// own invoice actually calls for.
{ description: "Consultation", quantity: 1, unitPrice: 90, vatRate: 0, vatCategory: "Z" },
],
});The supplier identity — name, country, Peppol ID — is taken from the customer's verified 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 | The identifier already lives on another access point. This blocks Step 3 — pick a company that isn't registered elsewhere |
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.