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_typestringNoA 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_idstringNoA Peppol process identifier. Must start with urn:. Defaults 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

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 one

There are two endpoints with a field called document_type, and they take completely different values. This has already cost a partner two failed sends.

EndpointWhat document_type means thereExample value
POST /api/v1/documents (this page)The Peppol document type identifierurn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice
POST /api/v1/invoices (the JSON invoice endpoint)Which kind of commercial document this isinvoice, 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.

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. 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:

FieldOn a replay
transaction_idThe first send's transaction id
statusThat transaction's status as it is now โ€” queued, submitted, delivered, failed, or any other value from Delivery Status
messageDocument 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-Key you have used before, within the last 24 hours, for the same request.
  • You re-sent the same document and sent no Idempotency-Key at 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.

Send a key if the retry must be safe

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โ€‹
Statuserror_codeWhen it happens
422IDEMPOTENCY_KEY_REUSEDYou 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.
400INVALID_IDEMPOTENCY_KEYThe 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โ€‹

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.
422 Unprocessable EntityThe 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 Requestvalidation_error / INVALID_REQUEST โ€” the body was well formed but the send could not be set up. See the four causes below.
401 UnauthorizedInvalid or missing API key
403 ForbiddenThe API key lacks the invoices:send permission
409 Conflictduplicate_invoice_number โ€” this invoice number has already been used for a different invoice on the same date. See 409 Conflict below.
429 Too Many RequestsRate limit exceeded
500 Server Errorinternal_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 its schemeID;
  • the document type code (cbc:InvoiceTypeCode or cbc: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.

Documents that cannot be read this way are never blocked

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.

There is no signing failure to handle on this endpoint

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 โ†’ โ€ฆ
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โ€‹