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 | A Peppol document type identifier, not a word. Must start with urn: and contain ::. Defaults to the Peppol BIS 3.0 Invoice identifier โ see below |
process_id | string | No | A Peppol process identifier. Must start with urn:. 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 |
document_type and process_id: send a URN, or send nothingโ
Both fields are optional and both defaults are correct for an ordinary Peppol BIS 3.0 invoice. If you are not sure, leave them out. Setting them to a plausible-looking word is the single most common way a send goes wrong on this endpoint.
document_type names the Peppol document type identifier that travels in the AS4 envelope.
A full one looks like this:
urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1
That is <root-namespace>::<local-name>##<customization-id>::<version>, and it is the
default. The shorter urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice form,
without the ## customization part, is also accepted.
process_id names the Peppol process, for example
urn:fdc:peppol.eu:2017:poacc:billing:01:1.0 for BIS 3.0 billing or urn:peppol:bis:billing
for PINT.
The API checks both. A document_type that does not start with urn: and contain ::, or
a process_id that does not start with urn:, is refused straight away with
422 Unprocessable Entity and a message naming the field and giving an example. Nothing is
queued and nothing is charged.
"invoice" is a valid value on a different endpoint โ not on this oneThere are two endpoints with a field called document_type, and they take completely
different values. This has already cost a partner two failed sends.
| Endpoint | What document_type means there | Example value |
|---|---|---|
POST /api/v1/documents (this page) | The Peppol document type identifier | urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice |
POST /api/v1/invoices (the JSON invoice endpoint) | Which kind of commercial document this is | invoice, credit_note, debit_note, self_billing_invoice, self_billing_credit_note |
If you have been following one of our getting-started guides, you have probably been using
POST /api/v1/invoices, where "document_type": "invoice" is exactly right. Carrying that
value across to POST /api/v1/documents is not. The safe move on this page is to omit the
field.
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. On a replay, status and message are different โ see Idempotency |
Idempotencyโ
Send an Idempotency-Key header to make retries safe. When GoRoute recognises a send as a
repeat of one it has already accepted, it answers the same transaction_id instead of
sending the document twice. That answer is called a replay.
A replay looks like this:
| Field | On a replay |
|---|---|
transaction_id | The first send's transaction id |
status | That transaction's status as it is now โ queued, submitted, delivered, failed, or any other value from Delivery Status |
message | Document already accepted (idempotent replay) |
Two things about a replay catch people out. The status is live, not a record of the first
answer โ if the invoice was delivered an hour ago, a replay says delivered, not queued.
And nothing is announced a second time: the activity event and the transaction.queued
webhook fire on the first send only, so do not wait for a second webhook after a retry.
POST /api/v1/invoices (the JSON invoice endpoint) answers a replay under the
Idempotency-Key header in the same way, with its own message,
Invoice already accepted (idempotent replay), and with the document_number and
document_uuid the first send recorded. That replay is not validated again, so its
validation is null: do not read validation.valid on a replay.
Two ways a send comes back as a replayโ
- You sent an
Idempotency-Keyyou have used before, within the last 24 hours, for the same request. - You re-sent the same document and sent no
Idempotency-Keyat all. GoRoute recognises the document itself. This works less often than people assume โ see below.
Both answer Document already accepted (idempotent replay). You can tell a keyless resend from a
fresh send by that message alone โ you do not have to compare transaction ids.
When a keyless resend is recognised โ and when it is notโ
With no key, GoRoute works out the document's identity from seven fields it reads out of your
XML โ the seller's electronic address and its scheme, the document type code, the document
number, the issue date, and both tax totals. They are listed field by field under
409 Conflict. If GoRoute can read all seven, a resend
of the byte-identical document answers the first transaction_id rather than sending again.
If it cannot read them, there is no identity to compare and this protection does not apply
at all โ the resend is treated as a new send and the document goes out a second time. That is
the case when the document is not a UBL Invoice or CreditNote, or when any one of those
seven fields is missing from it.
An Idempotency-Key is the only protection that does not depend on what GoRoute can read out
of your XML. If you retry sends at all, send one.
A document that matches on all seven fields but differs anywhere else is refused with 409
instead โ that is the reused-invoice-number case. See
409 Conflict.
Two refusals to handleโ
| Status | error_code | When it happens |
|---|---|---|
422 | IDEMPOTENCY_KEY_REUSED | You used a key you had already used, within the last 24 hours, for a different request. Nothing was sent. The response body also carries existing_transaction_id, the transaction the key already belongs to. |
400 | INVALID_IDEMPOTENCY_KEY | The key is longer than 255 characters. |
The key is read only from the Idempotency-Key header. An idempotency_key field in the
JSON body is not a key. POST /api/v1/invoices and POST /api/v1/documents follow the same
rule: a body idempotency_key (at the top level or inside options) that differs from the
Idempotency-Key header is refused with 422 and error_code
IDEMPOTENCY_KEY_IN_BODY_NOT_SUPPORTED before anything is created or sent. One that equals the
header is accepted, and the header deduplicates the request as usual.
The 422 is the one that bites retry loops. GoRoute stores a fingerprint of the request you
sent with the key and compares it on every reuse. The fingerprint is taken from the values
you sent โ the document, the receiver, the document type, the metadata โ so reformatting your
JSON or reordering its keys is harmless, but changing a single byte of the document is not. If
your worker rebuilds the invoice before retrying (a fresh issue date, a recalculated total, a
re-serialised XML), it sends a different request under the same key and is refused. Keep the
exact body you sent and resend that, or give the changed document a key of its own.
A key lasts 24 hoursโ
The record behind a key expires 24 hours after it was stored. Within that window, reuse with a
different request is the 422 above. After it, the record is deleted and the request proceeds
as a new one, so the 422 no longer applies. An unchanged resend under that same old key is
still recognised as a replay, because the transaction itself keeps the key it was sent with.
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. |
422 Unprocessable Entity | The body did not pass the request checks. An empty document, a document_type that is not a Peppol identifier, a process_id that is not a URN, or a missing receiver_scheme or receiver_id all land here. The response names the field. |
400 Bad Request | validation_error / INVALID_REQUEST โ the body was well formed but the send could not be set up. See the four causes below. |
401 Unauthorized | Invalid or missing API key |
403 Forbidden | The API key lacks the invoices:send permission |
409 Conflict | duplicate_invoice_number โ this invoice number has already been used for a different invoice on the same date. See 409 Conflict below. |
429 Too Many Requests | Rate limit exceeded |
500 Server Error | internal_error / INTERNAL_ERROR โ an unhandled failure on our side. The message is always the generic "An internal error occurred". |
422 and 400 are not the same thing, and the difference tells you where to look.
A 422 means the request body itself was rejected before any sending was attempted. This
is the one you get from a wrong document_type or process_id, or from an empty
document. Fix the body.
A 400 means the body was acceptable but the send could not proceed. Every one of these
carries error_code: INVALID_REQUEST, and the causes are:
- the UBL document failed Peppol validation โ the message lists up to five specific errors;
- for Oman documents, the invoice UUID inside the document is not the one GoRoute derives
from it, reported as
[BTOM-002]; - no sender was given and your organisation has no default participant to fall back on;
- on the staging environment only, your organisation is not flagged as a demo account.
Those four are the causes for an invoice or a credit note. They are not the whole list for this
endpoint: a post-award business document โ a self-billing invoice, an advanced despatch advice, a
waybill โ is checked against its own official rules instead, and can be refused here for reasons
specific to it, such as a ProfileID that its specification does not allow or a document_type
that disagrees with the document you actually sent. Those refusals arrive the same way, as a 400
with error_code: INVALID_REQUEST, and the reason is in the message. So read the message rather
than matching against a fixed list of causes.
Earlier revisions of this page said a rejected body returns 400 and gave an empty
document as the example. That was wrong: an empty document is caught by the request
checks and returns 422.
409 Conflict โ a reused invoice numberโ
An invoice number may be used once. Send a different invoice under a number you have already used on the same date, and the send is refused:
{
"error": "duplicate_invoice_number",
"message": "This invoice number has already been used for a different invoice on this date. Each invoice needs its own number. To change an invoice that has already been sent, issue a credit note and send the corrected invoice under a new number.",
"existing_transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"existing_created_at": "2026-09-20T09:14:22.115300"
}
existing_transaction_id is the transaction that already holds that number, so you can show
a user what they sent the first time. There is no error_code on this refusal โ the value to
branch on is error. Omani sellers and PINT OM documents get an Oman-specific message
explaining that the document identifier is derived from the invoice number; the code and the
fields are the same either way.
This applies to every seller, in every country. It is not an Oman rule, even though Oman is the reason it exists. GoRoute derives an identity for each document from seven fields read straight out of the UBL:
- the seller's electronic address (
cac:AccountingSupplierParty/cac:Party/cbc:EndpointID) and itsschemeID; - the document type code (
cbc:InvoiceTypeCodeorcbc:CreditNoteTypeCode); - the invoice number (
cbc:ID); - the issue date (
cbc:IssueDate); - the total tax amount (
cac:TaxTotal/cbc:TaxAmount) and the total including tax (cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount).
Two documents with the same seven values are the same document. Change any one of them โ including either total โ and it is a different one. So a Belgian or Singaporean seller meets this refusal on exactly the same terms an Omani seller does.
Fix: give the invoice a new number and send it again. To correct an invoice that has already gone out, issue a credit note and send the corrected invoice under a new number.
If GoRoute cannot read those seven fields out of your document โ because it is not a UBL Invoice or CreditNote, or a field is missing โ the check does not apply and the send goes ahead. A send is never refused over an identity we failed to derive.
Sending the identical document twice is safeโ
Re-send a document whose content is byte-for-byte identical to one you already sent, and
you get 202 Accepted with the original transaction_id and the message
Document already accepted (idempotent replay). Nothing is sent twice, and no second
transaction is created.
The status in that answer is the original transaction's status as it stands now, so a
document delivered an hour ago comes back as delivered rather than queued.
This is a different mechanism from the Idempotency-Key header โ it recognises the document
rather than the key โ but it gives the same message and, like the key, returns the original
transaction_id. See Idempotency for both, and for the two refusals a reused
key can produce.
500 Server Error โ there is nothing to branch on but the request idโ
A 500 from this endpoint means something failed on our side that we did not anticipate.
The body is always the same shape, and the message is always generic:
{
"error": "internal_error",
"error_code": "INTERNAL_ERROR",
"message": "An internal error occurred",
"request_id": "7b3f1a2c-0d54-4a1e-9c2b-8f6e5d4c3b2a"
}
Log the request_id and quote it to support. It is the only thing in that response that
tells us which request failed, and there is no other field worth branching on.
Earlier revisions of this page listed a signing error as a possible 500 for Oman
documents. That was wrong twice over: no such code has ever been emitted by the product,
and this endpoint does not sign anything. A document you post here is transmitted exactly as
you sent it, byte for byte. Signing applies only where GoRoute renders the document itself โ
that is the JSON invoice endpoint, not this one.
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"])