Skip to main content

Document Preparation

Guidelines for creating high-quality Peppol documents that pass validation.

Invoice Creation Checklist​

Required Elements​

Every Peppol BIS 3.0 invoice must include:

ElementDescriptionExample
IDUnique invoice numberINV-2024-00001
IssueDateInvoice date2024-01-15
InvoiceTypeCodeDocument type380 (invoice)
DocumentCurrencyCodeCurrencyEUR, USD, GBP
AccountingSupplierPartySeller detailsName, address, VAT
AccountingCustomerPartyBuyer detailsName, address, ID
TaxTotalTax summaryAmount, breakdown
LegalMonetaryTotalTotalsSubtotal, tax, payable
InvoiceLineLine itemsAt least one line

Pre-Send Validation​

def prepare_invoice(data: dict) -> str:
"""Build and validate invoice before sending."""

# 1. Build UBL XML
invoice_xml = build_ubl_invoice(data)

# 2. Local validation (fast)
local_errors = validate_locally(invoice_xml)
if local_errors:
raise ValidationError(local_errors)

# 3. API validation (comprehensive)
response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/validate",
headers={"X-API-Key": api_key},
data=invoice_xml
)

result = response.json()
if not result["valid"]:
raise ValidationError(result["errors"])

# 4. Check for warnings
if result.get("warnings"):
log_warnings(result["warnings"])

return invoice_xml

Data Quality​

Clean Text Fields​

import re

def clean_text(text: str, max_length: int = 100) -> str:
"""Sanitize text for XML."""
if not text:
return ""

# Remove control characters
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)

# Trim whitespace
text = text.strip()

# Truncate if needed
if len(text) > max_length:
text = text[:max_length-3] + "..."

return text

Validate Identifiers​

def validate_peppol_id(scheme: str, identifier: str) -> bool:
"""Validate Peppol participant identifier."""

validators = {
"0106": validate_kvk, # Netherlands
"0190": validate_oin, # Netherlands gov
"0192": validate_no_org, # Norway
"0195": validate_uen, # Singapore
"0204": validate_leitweg, # Germany gov
"0208": validate_cbe, # Belgium
"9906": validate_it_vat, # Italy
"9930": validate_de_vat, # Germany
}

validator = validators.get(scheme)
if validator:
return validator(identifier)

# Generic validation for unknown schemes
return bool(identifier and len(identifier) <= 50)

Amount Handling​

from decimal import Decimal, ROUND_HALF_UP

def format_amount(value: float, currency: str = "EUR") -> str:
"""Format amount for Peppol invoice."""

# Convert to Decimal for precision
d = Decimal(str(value))

# Round to 2 decimal places
rounded = d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

return str(rounded)

def calculate_line_totals(lines: list) -> dict:
"""Calculate invoice totals from lines."""

line_extension = Decimal("0")
tax_exclusive = Decimal("0")
tax_total = Decimal("0")

for line in lines:
quantity = Decimal(str(line["quantity"]))
price = Decimal(str(line["unit_price"]))
tax_rate = Decimal(str(line.get("tax_rate", 0)))

line_amount = (quantity * price).quantize(Decimal("0.01"))
line_tax = (line_amount * tax_rate / 100).quantize(Decimal("0.01"))

line["amount"] = str(line_amount)
line["tax_amount"] = str(line_tax)

line_extension += line_amount
tax_total += line_tax

tax_exclusive = line_extension
tax_inclusive = tax_exclusive + tax_total

return {
"line_extension": str(line_extension),
"tax_exclusive": str(tax_exclusive),
"tax_total": str(tax_total),
"tax_inclusive": str(tax_inclusive),
"payable": str(tax_inclusive)
}

Common Pitfalls​

1. Mismatched Totals​

Problem: Line totals don't sum to invoice total.

# Wrong - floating point errors
total = 10.10 + 10.20 + 10.30 # Might not equal 30.60!

# Right - use Decimal
from decimal import Decimal
total = Decimal("10.10") + Decimal("10.20") + Decimal("10.30")

2. Invalid Characters​

Problem: Special characters break XML.

# Wrong
name = "Johnson & Johnson" # & is invalid in XML

# Right
from xml.sax.saxutils import escape
name = escape("Johnson & Johnson") # "Johnson &amp; Johnson"

3. Wrong Date Format​

Problem: Incorrect date format.

# Wrong
issue_date = "15/01/2024" # European format
issue_date = "01-15-2024" # US format

# Right - ISO 8601
issue_date = "2024-01-15" # YYYY-MM-DD

4. Missing Buyer Reference​

Problem: B2G invoices rejected without buyer reference.

def build_invoice(data: dict) -> str:
invoice = InvoiceBuilder()

# Always include for B2G
if data.get("is_government") or data.get("buyer_country") in ["DE", "FR"]:
if not data.get("buyer_reference"):
raise ValueError("Buyer reference required for B2G invoices")
invoice.set_buyer_reference(data["buyer_reference"])

return invoice.build()

5. Currency Mismatch​

Problem: Line currency differs from document currency.

# All amounts must use document currency
document_currency = "EUR"

for line in lines:
line_currency = line.get("currency", document_currency)
if line_currency != document_currency:
raise ValueError(
f"Line currency {line_currency} must match "
f"document currency {document_currency}"
)

UBL Structure​

Invoice Template​

<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">

<!-- REQUIRED: Peppol BIS 3.0 Customization -->
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>

<!-- REQUIRED: Invoice identification -->
<cbc:ID>INVOICE-NUMBER</cbc:ID>
<cbc:IssueDate>YYYY-MM-DD</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>

<!-- CONDITIONAL: Required for B2G -->
<cbc:BuyerReference>REFERENCE</cbc:BuyerReference>

<!-- REQUIRED: Seller -->
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="SCHEME">IDENTIFIER</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Seller Name</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>12345</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>XX</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>VAT-NUMBER</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Legal Name</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>

<!-- REQUIRED: Buyer -->
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="SCHEME">IDENTIFIER</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Buyer Name</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>12345</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>XX</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Legal Name</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>

<!-- RECOMMENDED: Payment terms -->
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>IBAN</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>

<!-- REQUIRED: Tax totals -->
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">0.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>

<!-- REQUIRED: Monetary totals -->
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">0.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">0.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">0.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">0.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>

<!-- REQUIRED: At least one line -->
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">0.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Item Description</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">0.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>

Unit Codes​

Common UN/ECE Rec 20 unit codes:

CodeDescription
C62Each (piece)
HURHour
DAYDay
MONMonth
KGMKilogram
MTRMeter
LTRLiter
PCEPiece
EAEach

Next Steps​