Skip to main content

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)
StatusWhat it meansTerminal
queuedAccepted by the API and waiting to be picked up for sending.No
submittedHanded to the AS4 sender for transmission to the receiver's access point.No
acceptedThe receiving side's AS4 acknowledgement has been recorded as a step of its own. Most sends never show this โ€” see the note below.No
deliveredThe receiver's access point acknowledged the document. delivered_at is set.Yes
failedThe 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
retryingA transport problem that may clear on its own. The send is retried up to three more times; after that the transaction becomes failed.No
issuedThe document was built and stored for delivery by email rather than over the network. GoRoute will never transmit it. Nothing has gone wrong.Yes
heldBuilt, 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:

FieldNotes
idThe transaction identifier, a UUID.
sender_peppol_id, receiver_peppol_idText, in the form scheme:identifier. Empty if the transaction has no scheme or identifier recorded.
metadataWhatever you sent as metadata. The invoice number recorded by the send paths lives here under document_number.
delivery_channelWhich 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_messageBoth plain text, both null unless the send failed.
receipt_message_id, receipt_timestampThe transport acknowledgement from the receiving access point. See Proof of delivery.
retention_expires_atWhen 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_atWhether 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_reasonOman 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']}")

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.

EventDescription
transaction.queuedDocument accepted for processing
transaction.deliveredSuccessfully delivered to the recipient's Access Point
transaction.failedDelivery 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"])
Check whether the service you call retries a failed delivery

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.

ParameterTypeDescription
pageintegerPage number, starting at 1. Default 1.
page_sizeintegerResults per page. Default 50, maximum 100.
statusstringOne of queued, submitted, accepted, delivered, failed, retrying, held, issued.
directionstringsent or received.
sender_idstringSender identifier, matched exactly, without the scheme โ€” 123456789, not 9959:123456789.
receiver_idstringReceiver identifier, same rule as sender_id.
participantstringThe 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.
scopestringSet 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_typestringPartial, case-insensitive match on the document type identifier.
date_fromstringCreated on or after this time (ISO 8601).
date_tostringCreated on or before this time (ISO 8601).
invoice_numberstringThe invoice number as it appears on your document, matched exactly. Use it to find a transaction again when you no longer have its identifier.
sourcestringWhere the document came from, for example csv_import.
import_job_idstringEverything created by one bulk import job.
exclude_reporting_legsbooleanLeave 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โ€‹

ParameterTypeDescription
directionstringsent 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_fromstringOnly count documents exchanged on or after this time (ISO 8601).
date_tostringOnly count documents exchanged on or before this time (ISO 8601).
exclude_reporting_legsbooleanDefaults 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.
limitintegerHow many partners to return at most. Default 200, minimum 1, maximum 500.
scopestringgroup 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
}
FieldMeaning
peppol_idThe 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.
nameThe 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, receivedHow many documents went each way inside the window you asked for.
totalOn a row, sent + received.
last_seenWhen 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.

The top-level total is not a count of your partners

It 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

Statuserror_codeWhen
400INVALID_DIRECTIONdirection 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.

Correction to an earlier version of this page

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"}
)
PointDetail
Permissiontransactions:read.
legRequired on this call: received for the buyer's answer, authority for the Oman Tax Authority's answer on the invoice's Tax Data Document.
ResponseThe ApplicationResponse XML, exactly as exchanged.
When there is none404 with the code MLS_NOT_AVAILABLE.
AvailabilityOnly on a service with the Oman pack switched on. It is not mounted on app.goroute.ai today.
Do not use the core MLS call for the buyer's answer

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.

PointDetail
Permissiontransactions:export
langen (the default) or ar. Arabic labels appear only on an Oman document โ€” see What lang=ar actually does
currency_displayauto (the default), sign, code or symbol โ€” see How amounts are printed
hybridBoolean, 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
FilenameBuilt 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

Statuserror_codeWhen
404TRANSACTION_NOT_FOUNDNo transaction with that id in your organisation
500PDF_GENERATION_FAILEDThe transaction exists but the PDF could not be produced
transactions:export is a permission in its own right

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

ValueWhat appears on the page
auto (default)The Central Bank of Oman's rial sign, drawn as artwork to the left of the figure
signThe same as auto
codeOMR 79.000
symbol79.000 ุฑ.ุน.

On any other currency:

ValueWhat appears on the page
auto (default)Behaves as code
codeThe figure first, then the code: 120.00 EUR
sign or symbolThe 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

Statuserror_codeWhen
404TRANSACTION_NOT_FOUNDNo transaction with that id in your organisation
400CANNOT_RETRY_RECEIVEDThe transaction is an inbound document. Only something you sent can be delivered again
400NOT_FAILEDThe transaction is not failed or retrying. There is no force option here, so a delivered or queued transaction cannot be retried
400NO_DOCUMENTThe document is no longer in storage, so there is nothing left to deliver
409ALREADY_QUEUEDAnother 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?โ€‹

RetryResend
What goes outThe stored document, unchangedA 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 transactionThe same one, reopenedA new one when the rebuild has a new document identifier; otherwise the existing failed one, reopened
Message idUnchangedNew for a new transaction; unchanged when the existing one is reopened
What it needsThe document in storage โ€” true whichever way you sent itThe structured invoice, which raw UBL sends do not have
Can change the issue dateNoYes, 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 whenThe send failed in transport and nothing about the document needs to changeThe 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. outcome is requeued. The update_timestamps default 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 422 OMAN_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_code and error_message are cleared; the failure itself was recorded when it happened, as a transaction.failed event. This is the same whether or not the original send carried an Idempotency-Key: the identifier is compared with the document the transaction holds, not with the key it was filed under.
Read this before you build on it: a resend needs the invoice, not the XML

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.

FieldMeaning
has_invoice_dataWhether GoRoute still holds the invoice as structured data. false means a resend cannot work at all.
can_resendtrue only when the transaction is outbound, has stored invoice data, and is failed or retrying.
invoice_dataThe stored invoice itself. Pass include_invoice_data=false to leave it out and get a lighter response.
status, direction, error_code, error_message, retry_countThe same values as on the transaction, repeated here so one call answers the whole question.
sender_peppol_id, receiver_peppol_id, document_type, document_numberWho 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:

FieldMeaning
outcomenew_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_idThe transaction carrying this delivery: the new one, or the existing one queued again. Track this id
new_transaction_idThe new transaction's id when outcome is new_transaction; null when requeued
original_transaction_idThe transaction you asked to resend
success, messagetrue, 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.

Sending a body โ€” even an empty one โ€” rewrites the invoice issue date to today

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, because update_timestamps defaults to true and 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

FieldDefaultMeaning
forcefalseResend even when the transaction is not in a failed state
update_timestampstrueSet 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

Statuserror_codeWhen
404TRANSACTION_NOT_FOUNDNo transaction with that id in your organisation
400CANNOT_RESEND_RECEIVEDThe transaction is an inbound document. You cannot resend something someone sent you
400NOT_FAILEDThe transaction is not failed or retrying, and force was not set to true
400NO_INVOICE_DATAThe original invoice data was never stored, so there is nothing to rebuild from
400INVOICE_INVALIDThe stored invoice no longer passes validation. The response lists the specific errors
422OMAN_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
422OMAN_RESEND_NOT_PINT_OMAn 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)
400NO_DOCUMENTThe 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
409SAME_DOCUMENT_EXISTSThe 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
409duplicate_invoice_number (in error)The rebuild has the identifier of a different transaction whose document is not the same. Nothing was sent
409ALREADY_QUEUEDAnother 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
500RESEND_ERRORThe 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:

  1. Log in to app.goroute.ai
  2. Navigate to Transactions
  3. Filter by status, date range, or document type
  4. 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"}

Next Stepsโ€‹