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 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)
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. error_code, error_message and failed_at are set.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
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 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:

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

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']}")

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.

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. GoRoute does not retry a failed
# webhook delivery, and a failed transaction is terminal.
queue_for_review(transaction_id, data["error_code"])
The delivery is attempted once

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.

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.
directionstringsent or received.
sender_idstringSender identifier, matched exactly, without the scheme โ€” 123456789, not 9959:123456789.
receiver_idstringReceiver identifier, same rule as sender_id.
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']}")

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 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"}
)
PointDetail
Permissiontransactions:read.
legsent 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.
ResponseThe ApplicationResponse XML, exactly as exchanged.
When there is none404 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:

  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", "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โ€‹