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.
A delivery is attempted once. GoRoute does not automatically re-send a webhook that your server failed to accept. If your endpoint is down for ten minutes, the messages sent in those ten minutes are not retried for you β you recover them yourself, from the delivery record, using the endpoints in Deliveries. 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. 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. 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.
The events GoRoute sendsβ
Subscribe only to events in this table. 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.failed | A document you sent could not be delivered and will not be attempted again. |
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: several catalogued names are not
emitted anywhere in the product today, so a subscription to one is a subscription to
silence. The four above are the ones traced to code that sends them. If you need another
event, ask support to confirm it is emitted 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 30 seconds for your response and treats a timeout as a failed delivery.
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, and you can
replay every webhook for a transaction. Key on id.
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 30 seconds. After that GoRoute gives up on the connection and records the delivery as failed β and, because there is no automatic retry, that message is not sent to you again unless you ask for it.
# 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β
This section describes what the platform does, which is less than earlier revisions of this page promised. Read it before you design your error handling.
One attempt per event. GoRoute POSTs the message once. A non-2xx response, a connection error or a timeout is recorded against the delivery and against the webhook's health counters, and that is the end of it. Nothing re-queues it.
Thirty seconds. The delivery times out after 30 seconds, with a 5-second connect timeout.
Ten consecutive failures switches the webhook off. Each failure increments a consecutive
failure count; a success resets it to zero. On reaching ten, the webhook's status is set to
disabled and it stops receiving events entirely. Re-enable it by setting status back to
active with the update call.
GoRoute has no notification for a webhook being switched off. Nothing is emailed and no
event is sent. Check status and consecutive_failures on your webhook periodically β
both are returned by the read and list calls β or you will find out from the documents you
did not receive.
Recovery is yours to trigger. Two ways, both described in the next section: retry an individual failed delivery, or replay every webhook for a transaction. A retry is capped at ten attempts per delivery with a five-second cooldown 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 consecutive failures. 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},
)