Webhook Setup
A webhook is a small JSON message that GoRoute sends to an address you own, by HTTP POST, whenever something happens to one of your documents. Registering one is how you receive incoming Peppol documents without polling for them.
How It Worksβ
βββββββββββββββββ βββββββββββββββββ βββββββββββββββββ βββββββββββββββββ
β Sender's ββββΆβ GoRoute ββββΆβ Webhook ββββΆβ Your App β
β Access Point β β Receives β β Delivery β β Processes β
βββββββββββββββββ βββββββββββββββββ βββββββββββββββββ βββββββββββββββββ
- A sender transmits a document via their Access Point
- GoRoute receives the document at your registered Peppol ID
- GoRoute validates and processes the document
- GoRoute sends a
transaction.receivedmessage to your webhook address - Your application reads the transaction ID from that message and fetches the document
Note step 5. The webhook message tells you that a document has arrived and identifies it. It does not contain the document. You fetch the document yourself, using the transaction ID in the message β see Fetch the document.
Whether a failed delivery is sent again depends on the GoRoute service you call. A
service with automatic retry sends an event your server failed to accept up to five more
times, over about nine hours (1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours after
each failed attempt). A service without it attempts each delivery once: if your endpoint is
down for ten minutes, the messages sent in those ten minutes are not retried for you, and
you recover them yourself, from the delivery record, using the endpoints in
Deliveries. Every delivery from a service
with automatic retry carries an X-Webhook-Attempt header, so look for it rather than
assume. This is covered in full under
Delivery, failure and recovery.
Register a Webhookβ
Create a webhook with POST /api/v1/webhooks. It needs the webhooks:manage permission and
returns 201 Created.
import requests
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/peppol",
"events": ["transaction.received"],
"description": "Incoming documents, production",
},
)
response.raise_for_status()
webhook = response.json()
print(f"Webhook ID: {webhook['id']}")
print(f"Endpoint: {webhook['url']}")
# Store this now. It is returned once and never again.
save_webhook_secret(webhook["secret"])
The fields the API acceptsβ
The create request accepts exactly these four fields. Anything else is rejected.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | The address GoRoute posts to. Must be https:// (plain http:// is refused outside local development), must not be localhost, and must not resolve to a private or reserved IP address. The host is re-resolved and re-checked immediately before every delivery. |
events | array of strings | Yes | At least one event name. Each name is checked against the event catalogue and an unknown name is refused with 422 Unprocessable Entity. |
description | string | No | Free text, up to 255 characters, for your own reference. |
metadata | object | No | Any JSON object you want stored alongside the webhook. GoRoute does not interpret it. |
You do not choose the secret and you cannot send one. GoRoute generates it, and this is the only field of its kind you need to think about β see below.
The secret is shown onceβ
The 201 response body is the webhook record plus a secret field. It is a string
beginning whsec_. That field appears in this one response and in no other. Every later
read of the webhook β the single-webhook read, the list, the update β returns
secret_preview instead, which is the first eight characters only. Six of those eight are
the whsec_ prefix that every secret carries, so the preview is not enough to tell two
secrets apart or to check that you saved the right one. There is no endpoint that reveals
the secret again and no rotation endpoint.
If you lose it, your only option is to delete the webhook and create a new one, which produces a new secret.
Store it wherever you keep credentials, before your code does anything else with the response. You need it to verify that a delivery really came from GoRoute.
Sandbox accounts are cappedβ
An account on the developer sandbox plan may have at most two active webhooks. A
demo read-only account may have none. Creating one past the cap fails with 403 Forbidden
and an error of sandbox_limit_reached. Paid plans are not capped by this rule.
Two is the starting number, not a fixed rule. Since 22 September 2026 GoRoute can raise
the cap for your organisation, so ask us if you are building something on the sandbox that
genuinely needs more webhooks β a platform serving several sellers, for instance. Write to
admin@goroute.ai, which is the address the 403 itself gives. There is no API call and no
setting in your account that changes it; we set it on our side.
A raised cap is on GoRoute's production service and on its test service. It reached
test on 22 September 2026 and production later the same day, on 22 September 2026 at
22:05 UTC, when production was upgraded to the build test was already running. The
two-webhook default, the read-only account having none, the 403 and paid plans being
uncapped are true on both, and always were.
The events GoRoute sendsβ
These are the events about sending and receiving documents, which is what most integrations need. Each one is listed because a specific line of the platform sends it, not because it appears in a catalogue.
| Event | GoRoute sends it when |
|---|---|
transaction.received | A document addressed to you arrives from the Peppol network. This is the one you want for receiving. |
transaction.queued | You submitted a document for sending and GoRoute accepted it for processing. |
transaction.delivered | A document you sent reached the recipient's Access Point. |
transaction.retrying | A document you sent failed for a reason worth another attempt, and will be sent again. |
transaction.failed | A document you sent could not be delivered and will not be attempted again. |
GoRoute sends other events too β Invoice Responses and Oman tax reporting. They are not repeated here; the full list, with the payload of each, is on the Webhooks reference. One list, one place.
The event catalogue behind the events check is long, and the API will happily accept a
name from it. Acceptance is not a promise of delivery: most catalogued names are not
emitted anywhere in the product today, so a subscription to one is a subscription to
silence. Every event with a section on the
Webhooks reference is traced to a line of the platform that
sends it. For any other name, check its status field in
GET /api/v1/events/catalog β live
means something dispatches it, planned means nothing does β or ask support before you
build on it.
Earlier revisions of this page listed events under a document. prefix, a
participant. prefix and an organisation quota name. None of those has ever existed;
the create call rejects them with 422.
What a delivery looks likeβ
Every message has the same four top-level fields.
{
"id": "evt_9f1c2d3e4b5a678901234567890abcde",
"event_type": "transaction.received",
"created_at": "2026-01-15T10:30:45.123456",
"data": {}
}
| Field | Description |
|---|---|
id | The event ID, evt_ followed by 32 hex characters. The same value is in the X-Event-ID header. Use it for idempotency. |
event_type | The event name, one of the table above. The same value is in the X-Event-Type header. |
created_at | When GoRoute built the message. It is UTC, and it carries no timezone suffix β parse it as UTC rather than as local time. |
data | The event-specific body, described below. |
The field is event_type. If your handler reads a field called type or event, it will
raise a key error on the first message, before any of your business logic runs.
An incoming documentβ
{
"id": "evt_9f1c2d3e4b5a678901234567890abcde",
"event_type": "transaction.received",
"created_at": "2026-01-15T10:30:45.123456",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"direction": "received",
"status": "delivered",
"sender_peppol_id": "9959:987654321",
"receiver_peppol_id": "0192:123456789",
"document_type": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1",
"message_id": "phase4-msg-abc123",
"received_at": "2026-01-15T10:30:44+00:00"
}
}
Those eight fields are the whole of data for an incoming document. In particular there is
no invoice number, no currency, no total, no supplier name and no attachment list. If you
need any of that, read it out of the document itself after you fetch it.
transaction_id is the handle for everything that follows: fetching the document, looking up
the transaction, and replaying webhooks for it.
A sent documentβ
{
"id": "evt_1a2b3c4d5e6f708192a3b4c5d6e7f809",
"event_type": "transaction.delivered",
"created_at": "2026-01-15T10:32:11.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 instead.
Verify the signatureβ
Every delivery carries these four headers, alongside the usual Content-Type.
| Header | Value |
|---|---|
X-Webhook-Signature | The signature. Format described below. |
X-Webhook-ID | The ID of the webhook subscription the message was sent to. |
X-Event-ID | The event ID, the same value as id in the body. |
X-Event-Type | The event name, the same value as event_type in the body. |
The signature header value is two comma-separated parts:
X-Webhook-Signature: t=1768473045,v1=1f8ac10f23c5b5bc1173d43d4e8f97ab5b3a9c1d2e0f4a6b8c9d0e1f2a3b4c5d
tis the Unix timestamp, in seconds, of the moment the message was signed.v1is an HMAC-SHA256 digest, hex-encoded.
The digest is not taken over the request body alone. It is taken over the timestamp, a full stop, and then the exact bytes of the body:
signed string = "<t>" + "." + <raw request body>
Re-serialising the JSON before you check it will change the bytes and the check will fail. Verify against the raw body you received.
import hashlib
import hmac
import time
def verify_signature(
raw_body: bytes,
header_value: str,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
"""Confirm a delivery was signed by GoRoute with our webhook secret."""
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; rejecting an old
# timestamp is how you stop a captured message being sent to you again.
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)
Earlier revisions of this page, and some of our own integration guides, described a differently named signature header carrying a bare hash of the request body with a prefix. That header has never been sent. Code written against it finds nothing in the header it looks for and rejects every genuine delivery as forged.
If your webhook handler returns 401 to everything we send, this is why. Switch to the header and the format above.
Implement Your Endpointβ
Basic handlerβ
import hashlib
import hmac
import requests
from flask import Flask, jsonify, request
app = Flask(__name__)
WEBHOOK_SECRET = load_webhook_secret() # the value saved at creation time
API_KEY = load_api_key()
BASE_URL = "https://app.goroute.ai/peppol-api/api/v1"
@app.route("/webhooks/peppol", methods=["POST"])
def handle_webhook():
if not verify_signature(
request.get_data(),
request.headers.get("X-Webhook-Signature"),
WEBHOOK_SECRET,
):
return jsonify({"error": "Invalid signature"}), 401
event = request.get_json()
if event["event_type"] == "transaction.received":
handle_incoming_document(event["data"])
elif event["event_type"] == "transaction.delivered":
handle_delivery_confirmation(event["data"])
elif event["event_type"] == "transaction.failed":
handle_delivery_failure(event["data"])
# Any 2xx acknowledges the delivery. Anything else marks it failed.
return jsonify({"status": "received"})
def handle_incoming_document(data: dict):
"""Process an incoming Peppol document."""
transaction_id = data["transaction_id"]
print(f"Received {data['document_type']} from {data['sender_peppol_id']}")
document_xml = fetch_document(transaction_id)
process_document(transaction_id, document_xml)
Fetch the documentβ
The webhook message does not contain the document. Fetch it with the transaction ID, using
GET /api/v1/transactions/{transaction_id}/document. It needs the transactions:read
permission and returns the original UBL XML β not JSON.
def fetch_document(transaction_id: str) -> bytes:
"""Download the original UBL XML for a transaction."""
response = requests.get(
f"{BASE_URL}/transactions/{transaction_id}/document",
headers={"X-API-Key": API_KEY},
)
response.raise_for_status()
return response.content
There are two document endpoints and they differ only in how the bytes come back:
| Endpoint | Returns |
|---|---|
GET /api/v1/transactions/{transaction_id}/document | The XML as a file download, named transaction_<id>.xml. |
GET /api/v1/transactions/{transaction_id}/document/content | The same XML as raw text, for previewing in a browser or an editor. |
Both answer 404 if the transaction or its stored document cannot be found.
There is no way to fetch a received document by a document ID. Documents are addressed
through their transaction. Earlier revisions of this page fetched from a path under
/api/v1/documents β that prefix accepts only POST, for sending and for validating, and has
never answered a GET.
Async handlerβ
Answer fast, work later. GoRoute waits at most 30 seconds for your response and on some deployments only 10, and treats a timeout as a failed delivery. Answer within 10 seconds and you are inside both.
import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
@app.post("/webhooks/peppol")
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
raw_body = await request.body()
if not verify_signature(
raw_body,
request.headers.get("X-Webhook-Signature"),
WEBHOOK_SECRET,
):
return Response(status_code=401)
event = await request.json()
background_tasks.add_task(process_event, event)
return {"status": "received"}
async def process_event(event: dict):
if event["event_type"] == "transaction.received":
await process_incoming_document(event["data"])
async def process_incoming_document(data: dict):
transaction_id = data["transaction_id"]
async with httpx.AsyncClient() as client:
response = await client.get(
f"{BASE_URL}/transactions/{transaction_id}/document",
headers={"X-API-Key": API_KEY},
)
response.raise_for_status()
document_xml = response.content
await save_to_database(transaction_id, document_xml)
await notify_accounts_payable(transaction_id)
Security Best Practicesβ
1. Verify every deliveryβ
Use the check in Verify the signature. Reject anything that fails it, and reject anything with no signature header at all.
2. Use HTTPSβ
Your endpoint must use HTTPS with a valid certificate. GoRoute refuses to register a plain
http:// address outside local development, and refuses any address that points at
localhost or at a private or reserved IP range.
3. Be idempotentβ
The same event ID can arrive more than once β you can retry a delivery yourself, you can
replay every webhook for a transaction, and some GoRoute deployments also retry a failed
delivery automatically. A retry repeats the event's id and body, and the signature changes
with every attempt. Key on id. It is the one value that is the same on every attempt of
an event and is sent by every deployment.
def handle_webhook(event: dict):
if db.event_exists(event["id"]):
return {"status": "already_processed"}
process_event(event)
db.mark_event_processed(event["id"])
return {"status": "processed"}
4. Respond quicklyβ
Return a 2xx within 10 seconds. Some GoRoute deployments wait 30 seconds for your response and some wait only 10, so build to the shorter number and you are safe on either. After the wait is over GoRoute gives up on the connection and records the delivery as failed. What happens next depends on the deployment: one that retries sends the same event again later, so a slow handler sees it several times and an endpoint that is always slow ends up switched off; one that does not retry simply loses that event unless you go and fetch it. The status code is your answer β GoRoute reads at most the first 1,000 bytes of your response body, for the delivery record β and an attempt may be cut off while your server is still sending, so send the status line first and the body afterwards.
# Slow: the whole job runs before GoRoute gets an answer.
@app.post("/webhooks/peppol")
def webhook():
process_everything_synchronously(request.get_json())
return {"status": "ok"}
# Fast: acknowledge, then work.
@app.post("/webhooks/peppol")
def webhook():
queue.enqueue(process_event, request.get_json())
return {"status": "ok"}
Delivery, failure and recoveryβ
Read this before you design your error handling.
Everything in this section about automatic retries needs a GoRoute deployment that has automatic retry, and not every deployment does. On one that does not, each event is attempted once, and a failed delivery is never sent again on its own β recovery is yours to trigger, with the manual retry or the per-transaction replay described below. Design for that and you are safe either way.
Finding out takes one delivery: if X-Webhook-Attempt reaches your endpoint, the service
retries; if it never does, it does not. The full comparison, including the response timeout
and which headers are sent, is in
Delivery, failure and recovery in
the webhook reference, which is the one copy of it.
Where automatic retry is available, up to six attempts per event. A non-2xx response, a
connection error or a timeout is recorded against the delivery and against the webhook's
health counters, and the same event (same id, same body) is sent again 1 minute,
5 minutes, 30 minutes, 2 hours and 6 hours after each failed attempt in turn β about nine
hours in all. The first 2xx ends it. Each attempt carries X-Webhook-Attempt (1 to 6)
and is listed as its own delivery. Full table:
Delivery, failure and recovery.
How long an attempt waits for you, and what is read. A deployment with automatic retry waits 10 seconds for your response, with a 5-second connect timeout, and ends the attempt 15 seconds after it starts even if your server is still sending; one without it waits 30 seconds and sets no separate limit on the attempt. Answer within 10 seconds and neither can cut you off. Once the status line has arrived the status code decides, and at most the first 1,000 bytes of the body are read, for the delivery record β that part is the same either way.
Ten undelivered events in a row switch the webhook off. An event is undelivered when its
last automatic attempt has failed; a failed test event or manual retry counts as one too.
A success resets the count (consecutive_failures) to zero. On reaching ten, the webhook's
status is set to disabled, last_error says why, and it stops receiving events entirely:
new events are not queued for it and a queued retry that comes due is dropped. Re-enable it by setting
status back to active with the update call; that clears the count and the reason.
Because only an event's last attempt counts, on a deployment with automatic retry a webhook
whose endpoint has stopped answering stays active for at least eight and a half hours (its
first event's full retry schedule), and is switched off only once ten events have each run
out of retries. Until then every new event is attempted six times. Without automatic retry
there is no such grace: one attempt is the whole of an event, so ten events in a row to a
dead endpoint switch the webhook off as fast as ten events arrive.
GoRoute has no notification for a webhook being switched off. Nothing is emailed and no
event is sent: account.webhook_disabled is listed in the event catalogue as a planned
event, and nothing sends it yet. Check status, last_error and consecutive_failures on your webhook
periodically β all three are returned by the read and list calls β or you will find out
from the documents you did not receive.
Recovery beyond that is yours to trigger. Two ways, both described in the next section: retry an individual failed delivery, or replay every webhook for a transaction. An event is attempted at most ten times in all, automatic attempts included, so after a full automatic run four manual retries remain; a replay starts a new event. Manual retries wait five seconds between them.
A webhook has three states, in status:
| Status | Meaning |
|---|---|
active | Receiving events. |
paused | You paused it with the update call. No events are delivered. |
disabled | GoRoute switched it off after ten undelivered events in a row; last_error says so. No events are delivered. |
Deliveries: see what we sent, and send it againβ
Every attempt is recorded, whether it succeeded or not, and the record is queryable. This is where you go when a document did not arrive.
| Endpoint | Purpose |
|---|---|
GET /api/v1/webhooks/deliveries | List delivery attempts, with filters. |
GET /api/v1/webhooks/deliveries/{delivery_id} | One attempt in full, including the complete request and response bodies. |
POST /api/v1/webhooks/deliveries/{delivery_id}/retry | Send a failed delivery again. |
POST /api/v1/transactions/{transaction_id}/replay-webhooks | Re-send every webhook for one transaction. |
Find what failedβ
GET /api/v1/webhooks/deliveries needs webhooks:read. Filters are webhook_id,
transaction_id, event_type, status (pending, success or failed), from_date and
to_date (ISO 8601). limit is between 1 and 100 and defaults to 50. Paging is by cursor:
pass the next_cursor from one response as the cursor of the next.
response = requests.get(
f"{BASE_URL}/webhooks/deliveries",
params={"status": "failed", "limit": 50},
headers={"X-API-Key": API_KEY},
)
response.raise_for_status()
page = response.json()
for delivery in page["items"]:
print(
delivery["created_at"],
delivery["event_type"],
delivery["response_status"],
delivery["error_message"],
)
while page["has_next"]:
response = requests.get(
f"{BASE_URL}/webhooks/deliveries",
params={"status": "failed", "limit": 50, "cursor": page["next_cursor"]},
headers={"X-API-Key": API_KEY},
)
page = response.json()
# ... handle page["items"] the same way
Each item carries the delivery ID, the webhook ID and URL, the transaction ID, the event
type and event ID, the attempt number, the status, the HTTP status we got back, the response
time in milliseconds, any error message, and request_body_preview β the first kilobyte of
what we sent, so you can see the message without a second call.
Read one in fullβ
GET /api/v1/webhooks/deliveries/{delivery_id} returns the same fields plus the complete
request_body and response_body, and two fields that tell you whether recovery is still
possible: can_retry and retry_count_remaining.
Retry itβ
POST /api/v1/webhooks/deliveries/{delivery_id}/retry needs webhooks:manage. It re-sends
the same event, with the same event ID, so an idempotent handler is safe.
delivery_id = page["items"][0]["id"]
response = requests.post(
f"{BASE_URL}/webhooks/deliveries/{delivery_id}/retry",
headers={"X-API-Key": API_KEY},
)
if response.status_code == 200:
result = response.json()
print(f"Attempt {result['attempt_number']}: {result['status_code']}")
print(f"Attempts remaining: {result['retry_count_remaining']}")
The guardrails, and the status code each one produces:
| Condition | Response |
|---|---|
| The delivery ID is unknown, or belongs to another organisation | 404 with error code DELIVERY_NOT_FOUND |
| Fewer than five seconds since the last attempt finished | 429 with error code RETRY_COOLDOWN |
| The delivery did not fail, or ten attempts have already been made | 400 with error code RETRY_FAILED |
Replay everything for a transactionβ
If several events for one document went missing, replay them together with
POST /api/v1/transactions/{transaction_id}/replay-webhooks. It needs transactions:read,
is limited to 10 replays per minute per organisation, and takes an optional event_type in
the body to replay just one kind.
response = requests.post(
f"{BASE_URL}/transactions/{transaction_id}/replay-webhooks",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={},
)
result = response.json()
print(f"Queued {result['replayed_count']} deliveries")
Replayed messages are delivered asynchronously, so the results appear in the deliveries list
rather than in this response. Their data is rebuilt from the transaction and is marked
with "replayed": true, so it is not a byte-for-byte copy of the original message.
Testing Webhooksβ
Send a test eventβ
POST /api/v1/webhooks/{webhook_id}/test posts a test.ping event to the webhook
immediately, from the request thread, and tells you what your server said.
response = requests.post(
f"{BASE_URL}/webhooks/{webhook_id}/test",
headers={"X-API-Key": API_KEY},
)
result = response.json()
print(result["success"], result["status_code"], result["response_time_ms"], result["error"])
Two things worth knowing. The test event is delivered whether or not you subscribed to
test.ping, so it always reaches you. And its data is a fixed placeholder β test,
message and timestamp β not a document, so use it to prove connectivity and signature
verification, not to exercise your document handling.
Local developmentβ
Use ngrok or similar to expose your local server. GoRoute will not deliver to localhost.
npm install -g ngrok
ngrok http 3000
# Register the https://<subdomain>.ngrok.io/webhooks/peppol address it prints.
Managing Webhooksβ
Listβ
GET /api/v1/webhooks needs webhooks:read. It pages with page and page_size
(1 to 100, default 50) and returns items, total, page, page_size, pages,
has_next and has_prev.
response = requests.get(
f"{BASE_URL}/webhooks",
params={"page": 1, "page_size": 50},
headers={"X-API-Key": API_KEY},
)
page = response.json()
for webhook in page["items"]:
print(
webhook["id"],
webhook["url"],
webhook["status"],
webhook["consecutive_failures"],
)
Each record also carries failure_count, last_triggered_at, last_success_at,
last_failure_at, last_error and secret_preview.
Updateβ
PATCH /api/v1/webhooks/{webhook_id} needs webhooks:manage and accepts url, events,
status (active or paused), description and metadata. Sending status back to
active is how you re-enable a webhook that was switched off.
response = requests.patch(
f"{BASE_URL}/webhooks/{webhook_id}",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={
"url": "https://new-endpoint.example.com/webhooks/peppol",
"events": ["transaction.received", "transaction.delivered"],
"status": "active",
},
)
Deleteβ
DELETE /api/v1/webhooks/{webhook_id} needs webhooks:manage and answers 204 No Content.
The secret goes with it.
response = requests.delete(
f"{BASE_URL}/webhooks/{webhook_id}",
headers={"X-API-Key": API_KEY},
)