Send an Invoice
Send invoices to the Peppol network with attachments, allowances, charges, and delivery details.
Send a Peppol invoice. The API validates the payload against BIS 3.0 business rules, generates UBL XML, and delivers it to the buyer's access point.
Parties
Your API key determines the seller/legal entity. The payload must include to (buyer) with name, peppolId, street, city, postalCode, and country. The seller identity comes from that legal entity, so do not use from to change it. What from does carry is the seller contact — contactName, phone and email (BG-6) — which a legal entity has no field for. Domestic German invoices require all three, and are refused with 422 (DE-R-002) without them.
from field. Sending without one returns a 422 with a hint pointing you there; production standard-key sends use peppol_identity_incomplete.
9925:BE0314595348 (SPF Economie, the standard Peppol test receiver)
as the to.peppolId. See the sandbox guide.
Line Items
Each line needs description, quantity, unitPrice, and vatRate. An optional vatCategory sets the UBL tax category (defaults to standard VAT). Amounts are calculated automatically — no need for manual totals.
vatCategory accepts "S" (standard), "Z" (zero-rated), "E" (exempt), "AE" (reverse charge), "K" (intra-community), "G" (export) and "O" (outside scope). Codes are case-sensitive: "AE" is reverse charge, "ae" is rejected. Omit the field to take the default — a value you supply is never silently corrected.
GET /v1/identity and copy sandboxFirstSend.line onto each line. It returns either O/0 for a routing-only sender or AE/0 for a sender carrying a tax identifier.
The CLI quickstart performs this preflight automatically and blocks before
submission when the identity is not ready — including national_identifier_required, when
the sender's country has an identity rule the account does not meet yet — a KVK or OIN in the Netherlands (NL-R-003), a CVR in Denmark (DK-R-002), an organisationsnummer in Sweden (SE-R-003), the organisation number and MVA number pair in Norway, or the kennitala as the first identifier in Iceland (IS-R-002).
Payment
Add paymentTerms and paymentIban to include payment instructions. Strongly recommended for Belgian and French mandates.
GET /v1/invoices/{id}/as/original answers 404 on a document that is perfectly healthy — the stored copy is
still being registered. Fetching it synchronously right after a send
therefore looks like a failure; poll rather than assuming one retry is
enough, since no bound on that window is guaranteed. It does not wait for delivery:
the document is downloadable well before
the receiving access point has signed for it. To know that delivery
actually happened, read the status from GET /v1/invoices/{id} — available immediately — or wait for the invoice.sent webhook.
A PDF rendering is optional evidence: when /as/pdf has no PDF
to return, it answers 404 with invoices.export_format_unavailable and
lists the availableMimeTypes. It never substitutes the UBL XML
for the requested PDF, and a 200 never carries a body of
another media type: an upstream response it cannot read, an empty stored
document, or one served under a contradicting media type answers a
retryable 502 with provider.unavailable. The two formats that name an XML schema are
checked against the business document before being served: /as/xml.ubl.invoice.bis3 resolves
only when it is UBL and /as/xml.facturae.3.2 only when it is Facturae, otherwise
the same 404. ⛔ That check reads through the Peppol SBDH envelope and does
not remove it — original and xml.ubl.invoice.bis3 return the envelope,
rooted on <sh:StandardBusinessDocument>. Request payload for the
bare business document, which is the one to validate against a schema. And since the Peppol
network carries UBL, /as/xml.facturae.3.2 normally has nothing to return: expect
its 404, and reach for payload or original instead.
Main parameters
| Name | Type | Required | Description |
|---|---|---|---|
number | string | Required | Unique invoice number (e.g. INV-2026-042) |
to | BuyerParty | Required | Buyer details: name, peppolId, street, city, postalCode, country |
lines | InvoiceLine[] | Required | Line items with description, quantity, unitPrice, vatRate |
from | Party | Optional | Seller contact for this document: contactName, phone and email (BG-6). The seller identity comes from your registered legal entity — do not use from to change it. Domestic German invoices require all three contact fields. |
date | string | Optional | ISO 8601 invoice date (defaults to today) |
dueDate | string | Optional | ISO 8601 payment due date |
currency | string | Optional | ISO 4217 currency code (default: "EUR") |
paymentTerms | string | Optional | Payment terms description |
paymentIban | string | Optional | IBAN for bank transfer. Required with paymentMeans 30 or 58 (rule BR-61) |
attachments | Attachment[] | Optional | Embedded file attachments (base64 content, mimeType, filename) |
allowances | AllowanceCharge[] | Optional | Document-level discounts |
charges | AllowanceCharge[] | Optional | Document-level surcharges |
delivery | Delivery | Optional | Delivery date and address |
invoicePeriod | InvoicePeriod | Optional | Billing period (start/end dates) |
note | string | Optional | Free-text note for the buyer |
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_..." });
const result = await peppol.invoices.send({
number: "INV-2026-042",
buyerReference: "PO-2026-007",
// Buyer
to: {
name: "ACMEDIA",
peppolId: "0208:0685660237",
street: "Avenue Louise 54",
city: "Brussels",
postalCode: "1050",
country: "BE",
},
// Line items
lines: [
{ description: "Arc Reactor Maintenance Q1", quantity: 1, unitPrice: 50_000, vatRate: 21 },
{ description: "Vibranium Shield Polish", quantity: 3, unitPrice: 250, vatRate: 21 },
],
// Payment
paymentTerms: "Net 30 days",
paymentIban: "BE68539007547034",
// Optional
date: "2026-03-01",
dueDate: "2026-03-31",
note: "Thank you for your business!",
});
console.log(`Sent! ID: ${result.id}, Status: ${result.status}`);Attachments
Attach supporting documents to your invoices — PDF copies, timesheets, contracts, or any file. A Peppol document carries the file itself, so every attachment is embedded.
Embedded
Provide filename, mimeType, and raw base64-encoded content without a data URI prefix. The file is included directly in the UBL XML.
No links
Sending refuses any attachment with a non-empty url, including one that also has content: POST /v1/invoices answers with status 422 and the result code invoices.attachment_url_not_supported. Our provider has no field for a link, so the URL would otherwise be dropped in silence. Only the local UBL builder reads the url field.
413.
Server-side validation (POST /validate/server) also rejects embedded content above 2 MB decoded per attachment.
import { Peppol } from "@getpeppr/sdk";
import { readFileSync } from "fs";
const peppol = new Peppol({ apiKey: "sk_live_..." });
const result = await peppol.invoices.send({
number: "INV-2026-043",
to: { name: "ACMEDIA", peppolId: "0208:0685660237", street: "Rue de la Loi 200", city: "Brussels", postalCode: "1000", country: "BE" },
lines: [
{ description: "Consulting Q1", quantity: 40, unitPrice: 125, vatRate: 21 },
],
// Each file travels inside the document: the whole request must stay under 1 MB
attachments: [
{
id: "ATT-001",
filename: "invoice-2026-043.pdf",
mimeType: "application/pdf",
content: readFileSync("./invoice.pdf").toString("base64"),
},
{
id: "ATT-002",
description: "Detailed timesheet",
filename: "timesheet-q1.pdf",
mimeType: "application/pdf",
content: readFileSync("./timesheet-q1.pdf").toString("base64"),
},
],
});Allowances & Charges
Apply discounts (allowances) and surcharges to invoices at both the document level and line level.
Document-level
Use allowances for discounts and charges for surcharges that apply to the entire invoice. Each requires reason, amount, and vatRate.
Line-level
Add allowances or charges to individual line items. Line-level adjustments only need reason and amount.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_..." });
const result = await peppol.invoices.send({
number: "INV-2026-044",
to: { name: "ACMEDIA", peppolId: "0208:0685660237", street: "Rue de la Loi 200", city: "Brussels", postalCode: "1000", country: "BE" },
lines: [
{
description: "Consulting hours",
quantity: 40,
unitPrice: 125,
vatRate: 21,
// Line-level discount
allowances: [{ reason: "Loyalty discount", amount: 200 }],
},
],
// Document-level discount (applies to entire invoice)
allowances: [
{ reason: "Early payment discount (2%)", amount: 100, vatRate: 21 },
],
// Document-level surcharge
charges: [
{ reason: "Express delivery fee", amount: 50, vatRate: 21 },
],
});Delivery & Period
Specify delivery details and billing periods for invoices that cover goods delivery or service periods.
Delivery
Add a delivery object with an optional date and address. Some EU countries (e.g. Italy, Spain) require delivery information on invoices.
Invoice Period
Use invoicePeriod with startDate and endDate for subscriptions, retainers, or any service billed over a time range.
invoicePeriod —
it's required by some national rules and improves reconciliation for your buyers.
import { Peppol } from "@getpeppr/sdk";
const peppol = new Peppol({ apiKey: "sk_live_..." });
const result = await peppol.invoices.send({
number: "INV-2026-045",
to: { name: "ACMEDIA", peppolId: "0208:0685660237", street: "Rue de la Loi 200", city: "Brussels", postalCode: "1000", country: "BE" },
lines: [
{ description: "Server hardware", quantity: 10, unitPrice: 2500, vatRate: 21 },
],
// Delivery details (mandatory in some EU countries)
delivery: {
date: "2026-03-15",
address: {
street: "Rue de la Science 14",
city: "Brussels",
postalCode: "1000",
country: "BE",
},
},
// Billing period (common for SaaS / subscriptions)
invoicePeriod: {
startDate: "2026-01-01",
endDate: "2026-03-31",
},
});Common Errors
Most failures on this endpoint fall into one of these categories. The API returns structured JSON with a stable error field and (where available) a machine-readable code for client-side branching. See Error Handling for the full reference.
Validation failed (422)
One or more fields failed Peppol BIS 3.0 validation. Common causes: missing buyer address (BR-10/BR-11), a VAT percentage the provider does not accept for the seller's country (invalid_provider_tax_rate), malformed Peppol ID, or unknown currency code.
{
"error": "Buyer address is required: street, city, and postalCode must be provided in the 'to' object"
}Recipient not on the Peppol network (422)
Returned when x-validate-recipient: strict is set and the recipient's Peppol ID is not registered. Verify with GET /v1/directory/{scheme}/{id} first.
{
"error": "Recipient 0208:0685660237 not found in Peppol Directory. The participant may not be registered on the Peppol network."
}Rate limited (429)
Your per-key limit follows your PLAN, not the key's environment: 10 req/min on Sandbox, 60 on Starter, 120 on Pro, 300 on Business, and 300 on a live Platform contract. A paid account therefore gets its own limit on its sandbox keys too. The response includes a Retry-After header with the seconds to wait. The SDK retries automatically with exponential backoff.
{
"error": "Rate limit exceeded. Try again later."
}PeppolValidationError (carries the structured validation object with field paths and suggestions), PeppolProtocolError (a 2xx body missing a field the contract requires, since SDK 4.0.0), and PeppolApiError (carries statusCode and retryAfterMs). All three extend PeppolError; see Error Handling for the one failure that sits outside the hierarchy. Check err instanceof to
branch reliably on error type.