Skip to main content

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 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  1. A sender transmits a document via their Access Point
  2. GoRoute receives the document at your registered Peppol ID
  3. GoRoute validates and processes the document
  4. GoRoute sends a transaction.received message to your webhook address
  5. 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.

Read this before you build

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.

FieldTypeRequiredDescription
urlstringYesThe 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.
eventsarray of stringsYesAt least one event name. Each name is checked against the event catalogue and an unknown name is refused with 422 Unprocessable Entity.
descriptionstringNoFree text, up to 255 characters, for your own reference.
metadataobjectNoAny 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.

EventGoRoute sends it when
transaction.receivedA document addressed to you arrives from the Peppol network. This is the one you want for receiving.
transaction.queuedYou submitted a document for sending and GoRoute accepted it for processing.
transaction.deliveredA document you sent reached the recipient's Access Point.
transaction.failedA document you sent could not be delivered and will not be attempted again.
The create call accepts more names than GoRoute actually sends

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": {}
}
FieldDescription
idThe event ID, evt_ followed by 32 hex characters. The same value is in the X-Event-ID header. Use it for idempotency.
event_typeThe event name, one of the table above. The same value is in the X-Event-Type header.
created_atWhen GoRoute built the message. It is UTC, and it carries no timezone suffix β€” parse it as UTC rather than as local time.
dataThe 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.

HeaderValue
X-Webhook-SignatureThe signature. Format described below.
X-Webhook-IDThe ID of the webhook subscription the message was sent to.
X-Event-IDThe event ID, the same value as id in the body.
X-Event-TypeThe 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
  • t is the Unix timestamp, in seconds, of the moment the message was signed.
  • v1 is 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)
If your verification currently rejects everything

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:

EndpointReturns
GET /api/v1/transactions/{transaction_id}/documentThe XML as a file download, named transaction_<id>.xml.
GET /api/v1/transactions/{transaction_id}/document/contentThe 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.

note

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.

You are not told when this happens

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:

StatusMeaning
activeReceiving events.
pausedYou paused it with the update call. No events are delivered.
disabledGoRoute 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.

EndpointPurpose
GET /api/v1/webhooks/deliveriesList 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}/retrySend a failed delivery again.
POST /api/v1/transactions/{transaction_id}/replay-webhooksRe-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:

ConditionResponse
The delivery ID is unknown, or belongs to another organisation404 with error code DELIVERY_NOT_FOUND
Fewer than five seconds since the last attempt finished429 with error code RETRY_COOLDOWN
The delivery did not fail, or ten attempts have already been made400 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},
)

Next Steps​