Skip to content

Send an Invoice

Send invoices to the Peppol network with attachments, allowances, charges, and delivery details.

POST/v1/invoices#

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.

Your seller VAT / company identifier is configured once at the account level on the Peppol identity page — not via the per-invoice from field. Sending without one returns a 422 with a hint pointing you there; production standard-key sends use peppol_identity_incomplete.
Testing in sandbox? The examples on this page show a production send to a real buyer. Sandbox delivers only to test recipients — use 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.

Use the sender-specific sandbox profile. Read 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.

Sending is asynchronous. For a short window after the call returns, 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

NameTypeRequiredDescription
numberstringRequiredUnique invoice number (e.g. INV-2026-042)
toBuyerPartyRequiredBuyer details: name, peppolId, street, city, postalCode, country
linesInvoiceLine[]RequiredLine items with description, quantity, unitPrice, vatRate
fromPartyOptionalSeller 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.
datestringOptionalISO 8601 invoice date (defaults to today)
dueDatestringOptionalISO 8601 payment due date
currencystringOptionalISO 4217 currency code (default: "EUR")
paymentTermsstringOptionalPayment terms description
paymentIbanstringOptionalIBAN for bank transfer. Required with paymentMeans 30 or 58 (rule BR-61)
attachmentsAttachment[]OptionalEmbedded file attachments (base64 content, mimeType, filename)
allowancesAllowanceCharge[]OptionalDocument-level discounts
chargesAllowanceCharge[]OptionalDocument-level surcharges
deliveryDeliveryOptionalDelivery date and address
invoicePeriodInvoicePeriodOptionalBilling period (start/end dates)
notestringOptionalFree-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.

Attachments travel inside the request body, which is capped at 1 MB in total — base64 content, the rest of the invoice and the JSON around it included. There is no link alternative for a larger file: a request above the cap is refused with 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.

Allowances and charges follow BIS 3.0 business groups BG-20 (document allowances), BG-21 (document charges), BG-27 (line allowances), and BG-28 (line charges). See Type Definitions for the full interface.
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.

For SaaS and subscription billing, always include 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.

HTTP 422
{
  "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.

HTTP 422
{
  "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.

HTTP 429 (seconds to wait are in Retry-After)
{
  "error": "Rate limit exceeded. Try again later."
}
The SDK exposes 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.