Error Handling
Two different things can go wrong when you send a document, and they reach you in two different places.
- The call is refused. Your
POSTcomes back with a 4xx or 5xx status and a JSON body. Nothing was queued. You find out immediately. - The call succeeds and the delivery fails. You get
202 Acceptedand atransaction_id, and some time later the transaction ends upfailed. You find out from a webhook or by polling.
This page covers both. The codes are different in each case, so they are kept apart here.
Until this revision, this page showed every error nested inside an error object
({"error": {"code": โฆ}}) and named codes such as RECEIVER_UNAVAILABLE,
CERTIFICATE_ERROR and RATE_LIMIT_EXCEEDED. GoRoute has never sent that shape and does not
have those codes. Code written against them either crashes on the first real error or
contains branches that can never be true. If you copied an example from this page before
2026-09-23, re-read The shape of an error and fix your handler.
The shape of an errorโ
Every refused call returns a flat JSON object. There is no nesting.
{
"error": "unauthorized",
"error_code": "MISSING_API_KEY",
"message": "Authentication required. Sign in to the dashboard, or include an X-API-Key header for API access.",
"request_id": "req_fb6e258163c94f3899f1b0d6ca9c54ea"
}
| Field | Type | What it is |
|---|---|---|
error | string | A short lower-case family name โ unauthorized, forbidden, not_found, validation_error, rate_limit_exceeded and internal_error are the ones you will meet most, and there are others. Group on this if you only want to know what kind of problem it is. |
error_code | string | The specific machine-readable code, upper-case with underscores. Branch on this. A few errors have no error_code โ see below. |
message | string | A sentence meant for a human. Never match on its text; the wording changes. |
request_id | string | req_ followed by 32 hex characters. Also returned in the X-Request-ID response header on every response, error or not. Quote it to support. |
Some errors add fields of their own โ retry_after on a 429, existing_transaction_id on a
409. They sit alongside the four above, not inside them.
Two exceptions to be aware of.
- Not every error carries
error_code. Where a failure is raised with a plain text reason rather than a structured one, you get{"error": "error", "message": โฆ, "request_id": โฆ}and noerror_code. A request to a path that does not exist is the easiest way to see it: it answers{"error":"error","message":"Not found","request_id":"req_โฆ"}. The 409 described below also has noerror_code, onlyerror. So readerror_codewith a default, never with a bare index. - A 422 does not use this shape at all. Request-body checks run before any GoRoute code
does, so a 422 comes back in the framework's own format: a
detailarray, one entry per bad field, each naming the field's location and what was wrong with it. There is noerror_codeon a 422.
Reading an error safelyโ
import requests
response = requests.post(url, headers=headers, json=body, timeout=30)
if response.status_code >= 400:
body = response.json()
if response.status_code == 422:
# Framework request-shape errors: a list under "detail".
for problem in body.get("detail", []):
print("Bad field:", problem.get("loc"), problem.get("msg"))
else:
code = body.get("error_code") or body.get("error") or "unknown"
print(code, "-", body.get("message"))
print("request_id:", body.get("request_id"))
body.get("error_code") can be absent, so the fallback matters. The old example on this page
read response.json().get("error", {}).get("code"), which raises AttributeError against a
real GoRoute error, because error is a string.
Error categoriesโ
| Category | HTTP code | Retry? | What it means |
|---|---|---|---|
| Send could not be set up | 400 | No | The body was well formed, but the document failed validation or no sender could be resolved. |
| Authentication | 401 | No | Missing, invalid or expired API key. |
| Authorization | 403 | No | Authenticated, but not allowed โ missing permission, inactive organisation, plan quota reached. |
| Not found | 404 | No | The transaction, participant or stored document you named does not exist. |
| Duplicate invoice number | 409 | No | An invoice number already used for a different invoice on the same date. See 409 Conflict. |
| Request body rejected | 422 | No | A required field is missing or has the wrong form. Framework format. |
| Rate limit | 429 | Yes, after the wait | Too many requests this minute or this day. |
| Server error | 500 | Yes | Something failed inside GoRoute. |
Errors on the callโ
400 Bad Requestโ
Always "error": "validation_error" with "error_code": "INVALID_REQUEST". The body was
acceptable; the send could not proceed.
{
"error": "validation_error",
"error_code": "INVALID_REQUEST",
"message": "Document validation failed with 2 errors: [BR-02] ProfileID is mandatory; [BR-12] Buyer electronic address (EndpointID) is mandatory",
"request_id": "req_2f1d7c8e9a0b4c5d8e6f7a0b1c2d3e4f"
}
The message carries up to five specific validation errors, then ... and N more errors if
there were more. Parse the message only to show it to a person โ validate before you send if
you want structured results, which is what
Validation is for.
Other things that land on INVALID_REQUEST: an Oman document whose own cbc:UUID is not the
value GoRoute derives from it, reported as [BTOM-002]; and a send with no sender_scheme or
sender_id where your organisation has no default participant to fall back on
("Sender not specified and no default participant found").
Fix: correct the document, or name the sender.
401 Unauthorizedโ
error_code | When |
|---|---|
MISSING_API_KEY | No X-API-Key header and no signed-in session. |
INVALID_API_KEY | The key is not recognised, or it has been revoked. |
EXPIRED_API_KEY | The key was valid and has passed its expiry date. |
{
"error": "unauthorized",
"error_code": "INVALID_API_KEY",
"message": "Invalid or revoked API key",
"request_id": "req_4b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e"
}
A 401 also carries the header WWW-Authenticate: ApiKey.
Fix: check the key, and check you are sending it in X-API-Key and not Authorization.
403 Forbiddenโ
You are authenticated. You are not allowed to do this.
error_code | When |
|---|---|
INSUFFICIENT_PERMISSION | The key or user lacks the permission the endpoint requires. Sending a document requires invoices:send. The message names the permission. |
INSUFFICIENT_SCOPE | The API key does not carry the scope the operation needs. |
ORGANIZATION_INACTIVE | The organisation is inactive or suspended. |
ORG_RESIDENCY_MISMATCH | The organisation belongs to a different GoRoute estate than the one you called. Omani tenants are served in-country. |
PLAN_QUOTA_EXCEEDED | You have reached a limit on your billing plan. |
{
"error": "forbidden",
"error_code": "INSUFFICIENT_PERMISSION",
"message": "This action requires the 'invoices:send' permission.",
"request_id": "req_7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"
}
A PLAN_QUOTA_EXCEEDED body carries extra fields โ quota, limit, current_usage,
current_plan and upgrade_url โ so you can tell a user which limit they hit.
Fix: grant the permission or scope to the key, or upgrade the plan.
404 Not Foundโ
error_code | When |
|---|---|
TRANSACTION_NOT_FOUND | No transaction with that id belongs to your organisation. |
PARTICIPANT_NOT_FOUND | No participant with that id belongs to your organisation. |
DOCUMENT_NOT_STORED | The transaction exists, but its document was not kept. |
{
"error": "not_found",
"error_code": "TRANSACTION_NOT_FOUND",
"message": "Transaction 550e8400-e29b-41d4-a716-446655440000 not found",
"request_id": "req_0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f"
}
DOCUMENT_NOT_STORED uses "error": "document_not_found" rather than not_found, which is
another reason to branch on error_code and not on error. A 404 from a path that does not
exist at all is the short form with no error_code:
{"error": "error", "message": "Not found", "request_id": "req_โฆ"}.
A receiver that is not registered on the Peppol network does not produce a 404 from the send
call, and there is no RECEIVER_NOT_FOUND code. You find out at delivery time โ see
Delivery failures. To check before you send, use
Participant Lookup.
409 Conflict โ duplicate invoice numberโ
{
"error": "duplicate_invoice_number",
"message": "This invoice number has already been used for a different invoice on this date. Each invoice needs its own number. To change an invoice that has already been sent, issue a credit note and send the corrected invoice under a new number.",
"existing_transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"existing_created_at": "2026-09-20T09:14:22.115300",
"request_id": "req_9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b"
}
Two things to notice. There is no error_code here: the code you branch on is error, and
its value is duplicate_invoice_number. And the message is one of two sentences โ Omani
sellers and PINT OM documents get an Oman-specific wording that explains why the document
identifier depends on the invoice number. Branch on error, show message.
This is the refusal most integrations meet by accident, and it applies to every seller in every country. The full rule, and what to do about it, is on Send an Invoice.
Fix: give the invoice a new number. If you are correcting an invoice that has already been sent, issue a credit note and send the correction under a new number.
422 Unprocessable Entityโ
The request body failed its shape checks before any sending was attempted: a missing
receiver_scheme or receiver_id, an empty document, a document_type that is not a
Peppol document type identifier, a process_id that is not a URN. As noted above, a 422 does
not use the flat shape โ it returns a detail array naming each bad field.
Fix: correct the request body.
429 Too Many Requestsโ
Two separate limits, and the code tells you which one you hit.
error_code | When |
|---|---|
RATE_LIMIT_MINUTE | The per-minute limit for your organisation. |
RATE_LIMIT_DAY | The per-day limit for your organisation. |
{
"error": "rate_limit_exceeded",
"error_code": "RATE_LIMIT_MINUTE",
"message": "Rate limit exceeded. Limit: 120/minute",
"retry_after": 37,
"request_id": "req_1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e"
}
The limit quoted is the effective limit โ your key's own setting clamped to your plan's ceiling โ so it is the number you were actually held to.
A 429 also sets four response headers: Retry-After (seconds), X-RateLimit-Limit,
X-RateLimit-Remaining (always 0 on a refusal) and X-RateLimit-Reset (a Unix timestamp).
Use Retry-After; it is the same number as retry_after in the body.
import time
def send_with_retry(url, headers, body, max_retries=5):
"""Send, honouring Retry-After on a 429."""
for attempt in range(max_retries):
response = requests.post(url, headers=headers, json=body, timeout=30)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 60))
print(f"Rate limited, waiting {wait}s")
time.sleep(wait)
continue
return response
raise RuntimeError("Max retries exceeded")
500 Internal Server Errorโ
One code, and only one:
{
"error": "internal_error",
"error_code": "INTERNAL_ERROR",
"message": "An internal error occurred",
"request_id": "req_5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
}
The message is deliberately generic; the detail is in our logs against that request_id.
Fix: retry. If it keeps happening, send us the request_id.
Delivery failuresโ
A document that was accepted (202 Accepted) is sent to the receiver's Access Point
afterwards. If that fails, the transaction ends up retrying and then failed, and the
reason appears as two fields on the transaction โ error_code and error_message โ and in
the transaction.retrying and transaction.failed webhook messages.
These are not the same codes as the ones above. They come from the sending path, not from the API.
Codes from a real sendโ
error_code | What happened |
|---|---|
TRANSPORT_ERROR | The AS4 sender could not deliver to the receiving Access Point. Comes back from the sender itself. |
SMP_404 | The receiver could not be resolved on the network โ no SMP entry for that participant and document type. Also comes from the sender. |
SMP_LOOKUP_UNAVAILABLE | The Peppol directory did not answer (the SML, or the receiver's SMP, was down or unreachable), so GoRoute could not check whether the receiver is registered and did not send the document. Not a problem with the Peppol ID. Retried like TRANSPORT_ERROR. Also recorded when the sender reports TRANSPORT_ERROR and the receiver's SMP cannot be read in full. When the SML names the receiver's SMP and only reading it failed, a receiver outside Oman is sent to that SMP instead of getting this code. |
TIMEOUT | The sending request timed out. |
CONNECTION_ERROR | GoRoute could not reach its own AS4 sender. |
MISSING_COUNTRY_C1 | The sending organisation has no country on file, so the Peppol envelope could not be built. This one is fixable by you: set the organisation's country and send again. |
UNKNOWN_ERROR | The send failed for a reason the sender did not classify. |
An unclassified HTTP failure from the AS4 sender is recorded as the HTTP status code itself โ
so an error_code that is a bare number such as "502" is possible. Treat anything you do
not recognise as unclassified and show error_message.
SMP_404 and TRANSPORT_ERROR are the two codes GoRoute treats as "the receiver is not
reachable". For an Omani seller that has a further consequence: the invoice is still reported
to the Tax Authority under a substitute participant. See
Oman and
Tracking Delivery.
SMP_LOOKUP_UNAVAILABLE is not one of them. An outage of the
directory says nothing about the receiver, so no substitute report is filed for it: only an
answer from the directory (the receiver is not there, or does not take the document) leads to
one. On GoRoute's Oman
service, an Omani invoice that still ends failed with this code is checked again
automatically, every 30 minutes for the first day and every 6 hours after that, and the same
transaction is sent again as soon as the directory answers (its error_message says so while
it waits). After 7 days it is no longer checked automatically; retry it with
POST /transactions/{id}/retry. The same holds for the BIS 3.0 copy GoRoute sends to a buyer
who takes only BIS 3.0: while the directory does not answer, the copy waits and the invoice
says why (metadata.oman_reporting.bis_copy_waiting_reason); no substitute report is filed
for it. If the copy still cannot be sent after 7 days, the invoice's transaction.failed
is sent, with fallback: "bis_copy".
Codes from the sandbox test participantsโ
If you send to one of the 9999:test-โฆ sandbox participants, the failure is simulated and
the codes are the scenario's own. You will not see these against a real receiver.
error_code | Sandbox participant |
|---|---|
RECIPIENT_NOT_FOUND | 9999:test-recipient-not-found |
AS4_DELIVERY_FAILED | 9999:test-as4-failure |
TIMEOUT | 9999:test-timeout |
VALIDATION_FAILED | 9999:test-validation-failure |
TEMPORARY_ERROR | 9999:test-retry-then-success, on the attempt that fails before the retry succeeds |
Two more appear when the sandbox itself refuses: SANDBOX_NOT_ALLOWED, when you address a
9999:test-โฆ participant on an environment where sandbox transport is switched off, and
UNKNOWN_SANDBOX_RECEIVER, when you address a 9999: participant that is not one of the
defined scenarios.
GoRoute's in-country Oman service has no sandbox at all, and never sends to a 9999
receiver. POST /documents refuses one on the call, before anything is recorded: HTTP 422
with error_code SANDBOX_RECEIVER_NOT_ALLOWED. POST /invoices answers
validation_failed with the rule EDEC_001 instead, because it checks the buyer's Peppol ID
first. A transaction that already exists and is retried there ends failed with
SANDBOX_RECEIVER_NOT_ALLOWED. See Test identifiers.
Reading a failure from the transactionโ
transaction = requests.get(
f"https://app.goroute.ai/peppol-api/api/v1/transactions/{transaction_id}",
headers={"X-API-Key": api_key},
timeout=30,
).json()
if transaction["status"] == "failed":
print("Failed:", transaction["error_code"], "-", transaction["error_message"])
print("Attempts:", transaction["retry_count"])
error_code and error_message are both null unless the send failed.
Tracking Delivery covers the full status list and how to
ask for a failed transaction to be sent again.
Being told about failuresโ
Subscribe to transaction.failed and transaction.retrying. Every GoRoute webhook message
has the same four top-level fields โ id, event_type, created_at and data โ and the
event name is in event_type, not type.
# In your webhook handler
@app.post("/webhooks/goroute")
async def handle_webhook(request: Request):
event = await request.json()
if event["event_type"] == "transaction.failed":
data = event["data"]
notify_team(
f"Peppol delivery failed for transaction {data['transaction_id']}",
error_code=data["error_code"],
error_message=data["error_message"],
)
return {"status": "ok"}
The data of a transaction.failed message contains exactly transaction_id, status,
error_code, error_message and retry_count. There is no nested error object and no
transaction["id"]. transaction.retrying carries the same five fields.
Webhook Events Reference is the field-by-field account.
A worked clientโ
This is the whole pattern in one piece: retry a 429 on Retry-After, retry a 500, raise
everything else with the code and the request id attached.
import logging
import time
import requests
class GoRouteError(Exception):
"""A refused GoRoute API call."""
def __init__(self, status: int, code: str, message: str, request_id: str = None):
self.status = status
self.code = code
self.message = message
self.request_id = request_id
super().__init__(f"[{status} {code}] {message}")
class GoRouteClient:
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://app.goroute.ai/peppol-api"
self.logger = logging.getLogger(__name__)
def send_document(self, xml: str, receiver_scheme: str, receiver_id: str) -> dict:
"""Queue a UBL document for delivery. Returns the 202 body."""
return self._request(
"POST",
"/api/v1/documents",
body={
"receiver_scheme": receiver_scheme,
"receiver_id": receiver_id,
"document": xml,
},
)
def _request(self, method: str, path: str, body: dict = None, max_retries: int = 3) -> dict:
url = f"{self.base_url}{path}"
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
for attempt in range(max_retries):
response = requests.request(
method, url, headers=headers, json=body, timeout=30
)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 60))
self.logger.warning("Rate limited, waiting %ss", wait)
time.sleep(wait)
continue
if response.status_code >= 500 and attempt < max_retries - 1:
wait = 2 ** attempt
self.logger.warning("Server error, retrying in %ss", wait)
time.sleep(wait)
continue
if response.status_code >= 400:
raise self._error(response)
return response.json()
raise RuntimeError("Maximum retries exceeded")
@staticmethod
def _error(response) -> GoRouteError:
"""Build a GoRouteError from either error shape."""
try:
payload = response.json()
except ValueError:
return GoRouteError(response.status_code, "unknown", response.text[:200])
if response.status_code == 422:
problems = "; ".join(
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
for item in payload.get("detail", [])
)
return GoRouteError(422, "request_invalid", problems or "Request body rejected")
return GoRouteError(
response.status_code,
payload.get("error_code") or payload.get("error") or "unknown",
payload.get("message", ""),
payload.get("request_id"),
)
# Usage
client = GoRouteClient("your_api_key")
try:
result = client.send_document(invoice_xml, "9959", "987654321")
print("Queued:", result["transaction_id"])
except GoRouteError as e:
if e.code == "INVALID_REQUEST":
print("The document was rejected:", e.message)
elif e.code == "duplicate_invoice_number":
print("That invoice number is already in use. Give it a new one.")
elif e.code in ("MISSING_API_KEY", "INVALID_API_KEY", "EXPIRED_API_KEY"):
print("Check your API key.")
else:
print("Error:", e.message, "- quote request_id", e.request_id)
Loggingโ
Log the request_id on every failure. It is the one value that lets support find your exact
call.
logging.basicConfig(
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
level=logging.INFO,
)
logger = logging.getLogger("goroute")
try:
result = client.send_document(invoice_xml, "9959", "987654321")
logger.info(
"Invoice %s queued as %s", invoice_number, result["transaction_id"]
)
except GoRouteError as e:
logger.error(
"Invoice %s refused: %s", invoice_number, e.message,
extra={
"invoice_number": invoice_number,
"status": e.status,
"error_code": e.code,
"request_id": e.request_id,
},
)
raise