Webhook Events Reference
The events GoRoute sends, the exact shape of each message, and how to check that a message really came from us.
If you are setting a webhook up for the first time, start at Webhook Setup; this page is the field-by-field reference.
Event Structureโ
Every webhook message has the same four top-level fields, and no others.
{
"id": "evt_9f1c2d3e4b5a678901234567890abcde",
"event_type": "transaction.received",
"created_at": "2026-01-15T10:30:00.123456",
"data": {}
}
| Field | Type | Description |
|---|---|---|
id | string | Event ID: evt_ followed by 32 hex characters. Repeated in the X-Event-ID header. Use it for idempotency. |
event_type | string | The event name. Repeated in the X-Event-Type header. |
created_at | string | When the message was built. UTC, ISO 8601, with microseconds and no timezone suffix โ parse it as UTC. |
data | object | Event-specific body. |
There is no organization_id in the message. A message identifies the organisation only by
the webhook it was sent to, which is in the X-Webhook-ID header. If one of your endpoints
serves several organisations, key on that header or use a separate endpoint per
organisation.
Which events are actually sentโ
An event name being accepted by the create call does not mean GoRoute ever sends it. The create call validates names against an internal catalogue that is longer than the set of events the product emits, and subscribing to a catalogued-but-unemitted name gives you a subscription that is silent forever.
The events below are documented because a specific line of the platform sends each one. Anything not on this list should be confirmed with support before you build on it.
| Event | Sent when |
|---|---|
transaction.queued | You submitted a document and GoRoute accepted it for processing. |
transaction.delivered | An outgoing document reached the recipient's Access Point. |
transaction.failed | An outgoing document failed terminally. |
transaction.received | A document addressed to you arrived from the Peppol network. |
response.* | An Invoice Response arrived for a document you sent. Six named variants, listed below. |
test.ping | You called the test endpoint for a webhook. |
Earlier revisions of this page documented events under a document. prefix, a
participant. prefix and an organisation quota name, each with a detailed payload. None of
those events exists. The create call rejects every one of them with
422 Unprocessable Entity, so no working integration can be relying on them.
Transaction Eventsโ
transaction.queuedโ
Sent when a document you submitted has been accepted and queued for sending.
{
"id": "evt_1a2b3c4d5e6f708192a3b4c5d6e7f809",
"event_type": "transaction.queued",
"created_at": "2026-01-15T10:30:00.123456",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"receiver_id": "0192:987654321",
"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"
}
}
| Field | Description |
|---|---|
transaction_id | UUID of the transaction. Use it for every follow-up call. |
status | Always queued for this event. |
receiver_id | Recipient Peppol ID as scheme:identifier. |
document_type | The full Peppol document type identifier you submitted. |
transaction.deliveredโ
Sent when the document reached the recipient's Access Point.
{
"id": "evt_2b3c4d5e6f708192a3b4c5d6e7f80912",
"event_type": "transaction.delivered",
"created_at": "2026-01-15T10:32:00.884210",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "delivered",
"message_id": "phase4-msg-abc123",
"receipt_message_id": "phase4-rcpt-def456",
"receiver_id": "0192:987654321"
}
}
| Field | Description |
|---|---|
transaction_id | UUID of the transaction. |
status | Always delivered for this event. |
message_id | The AS4 message ID GoRoute sent under. |
receipt_message_id | The AS4 message ID of the receipt that came back. |
receiver_id | Recipient Peppol ID as scheme:identifier. |
There is no nested delivery-receipt object and no receiver endpoint URL in this message.
transaction.failedโ
Sent when an outgoing document failed and will not be attempted again.
{
"id": "evt_3c4d5e6f708192a3b4c5d6e7f8091223",
"event_type": "transaction.failed",
"created_at": "2026-01-15T10:35:00.551900",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"error_code": "AS4_DELIVERY_FAILED",
"error_message": "Recipient endpoint returned HTTP 503",
"retry_count": 3
}
}
| Field | Description |
|---|---|
transaction_id | UUID of the transaction. |
status | The transaction status at the time of the event. |
error_code | Machine-readable failure code. |
error_message | Human-readable failure detail. |
retry_count | How many send attempts the transaction had made. This counts send attempts to the Peppol network, not webhook delivery attempts. |
transaction.receivedโ
Sent when a document addressed to you arrives from the Peppol network. This is the event to subscribe to if you are receiving documents.
{
"id": "evt_4d5e6f708192a3b4c5d6e7f809122334",
"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"
}
}
| Field | Description |
|---|---|
transaction_id | UUID of the transaction. Fetch the document with it. |
direction | Always received for this event. |
status | Always delivered for this event โ the document reached us. |
sender_peppol_id | Sender Peppol ID as scheme:identifier. |
receiver_peppol_id | Your Peppol ID as scheme:identifier. |
document_type | The full Peppol document type identifier. |
message_id | The AS4 message ID the document arrived under. |
received_at | The AS4 timestamp, ISO 8601 with a timezone offset. |
Those eight fields are all of data. The document itself is not included and neither is
anything read out of it โ no invoice number, currency, totals, party names or attachment
list. Fetch the document with
GET /api/v1/transactions/{transaction_id}/document and read what you need from the XML.
Response Eventsโ
An Invoice Response โ a Message Level Status, or MLS โ is a message the recipient sends back
about a document you sent them. When one arrives and GoRoute can match it to your
transaction, it is dispatched as a response. event. The event name depends on the
response code, so subscribe to the names you care about rather than to one generic name.
| Response code | Event sent | Meaning |
|---|---|---|
AP | response.accepted | Accepted |
RE | response.rejected | Rejected |
IP | response.in_process | In process |
CA | response.conditionally_accepted | Conditionally accepted |
UQ | response.under_query | Under query |
PD | response.paid | Paid |
| anything else | response.received | An unrecognised response code |
Every one of them carries the same five fields.
{
"id": "evt_6f708192a3b4c5d6e7f8091223344556",
"event_type": "response.accepted",
"created_at": "2026-01-15T11:00:00.421700",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"response_code": "AP",
"reference_id": "INV-2026-00123",
"mls_id": "mls-abc123",
"description": "Invoice approved for payment"
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your original outgoing transaction, not of the response message. |
response_code | The two-letter code from the table above. |
reference_id | The document reference the response quotes back. |
mls_id | Identifier of the response message itself. |
description | Free-text reason from the sender, where one was given. May be null. |
reference_id, mls_id and description come from the incoming message and are null when
the sender did not supply them.
Test Eventsโ
test.pingโ
Sent when you call POST /api/v1/webhooks/{webhook_id}/test. It is delivered to that
webhook whether or not test.ping is in its subscription list, so it is a reliable way to
prove your endpoint and your signature check work.
{
"id": "evt_test_5e6f708192a3b4c5d6e7f80912233445",
"event_type": "test.ping",
"created_at": "2026-01-15T10:00:00.000000",
"data": {
"test": true,
"message": "This is a test webhook event",
"timestamp": "2026-01-15T10:00:00.000000"
}
}
The event ID of a test event begins evt_test_ rather than evt_.
Webhook Headersโ
Every delivery carries these four headers, plus Content-Type: application/json and a
User-Agent of PeppolAPI-Webhook/1.0.
| Header | Description |
|---|---|
X-Webhook-Signature | The signature. Format below. |
X-Webhook-ID | UUID of the webhook subscription this message was sent to. |
X-Event-ID | The event ID, matching id in the body. |
X-Event-Type | The event name, matching event_type in the body. |
There is no separate timestamp header. The timestamp is inside the signature value, because it is part of what is signed.
Verifying Webhooksโ
X-Webhook-Signature has two comma-separated parts:
X-Webhook-Signature: t=1768473045,v1=1f8ac10f23c5b5bc1173d43d4e8f97ab5b3a9c1d2e0f4a6b8c9d0e1f2a3b4c5d
tโ Unix timestamp in seconds, taken when the message was signed.v1โ HMAC-SHA256, hex-encoded, using your webhook secret as the key.
What is signed is not the body on its own. It is the timestamp, a full stop, then the exact bytes of the request body:
signed string = "<t>" + "." + <raw request body>
Compute the digest over that string and compare it with v1. Comparing a hash of the body
alone will never match. Neither will a comparison against a prefixed value โ there is no
prefix on v1.
import hashlib
import hmac
import time
from fastapi import HTTPException, Request
def verify_webhook(
raw_body: bytes,
header_value: str,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
"""Verify a GoRoute webhook signature."""
if not header_value:
return False
parts = dict(
piece.split("=", 1)
for piece in header_value.split(",")
if "=" in piece
)
timestamp = parts.get("t")
received = parts.get("v1")
if not timestamp or not received:
return False
# Your own replay window. GoRoute does not enforce one.
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
@app.post("/webhooks/goroute")
async def handle_webhook(request: Request):
raw_body = await request.body()
if not verify_webhook(
raw_body,
request.headers.get("X-Webhook-Signature"),
WEBHOOK_SECRET,
):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
# Process event...
Two details that break verification if you miss them:
- Verify the raw bytes. If your framework parses the JSON and you re-serialise it, the bytes change and the digest will not match. Take the body before parsing.
- The secret is the one returned when the webhook was created. It is shown once, in
that
201response, and never again โ later reads return only the first eight characters assecret_preview. If you no longer have it, delete the webhook and create a new one.
Earlier revisions of this page described the value of this header as a plain HMAC of the request body, and the sample code compared it against a prefixed hash of the body. That comparison can never succeed against what GoRoute sends. It also listed two headers, an event-type header and a timestamp header under different names, that are not sent at all.
Some of our own internal guides and two of our shipped accounting connectors carry the same mistake. If you have a handler that returns 401 to everything, this page is the correct description and that code needs changing.
Delivery, failure and recoveryโ
There is no automatic retry. GoRoute POSTs each event once. A non-2xx response, a connection error or a timeout is recorded and the event is not sent again on its own.
| Behaviour | Value |
|---|---|
| Delivery attempts made automatically | One |
| Response timeout | 30 seconds (5-second connect timeout) |
| Response treated as success | Any 2xx |
| Consecutive failures before the webhook is switched off | 10 |
| Manual retry attempts allowed per delivery | 10 |
| Cooldown between manual retries | 5 seconds |
| Replay rate limit, per organisation | 10 per minute |
Earlier revisions of this page, and of the setup page, published schedules of automatic retries spread over hours. They contradicted each other and neither described the product. Nothing re-sends a failed delivery for you.
When a webhook reaches ten consecutive failures its status becomes disabled and it stops
receiving events. Nothing notifies you โ there is no email and no event for it. Poll
status and consecutive_failures on your webhooks, and set status back to active with
PATCH /api/v1/webhooks/{webhook_id} to bring it back.
Recovery is something you trigger:
| Endpoint | Purpose |
|---|---|
GET /api/v1/webhooks/deliveries | List attempts. Filter by webhook_id, transaction_id, event_type, status, from_date, to_date. limit 1โ100, cursor paging. |
GET /api/v1/webhooks/deliveries/{delivery_id} | One attempt with the full request and response bodies, plus can_retry and retry_count_remaining. |
POST /api/v1/webhooks/deliveries/{delivery_id}/retry | Re-send one failed delivery. Same event ID, so an idempotent handler is safe. |
POST /api/v1/transactions/{transaction_id}/replay-webhooks | Re-send every webhook for one transaction. |
A replayed message is rebuilt from the transaction rather than copied, and its data
carries "replayed": true alongside the transaction's sender, receiver, document type,
status and message ID. Treat it as a fresh description of the transaction, not as a byte-for-byte
repeat of the original event.
Worked examples of finding a failed delivery and retrying it are in Webhook Setup.