Tracking Delivery
After sending a document, track its journey through the Peppol network to confirm delivery.
Transaction Lifecycleโ
A document you send is created as queued and finishes as delivered or failed.
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
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. error_code, error_message and failed_at are set. | 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 |
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 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"
},
"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. |
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:
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
if status["status"] == "failed":
raise Exception(f"Delivery failed: {status.get('error')}")
# Wait before polling again
time.sleep(2)
raise TimeoutError(f"Transaction {transaction_id} did not complete within {timeout}s")
# Usage
result = wait_for_delivery("txn_abc123")
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 limit of two webhooks, 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. GoRoute does not retry a failed
# webhook delivery, and a failed transaction is terminal.
queue_for_review(transaction_id, data["error_code"])
If your endpoint is down when GoRoute sends one of these, the message is not re-sent for you. You recover it yourself from the delivery record โ see Deliveries. An endpoint that fails ten times in a row is switched off, silently.
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. |
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. |
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']}")
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 exact document that was exchanged, download it. This returns the original UBL XML, unchanged.
response = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/document",
headers={"X-API-Key": "your_api_key"}
)
Requires the transactions:read permission. Depending on how documents are stored,
you get either the XML directly (Content-Type: application/xml) or a JSON body
containing a download_url that is valid for one hour. Handle both.
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.
response = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}/mls",
params={"leg": "received"},
headers={"X-API-Key": "your_api_key"}
)
| Point | Detail |
|---|---|
| Permission | transactions:read. |
leg | sent for the ApplicationResponse GoRoute issued on receiving a document, received for the one that came back for a document you sent. Omit it and you get the sent leg if there is one, otherwise the received leg. |
| Response | The ApplicationResponse XML, exactly as exchanged. |
| When there is none | 404 with the code MLS_NOT_AVAILABLE. |
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, and
this call will answer 404 with MLS_NOT_AVAILABLE for it. That is the expected
answer, not a fault. 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.
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", "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"}