Skip to main content

Sending an Invoice

This guide covers the complete process of sending an invoice through the Peppol network using GoRoute.

Quick Startโ€‹

Sending is a JSON call. The UBL document travels as a string inside the JSON body โ€” you do not POST raw XML.

import requests

# 1. Read your invoice XML
with open("invoice.xml", "r") as f:
invoice_xml = f.read()

# 2. Send it
response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/documents",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={
"receiver_scheme": "9959",
"receiver_id": "987654321",
"document": invoice_xml,
},
)

# 3. Check the result โ€” a successful send returns 202 Accepted
result = response.json()
print(result["transaction_id"], result["status"])
Earlier revisions of this page documented an endpoint that does not exist

This page previously told you to POST raw XML with Content-Type: application/xml to a send path directly under /api/v1. There has never been such an endpoint, and code written against it has never worked. The endpoint is POST /api/v1/documents, the content type is application/json, and the XML goes in the document field of the JSON body.

Complete Workflowโ€‹

Step 1: Prepare the Invoiceโ€‹

Ensure your invoice meets Peppol requirements:

<?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 identifiers -->
<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>

<!-- Invoice details -->
<cbc:ID>INV-2024-00123</cbc:ID>
<cbc:IssueDate>2024-01-15</cbc:IssueDate>
<cbc:DueDate>2024-02-15</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>

<!-- Seller (your organization) -->
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0106">12345678</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Your Company BV</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Main Street 1</cbc:StreetName>
<cbc:CityName>Amsterdam</cbc:CityName>
<cbc:PostalZone>1012AB</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Your Company BV</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>

<!-- Buyer (receiver) -->
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0106">87654321</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Customer Company BV</cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Customer Company BV</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>

<!-- Payment terms -->
<cac:PaymentTerms>
<cbc:Note>Payment due within 30 days</cbc:Note>
</cac:PaymentTerms>

<!-- Tax totals -->
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">21.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">100.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">21.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>

<!-- Invoice totals -->
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">100.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">121.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">121.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>

<!-- Invoice lines -->
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="HUR">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Professional Consulting Services</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">10.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>

POST /api/v1/documents/validate takes the same JSON shape and validates without sending.

validation = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/documents/validate",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={"document": invoice_xml},
).json()

if not validation["valid"]:
print(f"Validation failed: {validation['error_count']} error(s)")
for issue in validation["issues"]:
print(f" [{issue['severity']}] {issue['code']}: {issue['message']}")
raise SystemExit(1)

print("Validation passed")

Each entry in issues has severity, code, message and optionally location and rule_id. The response also carries document_type, profile, error_count, warning_count, sender_id, receiver_id, document_id and issue_date.

Step 3: Send the Invoiceโ€‹

response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/documents",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
"Idempotency-Key": "invoice-2026-001",
},
json={
"receiver_scheme": "9959",
"receiver_id": "987654321",
"document": invoice_xml,
},
)

if response.status_code == 202:
result = response.json()
print("Accepted")
print(" Transaction ID:", result["transaction_id"])
print(" Status:", result["status"]) # always "queued"
print(" Created at:", result["created_at"])
else:
print("Sending failed:", response.json())

Request bodyโ€‹

POST /api/v1/documents requires the invoices:send permission.

FieldTypeRequiredMeaning
receiver_schemestringYesReceiver's Peppol scheme, e.g. 9959
receiver_idstringYesReceiver's Peppol identifier
documentstringYesThe UBL document as an XML string. Must not be empty.
sender_schemestringNoDefaults to your organisation's participant
sender_idstringNoDefaults to your organisation's participant
document_typestringNoDefaults to the Peppol BIS 3.0 Invoice document type identifier
process_idstringNoDefaults to urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
metadataobjectNoArbitrary key/values stored with the transaction
webhook_urlstringNoOverrides your webhook URL for this transaction only

Responseโ€‹

202 Accepted. The document is queued, not delivered โ€” delivery is asynchronous.

FieldTypeMeaning
transaction_idUUIDTrack delivery with this
statusstringqueued on a fresh send
messagestringDocument queued for delivery
created_atdatetimeWhen the transaction was created
idempotency_keystring or nullEchoed back if you sent one

Idempotencyโ€‹

Send an Idempotency-Key header to make retries safe. A replay returns the same transaction_id with the message Document already queued (idempotent replay) rather than sending twice.

Step 4: Track Deliveryโ€‹

import time

transaction_id = result["transaction_id"]

for attempt in range(10):
transaction = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}",
headers={"X-API-Key": "your_api_key"},
).json()

print("Status:", transaction["status"])

if transaction["status"] == "delivered":
print("Delivered")
break
if transaction["status"] == "failed":
print("Delivery failed")
break

time.sleep(2)

Send Optionsโ€‹

With metadataโ€‹

Attach your own reference data to a transaction using the metadata field of the body. There are no X-Reference-ID or X-Correlation-ID headers.

response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/documents",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={
"receiver_scheme": "9959",
"receiver_id": "987654321",
"document": invoice_xml,
"metadata": {
"invoice_number": "INV-2026-001",
"po_number": "PO-123",
},
},
)

Per-transaction webhookโ€‹

webhook_url overrides your organisation's configured webhook for this send only.

    json={
"receiver_scheme": "9959",
"receiver_id": "987654321",
"document": invoice_xml,
"webhook_url": "https://your-app.example.com/hooks/peppol",
},

Sending is always asynchronousโ€‹

There is no synchronous send mode

Earlier revisions of this page documented a sync=true query parameter that waits for delivery confirmation and returns 200 OK. No such parameter exists. Every send returns 202 Accepted with status queued, and the document is delivered afterwards.

To find out whether a document was delivered, either poll GET /api/v1/transactions/{transaction_id} as in Step 4, or subscribe to webhooks. Do not write code that waits on a synchronous response.

Response Codesโ€‹

Status CodeMeaning
202 AcceptedDocument queued for delivery. This is the success case.
400 Bad Requestvalidation_error / INVALID_REQUEST โ€” the body was rejected, e.g. an empty document
401 UnauthorizedInvalid or missing API key
403 ForbiddenThe API key lacks the invoices:send permission
429 Too Many RequestsRate limit exceeded
500 Server ErrorInternal error, including signing_error / SIGNING_FAILED for Oman documents

Delivery Statusโ€‹

A transaction moves through these states:

queued โ†’ submitted โ†’ accepted โ†’ delivered
โ†˜ failed
โ†˜ retrying โ†’ โ€ฆ
StatusDescription
queuedAccepted by the API, pending processing. This is what a send returns.
submittedHanded to the AS4 sender
acceptedAn AS4 SignalMessage was received from the receiving Access Point
deliveredFinal success
failedTerminal failure
retryingTemporary failure; GoRoute will retry
heldPrepared, stored and deliberately not transmitted โ€” used for tax reports owed before an authority's endpoint exists. Not a failure.

Complete Exampleโ€‹

import requests
import time


class GoRouteClient:
def __init__(self, api_key: str, base_url: str = "https://app.goroute.ai/peppol-api"):
self.api_key = api_key
self.base_url = base_url

@property
def _headers(self) -> dict:
return {
"X-API-Key": self.api_key,
"Content-Type": "application/json",
}

def send_invoice(
self,
xml: str,
receiver_scheme: str,
receiver_id: str,
idempotency_key: str | None = None,
wait_for_delivery: bool = False,
) -> dict:
"""Validate, then queue an invoice for delivery via Peppol."""
validation = self.validate(xml)
if not validation["valid"]:
raise ValueError(f"Validation failed: {validation['issues']}")

headers = dict(self._headers)
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key

response = requests.post(
f"{self.base_url}/api/v1/documents",
headers=headers,
json={
"receiver_scheme": receiver_scheme,
"receiver_id": receiver_id,
"document": xml,
},
)
response.raise_for_status() # 202 on success
result = response.json()

if wait_for_delivery:
return self._wait_for_delivery(result["transaction_id"])

return result

def validate(self, xml: str) -> dict:
response = requests.post(
f"{self.base_url}/api/v1/documents/validate",
headers=self._headers,
json={"document": xml},
)
response.raise_for_status()
return response.json()

def _wait_for_delivery(self, transaction_id: str, max_attempts: int = 30) -> dict:
"""Delivery is asynchronous โ€” there is no synchronous send. Poll for it."""
for _ in range(max_attempts):
status = self.get_transaction(transaction_id)
if status["status"] in ("delivered", "failed"):
return status
time.sleep(2)
raise TimeoutError("Delivery timeout")

def get_transaction(self, transaction_id: str) -> dict:
response = requests.get(
f"{self.base_url}/api/v1/transactions/{transaction_id}",
headers={"X-API-Key": self.api_key},
)
response.raise_for_status()
return response.json()


# Usage
client = GoRouteClient("your_api_key")

with open("invoice.xml") as f:
invoice_xml = f.read()

result = client.send_invoice(
invoice_xml,
receiver_scheme="9959",
receiver_id="987654321",
idempotency_key="INV-2026-001",
wait_for_delivery=True,
)
print(result["status"], result["transaction_id"])

Next Stepsโ€‹