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"])
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>
Step 2: Validate (Optional but Recommended)โ
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.
| Field | Type | Required | Meaning |
|---|---|---|---|
receiver_scheme | string | Yes | Receiver's Peppol scheme, e.g. 9959 |
receiver_id | string | Yes | Receiver's Peppol identifier |
document | string | Yes | The UBL document as an XML string. Must not be empty. |
sender_scheme | string | No | Defaults to your organisation's participant |
sender_id | string | No | Defaults to your organisation's participant |
document_type | string | No | Defaults to the Peppol BIS 3.0 Invoice document type identifier |
process_id | string | No | Defaults to urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 |
metadata | object | No | Arbitrary key/values stored with the transaction |
webhook_url | string | No | Overrides your webhook URL for this transaction only |
Responseโ
202 Accepted. The document is queued, not delivered โ delivery is asynchronous.
| Field | Type | Meaning |
|---|---|---|
transaction_id | UUID | Track delivery with this |
status | string | queued on a fresh send |
message | string | Document queued for delivery |
created_at | datetime | When the transaction was created |
idempotency_key | string or null | Echoed 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โ
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 Code | Meaning |
|---|---|
202 Accepted | Document queued for delivery. This is the success case. |
400 Bad Request | validation_error / INVALID_REQUEST โ the body was rejected, e.g. an empty document |
401 Unauthorized | Invalid or missing API key |
403 Forbidden | The API key lacks the invoices:send permission |
429 Too Many Requests | Rate limit exceeded |
500 Server Error | Internal error, including signing_error / SIGNING_FAILED for Oman documents |
Delivery Statusโ
A transaction moves through these states:
queued โ submitted โ accepted โ delivered
โ failed
โ retrying โ โฆ
| Status | Description |
|---|---|
queued | Accepted by the API, pending processing. This is what a send returns. |
submitted | Handed to the AS4 sender |
accepted | An AS4 SignalMessage was received from the receiving Access Point |
delivered | Final success |
failed | Terminal failure |
retrying | Temporary failure; GoRoute will retry |
held | Prepared, 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"])