Tracking Delivery
After sending a document, track its journey through the Peppol network to confirm delivery.
Transaction Lifecycleโ
A document you send over the Peppol network is created as queued and finishes as
delivered or failed. A document that GoRoute issues for you to email instead is
created as issued and stays there โ it is finished, and it is never transmitted.
Those below are the only status values the API uses โ there is no pending,
processing or sending state.
queued โโโถ submitted โโโถ delivered
โ
โโโโถ retrying โโโถ delivered or failed
โโโโถ failed
issued โโโถ (finished: you send the PDF yourself โ see below)
held โโโถ (prepared on purpose and not transmitted โ see below)
| Status | What it means | Terminal |
|---|---|---|
queued | Accepted by the API and waiting to be picked up for sending. | No |
submitted | Handed to the AS4 sender for transmission to the receiver's access point. | No |
accepted | The receiving side's AS4 acknowledgement has been recorded as a step of its own. Most sends never show this โ see the note below. | No |
delivered | The receiver's access point acknowledged the document. delivered_at is set. | Yes |
failed | The send did not succeed and will not be retried automatically. error_code, error_message and failed_at are set. You can ask for it to be delivered again yourself โ see Retrying the same transaction. | Yes |
retrying | A transport problem that may clear on its own. The send is retried up to three more times; after that the transaction becomes failed. | No |
issued | The document was built and stored for delivery by email rather than over the network. GoRoute will never transmit it. Nothing has gone wrong. | Yes |
held | Built, stored and auditable, but deliberately not transmitted. Nothing has gone wrong. | No |
About accepted. For a document sent through this API the receiver's
acknowledgement comes back as part of the send itself, so a transaction normally
moves from submitted straight to delivered and accepted_at stays empty. The
acknowledgement is recorded on the transaction as receipt_message_id and
receipt_timestamp. See Proof of delivery below.
About issued. This is the one that most often catches people out, because it is an
ending that does not look like one. When you call POST /api/v1/invoices and the buyer
cannot be reached over Peppol, GoRoute builds the invoice, stores it, and hands it back to
you to send yourself โ usually as a ZUGFeRD or Factur-X PDF you attach to an email. The
transaction is then issued and nothing further will happen to it: no submitted, no
delivered, no failed, and no retry. Any code that waits for delivered or failed
must treat issued as a third ending, or it will wait forever. The response to the send
tells you this at the time, in a delivery object, and the transaction carries
delivery_channel: "email". How the choice is made, and how to fetch the PDF, is on
Delivery and formats.
About held. A held document has been built, stored and made auditable, but was
never put on the network on purpose โ for example a tax report that is owed before
the tax authority's receiving endpoint is open, or an Oman B2C invoice that the rules
say must not be exchanged with a receiving access point. It is not a failure, and
nothing is retrying it.
How long each step takes depends on the receiving access point, so this page quotes no timings.
Checking Statusโ
Get Transaction Detailsโ
import requests
def get_transaction_status(transaction_id: str) -> dict:
"""Get current status of a transaction."""
response = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}",
headers={"X-API-Key": "your_api_key"}
)
response.raise_for_status()
return response.json()
# Example usage. Transaction identifiers are UUIDs.
status = get_transaction_status("550e8400-e29b-41d4-a716-446655440000")
print(f"Status: {status['status']}")
print(f"Created: {status['created_at']}")
print(f"Updated: {status['updated_at']}")
Response Structureโ
The sender and receiver come back as single text fields holding
scheme:identifier, not as objects, and there are no company names in the
response. Errors come back as two separate fields, error_code and
error_message.
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"org_id": "661f9511-f3ac-52e5-b827-557766551111",
"direction": "sent",
"status": "delivered",
"message_id": "550e8400-e29b-41d4-a716-446655440000@peppol.goroute.ai",
"sender_peppol_id": "9959:123456789",
"receiver_peppol_id": "9959:987654321",
"document_type": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice",
"process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
"document_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"document_size_bytes": 15420,
"metadata": {
"document_number": "INV-2026-00123"
},
"delivery_channel": "peppol",
"created_at": "2026-08-20T10:30:00Z",
"updated_at": "2026-08-20T10:30:05Z",
"submitted_at": "2026-08-20T10:30:01Z",
"accepted_at": null,
"delivered_at": "2026-08-20T10:30:05Z",
"failed_at": null,
"error_code": null,
"error_message": null,
"retry_count": 0,
"receipt_message_id": "receipt-123@receiver.example",
"receipt_timestamp": "2026-08-20T10:30:05Z",
"retention_expires_at": null,
"storage_present": null,
"storage_checked_at": null,
"clearance_status": null,
"mls_status": null,
"substitute_participant": null,
"substitute_reason": null
}
Fields worth knowing before you write your parsing:
| Field | Notes |
|---|---|
id | The transaction identifier, a UUID. |
sender_peppol_id, receiver_peppol_id | Text, in the form scheme:identifier. Empty if the transaction has no scheme or identifier recorded. |
metadata | Whatever you sent as metadata. The invoice number recorded by the send paths lives here under document_number. |
delivery_channel | Which route was chosen for the document: peppol when it was queued for the network, email when it was issued for you to send (the transaction is then issued). It is null for documents that did not go through POST /api/v1/invoices โ raw UBL posted to POST /api/v1/documents, for example โ because no delivery decision was made for those. See Delivery and formats. |
error_code, error_message | Both plain text, both null unless the send failed. |
receipt_message_id, receipt_timestamp | The transport acknowledgement from the receiving access point. See Proof of delivery. |
retention_expires_at | When the statutory retention period for the record ends. Stamped on documents you receive and on Oman point-of-sale transactions; empty on an ordinary outbound send. |
storage_present, storage_checked_at | Whether the stored document was still retrievable when the storage sweep last looked, and when that was. Both null means nobody has looked yet โ which is not the same as the document being gone. |
clearance_status, mls_status, substitute_participant, substitute_reason | Oman tax-reporting outcomes. null for a document with no Oman reporting obligation. |
Polling for Statusโ
For asynchronous sends, poll until delivery is confirmed. There are three endings, not
two: delivered, issued and failed. A loop that only watches for delivered and
failed never returns for a document GoRoute issued for you to email.
import time
def wait_for_delivery(transaction_id: str, timeout: int = 120) -> dict:
"""Wait for transaction to reach terminal status."""
start_time = time.time()
while time.time() - start_time < timeout:
status = get_transaction_status(transaction_id)
if status["status"] == "delivered":
return status
# Terminal, and not a failure: the buyer is not reachable over Peppol,
# so the document was issued for you to email. Nothing more will happen
# to this transaction โ fetch the PDF and send it yourself.
if status["status"] == "issued":
return status
if status["status"] == "failed":
raise Exception(
f"Delivery failed ({status.get('error_code')}): "
f"{status.get('error_message')}"
)
# Wait before polling again
time.sleep(2)
raise TimeoutError(f"Transaction {transaction_id} did not complete within {timeout}s")
# Usage. Transaction identifiers are UUIDs โ there is no `txn_`-prefixed form.
result = wait_for_delivery("550e8400-e29b-41d4-a716-446655440000")
if result["status"] == "issued":
# delivered_at is null here; the document was never put on the network.
print("Issued for you to email โ download the PDF and send it")
else:
print(f"Delivered at: {result['delivered_at']}")
Webhooks (Recommended)โ
Instead of polling, receive status updates via webhooks:
Configure Webhookโ
The create call accepts four fields โ url, events, and optionally description and
metadata. It does not accept a secret: GoRoute generates one and returns it in this
response only. Save it immediately, because no later call will show you more than its first
eight characters.
# Register a webhook endpoint
response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/webhooks",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json"
},
json={
"url": "https://your-app.com/webhooks/goroute",
"events": ["transaction.delivered", "transaction.failed"],
"description": "Delivery tracking"
}
)
response.raise_for_status()
webhook = response.json()
save_webhook_secret(webhook["secret"]) # shown once, never again
Full details of registration, including the sandbox default of two webhooks โ which GoRoute can raise for your organisation on request, rather than it being a fixed rule โ are in Webhook Setup.
Webhook Eventsโ
These are the delivery-tracking events GoRoute actually sends. Each is subscribed to by exact name.
| Event | Description |
|---|---|
transaction.queued | Document accepted for processing |
transaction.delivered | Successfully delivered to the recipient's Access Point |
transaction.failed | Delivery failed terminally |
There is no event for validation starting or for transmission starting. Earlier revisions of
this page listed three names of that kind; none of them exists, and the create call rejects
an unknown event name with 422 Unprocessable Entity. The complete list of real events is in
the Webhook Events Reference.
Webhook Payloadโ
The event name is in a field called event_type. A handler that reads type fails on its
first line.
{
"id": "evt_2b3c4d5e6f708192a3b4c5d6e7f80912",
"event_type": "transaction.delivered",
"created_at": "2026-01-15T10:32:00.884210",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "delivered",
"message_id": "phase4-msg-abc123",
"receipt_message_id": "phase4-rcpt-def456",
"receiver_id": "0192:987654321"
}
}
A transaction.failed message carries transaction_id, status, error_code,
error_message and retry_count. Neither message contains your invoice number, so key your
own records on transaction_id โ see Store Transaction IDs.
created_at is UTC and carries no timezone suffix. Parse it as UTC.
Handle Webhooksโ
Two things to get right. The signature is in X-Webhook-Signature, and its value is
t=<timestamp>,v1=<hex digest> โ the digest is taken over the timestamp, a full stop, and
then the raw request body, not over the body alone. And the event name is in event_type.
import hashlib
import hmac
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = load_webhook_secret() # the value saved when the webhook was created
def verify_signature(raw_body: bytes, header_value: str, secret: str,
tolerance_seconds: int = 300) -> bool:
if not header_value:
return False
parts = dict(
piece.split("=", 1)
for piece in header_value.split(",")
if "=" in piece
)
timestamp = parts.get("t")
received = parts.get("v1")
if not timestamp or not received:
return False
# Your own replay window. GoRoute does not enforce one.
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
@app.route("/webhooks/goroute", methods=["POST"])
def handle_webhook():
if not verify_signature(
request.get_data(),
request.headers.get("X-Webhook-Signature"),
WEBHOOK_SECRET,
):
return {"error": "Invalid signature"}, 401
event = request.get_json()
if event["event_type"] == "transaction.delivered":
handle_delivery(event["data"])
elif event["event_type"] == "transaction.failed":
handle_failure(event["data"])
return {"status": "ok"}
def handle_delivery(data):
"""Process successful delivery."""
transaction_id = data["transaction_id"]
# Update your database against the transaction ID you stored when sending.
db.update_invoice_status_by_transaction(transaction_id, "delivered")
logger.info(f"Transaction {transaction_id} delivered")
def handle_failure(data):
"""Process delivery failure."""
transaction_id = data["transaction_id"]
# Alert the team
notify_team(f"Delivery failed: {transaction_id}", data["error_message"])
# Decide for yourself whether to resend: a failed transaction is terminal.
# (Key on the event id: a webhook delivery can arrive more than once.)
queue_for_review(transaction_id, data["error_code"])
A GoRoute service with automatic retry sends a webhook your endpoint failed to accept up to
five more times, over about nine hours; its deliveries carry an X-Webhook-Attempt header.
A service without it attempts each delivery once: if your endpoint is down when it sends one
of these, the message is not re-sent for you, and you recover it yourself from the delivery
record โ see
Deliveries.
Either way, a webhook whose events fail ten times in a row is switched off, silently. See
Delivery, failure and recovery.
List Transactionsโ
Query your transaction history:
# List recent transactions
response = requests.get(
"https://app.goroute.ai/peppol-api/api/v1/transactions",
params={
"status": "delivered",
"page": 1,
"page_size": 100
},
headers={"X-API-Key": "your_api_key"}
)
result = response.json()
print(f"Total: {result['total']} across {result['pages']} page(s)")
for txn in result["items"]:
print(f"{txn['id']}: {txn['metadata'].get('document_number')} - {txn['status']}")
Query Parametersโ
Paging uses page and page_size. There is no limit or offset, and the date
filters are date_from and date_to โ in that order, not the other way round.
| Parameter | Type | Description |
|---|---|---|
page | integer | Page number, starting at 1. Default 1. |
page_size | integer | Results per page. Default 50, maximum 100. |
status | string | One of queued, submitted, accepted, delivered, failed, retrying, held, issued. |
direction | string | sent or received. |
sender_id | string | Sender identifier, matched exactly, without the scheme โ 123456789, not 9959:123456789. |
receiver_id | string | Receiver identifier, same rule as sender_id. |
participant | string | The other side of the exchange, matched against either the sender or the receiver. Case-insensitive, and a substring: a full scheme:identifier, the bare identifier, or a fragment of one all match. Use it when you want everything you have exchanged with one trading partner, in both directions. Use sender_id or receiver_id when you want an exact, one-sided match. The values to pass come from Counterparties. |
scope | string | Set it to group to read your whole group โ the calling organisation and every entity beneath it โ with the same filters and paging. Each row then carries an entity object of org_id and name saying which entity it belongs to; outside group scope entity is null. |
document_type | string | Partial, case-insensitive match on the document type identifier. |
date_from | string | Created on or after this time (ISO 8601). |
date_to | string | Created on or before this time (ISO 8601). |
invoice_number | string | The invoice number as it appears on your document, matched exactly. Use it to find a transaction again when you no longer have its identifier. |
source | string | Where the document came from, for example csv_import. |
import_job_id | string | Everything created by one bulk import job. |
exclude_reporting_legs | boolean | Leave out documents the platform files on your behalf โ Oman Tax Data Documents and eDEC message-level status receipts โ rather than ones you issued. Set it when you are showing an invoice register, so the rows and the totals agree. |
Response Envelopeโ
Lists come back in a page envelope, not a bare array:
{
"items": [],
"total": 128,
"page": 1,
"page_size": 50,
"pages": 3,
"has_next": true,
"has_prev": false
}
items holds transactions in the shape shown under
Response Structure.
Example: Find Failed Transactionsโ
# Get all failed transactions in the last 24 hours
from datetime import datetime, timedelta
yesterday = (datetime.utcnow() - timedelta(days=1)).isoformat() + "Z"
response = requests.get(
"https://app.goroute.ai/peppol-api/api/v1/transactions",
params={
"status": "failed",
"date_from": yesterday
},
headers={"X-API-Key": "your_api_key"}
)
failed = response.json()["items"]
print(f"Failed in last 24h: {len(failed)}")
for txn in failed:
print(f" {txn['id']}: {txn['error_code']} - {txn['error_message']}")
Once you have the list, see Retrying the same transaction and Resending a failed invoice for what you can do about them โ and, importantly, for the cases where a resend is not possible and a retry is.
Counterparties: who you actually trade withโ
The participant filter above takes a Peppol identifier, and nobody knows their customers by
0248:OM1100000964. This call returns the trading partners that actually appear in your own
traffic, each with the name your register already shows for it, so the filter can be a menu
instead of a text box.
GET /api/v1/transactions/counterparties
response = requests.get(
"https://app.goroute.ai/peppol-api/api/v1/transactions/counterparties",
params={"direction": "sent", "limit": 50},
headers={"X-API-Key": "your_api_key"},
)
for party in response.json()["counterparties"]:
label = party["name"] or party["peppol_id"]
print(f"{label} ({party['total']} documents)")
Requires the transactions:read permission.
Query parametersโ
| Parameter | Type | Description |
|---|---|---|
direction | string | sent gives the parties you invoice, received the parties that invoice you. Leave it out, or send it empty, for both. Matched case-insensitively. Anything else is refused โ see below. |
date_from | string | Only count documents exchanged on or after this time (ISO 8601). |
date_to | string | Only count documents exchanged on or before this time (ISO 8601). |
exclude_reporting_legs | boolean | Defaults to true here, unlike the transaction list, where it defaults to false. Documents the platform files on your behalf โ Oman Tax Data Documents, eDEC message-level status receipts โ are addressed to a tax authority, which is machine traffic rather than a trading partner anyone picks from a menu. Set it to false to see them. |
limit | integer | How many partners to return at most. Default 200, minimum 1, maximum 500. |
scope | string | group aggregates across your whole group, under the same gate and the same refusals as the transaction list. |
Responseโ
{
"counterparties": [
{
"peppol_id": "9959:987654321",
"name": "Customer Inc",
"sent": 42,
"received": 3,
"total": 45,
"last_seen": "2026-09-05T09:14:22Z"
}
],
"total": 1
}
| Field | Meaning |
|---|---|
peppol_id | The other party โ the receiver of a document you sent, the sender of one you received โ as scheme:identifier. Pass it straight back as participant on the transaction list. |
name | The name recorded by the most recent of that party's documents that carries one, however many nameless documents came after it. A bare AS4 receive carries identifiers and nothing else, so name is null only when none of the party's documents named it. |
sent, received | How many documents went each way inside the window you asked for. |
total | On a row, sent + received. |
last_seen | When you last exchanged a document with that party. |
Ordering and limit. Rows come back busiest first: by row total descending, then by name
with unnamed parties last, then by identifier. limit keeps the busiest. Where several parties
tie on total at the cut-off, the identifier decides which survive, because the name is
resolved after the cut.
One partner is one row. Identifiers are grouped without regard to upper or lower case,
because the participant filter matches that way too and the network is not consistent about
it. The spelling you get back is the one the most recent document used. A document that
recorded only one side of the exchange has no counterparty and is skipped, exactly as your
register shows "-" for it.
total is not a count of your partnersIt is the number of rows in counterparties, after limit has been applied. This call
answers a picker, not a pageable register, so do not render it as "you have N trading
partners". With the default limit of 200, an organisation with 900 partners still sees
"total": 200.
Refusal
| Status | error_code | When |
|---|---|---|
| 400 | INVALID_DIRECTION | direction was something other than sent, received or empty. A mistyped value is refused rather than answered with an empty list, because an empty picker looks like an organisation with no customers. |
Proof of deliveryโ
Three different things can be described as proof, and they answer different questions. This section says which is which and how to get each one.
Until August 2026 this page described a downloadable delivery receipt, reached by adding a receipt step to a transaction's web address, and showed a response with fields for a notification identifier, the receiving access point and a raw notification body. No such call has ever existed in this API, and neither did those fields. Anyone who wrote code against that description got a "not found" on the first call. It has been removed.
The vocabulary was also wrong. A Message Disposition Notification, or MDN, belongs to AS2, an older transport. Peppol uses AS4, so nothing in a Peppol exchange produces an MDN.
1. What the network acknowledgedโ
When the receiver's access point acknowledges the transmission, the acknowledgement
is recorded on the transaction itself. There is nothing extra to fetch: read
receipt_message_id and receipt_timestamp from the transaction you already have.
txn = get_transaction_status("550e8400-e29b-41d4-a716-446655440000")
if txn["status"] == "delivered":
print(f"Acknowledged by the receiving access point at {txn['receipt_timestamp']}")
print(f"Acknowledgement message id: {txn['receipt_message_id']}")
This is transport-level evidence: the receiving access point confirmed it took the message. It says nothing about what the buyer's finance system later decided about the invoice.
2. The document itselfโ
To get back the document that was exchanged, fetch its XML with /document/content. The
XML is the response body (Content-Type: application/xml).
response = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/document/content",
headers={"X-API-Key": "your_api_key"}
)
xml = response.text
Requires the transactions:read permission. For a document issued by email, this call returns
the EN 16931 CII by default; add syntax=ubl for the stored UBL. See
Getting the XML on its own.
The plain GET /api/v1/transactions/{transaction_id}/document call does not return the XML
on GoRoute's service. It returns JSON: a download_url for the stored file, valid for one hour
(expires_in_seconds: 3600), with content_type and filename. The link needs no API key to
follow, so treat it like the document itself. A deployment that keeps documents on local disk
returns the file directly instead.
If the transaction exists but no document was stored for it, the call answers 404
with the code DOCUMENT_NOT_STORED.
3. The business-level status (Oman only, today)โ
A Message Level Status, or MLS, is a UBL ApplicationResponse: the Peppol business-level answer saying a document was accepted or rejected. Where one exists, you can fetch it in its original, unaltered form.
For the answer that came back about an invoice you sent โ the buyer's accept or
reject โ use the Oman call. BASE_URL here is the service you are integrating against, and
it has to be one with the Oman pack switched on; the row below says how that differs from the
other calls on this page.
response = requests.get(
f"{BASE_URL}/api/v1/oman/transactions/{transaction_id}/mls",
params={"leg": "received"},
headers={"X-API-Key": "your_api_key"}
)
| Point | Detail |
|---|---|
| Permission | transactions:read. |
leg | Required on this call: received for the buyer's answer, authority for the Oman Tax Authority's answer on the invoice's Tax Data Document. |
| Response | The ApplicationResponse XML, exactly as exchanged. |
| When there is none | 404 with the code MLS_NOT_AVAILABLE. |
| Availability | Only on a service with the Oman pack switched on. It is not mounted on app.goroute.ai today. |
GET /api/v1/transactions/{transaction_id}/mls?leg=received looks the answer up inside your
own organisation. The buyer's answer is addressed to GoRoute's service-provider identity, so
it is filed under GoRoute's organisation and that call answers 404 DOCUMENT_NOT_STORED
every time โ whether the buyer has answered or not. A 404 from it is not proof that no answer
has arrived, and an earlier version of this page gave MLS_NOT_AVAILABLE as the answer you
would get. Use the core call for leg=sent only.
The two calls, the required leg, every refusal code and how to tell whether a service has
the Oman call are documented in one place:
Fetching an MLS document.
Read this before you build on it. Message Level Status documents are produced
and recorded only by the Oman tax-reporting flow โ the Fawtara eDEC exchange. A
plain Peppol invoice sent to, say, a Belgian or Australian buyer has no MLS. Treat
this as an Oman feature until this page says otherwise, and use the transaction's own
status and receipt_* fields as your general proof of delivery.
You do not have to poll for the buyer's answer: it also arrives as a webhook event on the organisation that owns the invoice. See Status events for invoices you sent.
Download a readable copy: the invoice PDFโ
The XML above is what the network exchanged, but it is not something you would show a person. For a readable, branded copy of the same transaction:
GET /api/v1/transactions/{transaction_id}/pdf
response = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/pdf",
headers={"X-API-Key": "your_api_key"},
)
with open("invoice.pdf", "wb") as f:
f.write(response.content)
Every parameter below has a default, so the call above is complete as it stands.
The response is the PDF itself, with Content-Type: application/pdf โ not JSON, and not a
link to download later. The document carries your organisation's logo, primary brand colour
and footer text from your branding settings.
| Point | Detail |
|---|---|
| Permission | transactions:export |
lang | en (the default) or ar. Arabic labels appear only on an Oman document โ see What lang=ar actually does |
currency_display | auto (the default), sign, code or symbol โ see How amounts are printed |
hybrid | Boolean, default true. For an EU seller the PDF is then a PDF/A-3 hybrid carrying the EN 16931 CII XML inside it as factur-x.xml. Pass false for a plain PDF |
| Filename | Built from the transport message identifier, not the invoice number. The invoice number lives inside the UBL and is not available to name the file |
Errors
| Status | error_code | When |
|---|---|---|
| 404 | TRANSACTION_NOT_FOUND | No transaction with that id in your organisation |
| 500 | PDF_GENERATION_FAILED | The transaction exists but the PDF could not be produced |
transactions:export is a permission in its own rightIt is not implied by transactions:read. An API key that can read a transaction and download
its XML can still get 403 from this endpoint. If that happens, the key is missing
transactions:export rather than the transaction being unavailable. This permission is not
yet listed in the permissions reference; that gap is recorded for a future update.
How amounts are printedโ
currency_display takes four values, and what each one gives you depends on whether the
amount is in Omani rials.
On an OMR amount:
| Value | What appears on the page |
|---|---|
auto (default) | The Central Bank of Oman's rial sign, drawn as artwork to the left of the figure |
sign | The same as auto |
code | OMR 79.000 |
symbol | 79.000 ุฑ.ุน. |
On any other currency:
| Value | What appears on the page |
|---|---|
auto (default) | Behaves as code |
code | The figure first, then the code: 120.00 EUR |
sign or symbol | The figure first, then the currency's symbol: 120.00 โฌ |
Two things are easy to get wrong here. Outside Oman the code follows the amount โ you get
120.00 EUR, not EUR 120.00. And symbol falls back to the three-letter code for any
currency we hold no symbol for; symbols exist for OMR, USD, EUR, GBP, SAR, AED, KWD, BHD, QAR
and JOD, so an Australian invoice asking for symbol prints 120.00 AUD.
Omani rials print to three decimal places, as do Kuwaiti dinars, Bahraini dinars and Jordanian dinars. Every other currency prints to two.
What lang=ar actually doesโ
lang=ar adds Arabic to an Oman document and to nothing else. The PDF template switches
on the customization identifier: Arabic labels appear only when it contains om-1, the Oman
rulebook. An Oman document is bilingual Arabic and English by rule.
Asking for lang=ar on a German, Belgian or Australian invoice is not refused and is not an
error. You simply get the ordinary English document, with no Arabic in it. If you need Arabic
on a non-Oman invoice, this parameter will not give it to you today.
Retrying the same transactionโ
When a send failed for a transport reason โ the receiver's access point was unreachable, or a registry lookup blinked โ you can have GoRoute deliver the same transaction again from the document it already holds in storage. Nothing is rebuilt and nothing is renumbered: the same transaction id, the same message id, the same document.
This works whichever way you sent the document, raw UBL XML included, because the document is put in storage when the transaction is created.
POST /api/v1/transactions/{transaction_id}/retry
response = requests.post(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/retry",
headers={"X-API-Key": "your_api_key"},
)
result = response.json()
print(result["transaction_id"], result["status"]) # the same id, and "queued"
Requires the invoices:send permission. There is no request body. The response carries
transaction_id, status โ always queued โ and a human-readable message.
What it does to the transaction. The transaction goes back to queued, its retry_count
is reset to 0, and error_code, error_message and failed_at are cleared. A new send job
is queued, marked internally as a manual retry. From there the transaction moves through the
ordinary lifecycle above, so the webhook subscription and the status call you already use keep
working โ the id you are watching does not change.
Refusals
| Status | error_code | When |
|---|---|---|
| 404 | TRANSACTION_NOT_FOUND | No transaction with that id in your organisation |
| 400 | CANNOT_RETRY_RECEIVED | The transaction is an inbound document. Only something you sent can be delivered again |
| 400 | NOT_FAILED | The transaction is not failed or retrying. There is no force option here, so a delivered or queued transaction cannot be retried |
| 400 | NO_DOCUMENT | The document is no longer in storage, so there is nothing left to deliver |
| 409 | ALREADY_QUEUED | Another retry or resend of the same transaction queued it a moment earlier โ for example, your client repeated a call that timed out. Nothing more was queued; follow transaction_id as usual |
Retry or resend?โ
| Retry | Resend | |
|---|---|---|
| What goes out | The stored document, unchanged | A document rebuilt from the stored invoice data โ unless it keeps the document identifier of a failed transaction, in which case that transaction's stored document |
| The transaction | The same one, reopened | A new one when the rebuild has a new document identifier; otherwise the existing failed one, reopened |
| Message id | Unchanged | New for a new transaction; unchanged when the existing one is reopened |
| What it needs | The document in storage โ true whichever way you sent it | The structured invoice, which raw UBL sends do not have |
| Can change the issue date | No | Yes, and does by default as soon as you send a body. An Omani invoice is never re-dated: see "In Oman, a resend is a retry" below |
| Reach for it when | The send failed in transport and nothing about the document needs to change | The invoice, or what it must satisfy, has changed |
Retry does not replace resend. It cannot alter a single character of the document, so where the send failed because the document itself was rejected โ or because a validation rule tightened underneath you โ a resend is still the answer.
In Oman, a resend is a retry. An Omani document's identifier (BTOM-002) is worked out from
the document itself โ the seller's electronic address and its scheme, the document type code, the
invoice number, the issue date, the VAT total and the total including VAT, seven fields hashed
together โ and a B2C invoice's QR code carries the issue date, the time and that identifier. The
full derivation is in the Oman country guide. A new issue date would
therefore be a different invoice under the same number, reported to the Tax Authority a second
time, so GoRoute never re-dates or rebuilds an Omani invoice on resend. An Omani invoice is one
sent as PINT OM, or from a seller that is Omani by its address (or, where GoRoute reads the
seller's identity, by its 0248 Peppol ID or OM VAT number). A resend of one ends one of
three ways:
- Sent as PINT OM, with no body,
{},{"force": ...}or"update_timestamps": false. The failed transaction's stored document โ the one first sent, QR code and all โ is queued again under the same transaction and message id, to the same receiver, exactly as a retry does.outcomeisrequeued. Theupdate_timestampsdefault does not apply. No new transaction is made: the invoice's tax report stays with this transaction, as it does after a retry. - Sent as PINT OM, with
"update_timestamps": true. Refused with 422OMAN_RESEND_CANNOT_REDATE; nothing is sent. To change an Omani invoice that has been issued, issue a credit note for it and send a new invoice under a new number. - An Omani seller's invoice that went out as Peppol BIS 3.0, not PINT OM (for example, one
accepted before GoRoute read the seller's identity, while the seller's country was missing).
It cannot be reported to the Tax Authority, and a resend never rebuilds an Omani invoice, so it
is not resent at all, whatever the body: 422
OMAN_RESEND_NOT_PINT_OM, nothing sent. Send a new invoice under a new number instead (it is issued as PINT OM and reported), and if the buyer did receive this one, issue a credit note for it as well.
Where nothing about the invoice must change,
POST /api/v1/transactions/{transaction_id}/retry is the
simpler call: it takes no body and queues the same transaction and message id, provided the send
is failed or retrying with the document still in storage.
Resending a failed invoiceโ
Where a send failed, you can have GoRoute rebuild and send it again from the invoice data it stored at the time. What happens next depends on the rebuilt document's identifier, which is worked out from the seller, the document type, the invoice number, the issue date and the tax totals:
- A new identifier โ in practice, a new issue date. GoRoute creates a new transaction and leaves the original untouched, so the failure stays in your audit trail.
- The identifier of an existing failed or retrying transaction โ in practice, the one you are
resending, with its date kept. One identifier belongs to one document, so no second transaction
is made: that transaction's stored document is queued for delivery again under its own id, as a
retry would do. Its
error_codeanderror_messageare cleared; the failure itself was recorded when it happened, as atransaction.failedevent. This is the same whether or not the original send carried anIdempotency-Key: the identifier is compared with the document the transaction holds, not with the key it was filed under.
GoRoute can only rebuild an invoice it holds as structured data. That happens when you sent the invoice as JSON, or when we built it for you:
POST /api/v1/invoicesโ the JSON invoice endpoint- a CSV or Excel import, or the Excel add-in
- a batch send
- sending a saved draft
- an invoice pulled in by a connector, such as SAP or Odoo
It does not happen when you posted raw UBL XML to POST /api/v1/documents. That path
stores only the metadata you supplied with the call, so there is nothing to rebuild from and
a resend answers 400 with NO_INVOICE_DATA.
If raw UBL is how you send, use a retry instead: Retrying the same transaction above sends the stored document again and needs no invoice data, so it works for these transactions. Sending the document yourself as a fresh call is still an option, but it creates a new transaction.
Check first: can this one be resent?โ
Rather than trying a resend and reading the refusal, ask.
GET /api/v1/transactions/{transaction_id}/invoice
response = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/invoice",
headers={"X-API-Key": "your_api_key"},
)
history = response.json()
if history["can_resend"]:
resend(transaction_id)
elif not history["has_invoice_data"]:
print("Nothing stored to rebuild from โ send it again yourself")
else:
print(f"Not in a resendable state: {history['status']}")
Requires the transactions:read permission โ a different one from the resend itself.
| Field | Meaning |
|---|---|
has_invoice_data | Whether GoRoute still holds the invoice as structured data. false means a resend cannot work at all. |
can_resend | true only when the transaction is outbound, has stored invoice data, and is failed or retrying. |
invoice_data | The stored invoice itself. Pass include_invoice_data=false to leave it out and get a lighter response. |
status, direction, error_code, error_message, retry_count | The same values as on the transaction, repeated here so one call answers the whole question. |
sender_peppol_id, receiver_peppol_id, document_type, document_number | Who it was for and what it was. |
can_resend is answered strictly. It is false for a delivered transaction even though
force would let you resend one anyway, so treat it as "resendable without argument" rather
than as a hard veto. A transaction with no transaction_id of yours answers 404 with
TRANSACTION_NOT_FOUND.
Doing the resendโ
POST /api/v1/transactions/{transaction_id}/resend
response = requests.post(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/resend",
headers={"X-API-Key": "your_api_key"},
)
result = response.json()
print(result["outcome"], result["transaction_id"], result["message"])
Requires the invoices:send permission. The response carries:
| Field | Meaning |
|---|---|
outcome | new_transaction โ the rebuild is a new document, sent as a new transaction. requeued โ it has the identifier of an existing failed or retrying transaction, whose stored document was queued again; nothing new was created |
transaction_id | The transaction carrying this delivery: the new one, or the existing one queued again. Track this id |
new_transaction_id | The new transaction's id when outcome is new_transaction; null when requeued |
original_transaction_id | The transaction you asked to resend |
success, message | true, and a human-readable account of which of the two happened |
After a new_transaction resend the original keeps its failed status. After a requeued one,
the transaction in transaction_id goes back to queued and moves through the ordinary
lifecycle again.
The body is optional, and the two behaviours are not the same:
- No body at all: the invoice is resent with its original issue date, unchanged.
- Any body, including
{}: the issue date is overwritten with today's date, becauseupdate_timestampsdefaults totrueand the default applies as soon as a body exists.
An invoice is a tax record in most of the countries GoRoute serves, and its issue date has
legal meaning. Changing it by accident โ by sending {} to be tidy, or by sending
{"force": true} and not thinking about the other field โ changes the document you are
filing. If you send a body and want the original date kept, you must say so explicitly with
{"update_timestamps": false}.
An Omani invoice is the exception: it is never re-dated whatever the body, and an explicit
"update_timestamps": true is refused โ see Retry or resend? above.
The optional body
| Field | Default | Meaning |
|---|---|---|
force | false | Resend even when the transaction is not in a failed state |
update_timestamps | true | Set the invoice issue date to today. See the warning above. It is never applied to an Omani invoice, and true is refused for one |
Refusals
| Status | error_code | When |
|---|---|---|
| 404 | TRANSACTION_NOT_FOUND | No transaction with that id in your organisation |
| 400 | CANNOT_RESEND_RECEIVED | The transaction is an inbound document. You cannot resend something someone sent you |
| 400 | NOT_FAILED | The transaction is not failed or retrying, and force was not set to true |
| 400 | NO_INVOICE_DATA | The original invoice data was never stored, so there is nothing to rebuild from |
| 400 | INVOICE_INVALID | The stored invoice no longer passes validation. The response lists the specific errors |
| 422 | OMAN_RESEND_CANNOT_REDATE | "update_timestamps": true on an Omani invoice, whose issue date is part of its identifier and QR code. Nothing was sent. Resend without it to deliver the invoice as first sent, or issue a credit note and a new invoice |
| 422 | OMAN_RESEND_NOT_PINT_OM | An Omani seller's invoice that went out as Peppol BIS 3.0 (or another format), not PINT OM, whatever the body. It cannot be reported to the Tax Authority, so it is not resent. Nothing was sent. Send a new invoice under a new number (and a credit note if the buyer received this one) |
| 400 | NO_DOCUMENT | The rebuild has the identifier of an existing failed transaction (for an Omani invoice, which keeps its date: the transaction resent), whose document is no longer in storage |
| 409 | SAME_DOCUMENT_EXISTS | The rebuild has the identifier of a transaction that is not failed or retrying โ typically force on a delivered invoice with its date kept, and always force on a delivered Omani invoice, whose date is always kept. Nothing was sent. existing_transaction_id and existing_status name it |
| 409 | duplicate_invoice_number (in error) | The rebuild has the identifier of a different transaction whose document is not the same. Nothing was sent |
| 409 | ALREADY_QUEUED | Another retry or resend of the same transaction queued it a moment earlier โ for example, your client repeated a call that timed out. Nothing more was queued; follow the transaction id as usual |
| 500 | RESEND_ERROR | The rebuild or the send itself failed |
NO_INVOICE_DATA is the one to design around, and it is not rare. It is what you get for
every transaction created by posting raw UBL to POST /api/v1/documents, however
legitimately it failed. Check with
the invoice call above before you offer a customer a
resend button, so you do not build a control that most of your transactions cannot use.
Where a resend is refused for this reason, offer
a retry instead: it asks only for the stored document, and
that is there whichever way the document was sent.
INVOICE_INVALID usually means something outside the invoice moved โ a validation rule
tightened, or a party identifier stopped being acceptable โ between the original send and
the resend. The invoice has not changed; what it must satisfy has.
Monitoring Dashboardโ
Track transactions in the GoRoute dashboard:
- Log in to app.goroute.ai
- Navigate to Transactions
- Filter by status, date range, or document type
- Click a transaction for details
Best Practicesโ
1. Use Webhooks Over Pollingโ
# โ Don't do this in production
while True:
status = get_transaction_status(txn_id)
if status["status"] in ["delivered", "issued", "failed"]:
break
time.sleep(2)
# โ
Use webhooks instead
@app.route("/webhooks/goroute", methods=["POST"])
def webhook():
event = request.json
process_event(event)
2. Store Transaction IDsโ
# Save transaction ID with your invoice
def send_and_track(invoice_xml: str, invoice_id: str):
result = client.send_invoice(invoice_xml)
# Store mapping
db.save_transaction_mapping(
invoice_id=invoice_id,
transaction_id=result["transaction_id"]
)
return result
3. Handle Webhook Failuresโ
# Implement idempotency
def handle_webhook(event):
event_id = event["id"]
# Check if already processed
if db.event_exists(event_id):
return {"status": "already_processed"}
# Process event
process_event(event)
# Mark as processed
db.mark_event_processed(event_id)
return {"status": "ok"}