Skip to main content

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": {}
}
FieldTypeDescription
idstringEvent ID: evt_ followed by 32 hex characters. Repeated in the X-Event-ID header. Use it for idempotency.
event_typestringThe event name. Repeated in the X-Event-Type header.
created_atstringWhen the message was built. UTC, ISO 8601, with microseconds and no timezone suffix โ€” parse it as UTC.
dataobjectEvent-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.

EventSent when
transaction.queuedYou submitted a document and GoRoute accepted it for processing.
transaction.deliveredAn outgoing document reached the recipient's Access Point.
transaction.failedAn outgoing document failed terminally.
transaction.receivedA 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.pingYou 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"
}
}
FieldDescription
transaction_idUUID of the transaction. Use it for every follow-up call.
statusAlways queued for this event.
receiver_idRecipient Peppol ID as scheme:identifier.
document_typeThe 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"
}
}
FieldDescription
transaction_idUUID of the transaction.
statusAlways delivered for this event.
message_idThe AS4 message ID GoRoute sent under.
receipt_message_idThe AS4 message ID of the receipt that came back.
receiver_idRecipient 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
}
}
FieldDescription
transaction_idUUID of the transaction.
statusThe transaction status at the time of the event.
error_codeMachine-readable failure code.
error_messageHuman-readable failure detail.
retry_countHow 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"
}
}
FieldDescription
transaction_idUUID of the transaction. Fetch the document with it.
directionAlways received for this event.
statusAlways delivered for this event โ€” the document reached us.
sender_peppol_idSender Peppol ID as scheme:identifier.
receiver_peppol_idYour Peppol ID as scheme:identifier.
document_typeThe full Peppol document type identifier.
message_idThe AS4 message ID the document arrived under.
received_atThe 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 codeEvent sentMeaning
APresponse.acceptedAccepted
REresponse.rejectedRejected
IPresponse.in_processIn process
CAresponse.conditionally_acceptedConditionally accepted
UQresponse.under_queryUnder query
PDresponse.paidPaid
anything elseresponse.receivedAn 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"
}
}
FieldDescription
transaction_idUUID of your original outgoing transaction, not of the response message.
response_codeThe two-letter code from the table above.
reference_idThe document reference the response quotes back.
mls_idIdentifier of the response message itself.
descriptionFree-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.

HeaderDescription
X-Webhook-SignatureThe signature. Format below.
X-Webhook-IDUUID of the webhook subscription this message was sent to.
X-Event-IDThe event ID, matching id in the body.
X-Event-TypeThe 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:

  1. 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.
  2. The secret is the one returned when the webhook was created. It is shown once, in that 201 response, and never again โ€” later reads return only the first eight characters as secret_preview. If you no longer have it, delete the webhook and create a new one.
If your verification rejects every message

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.

BehaviourValue
Delivery attempts made automaticallyOne
Response timeout30 seconds (5-second connect timeout)
Response treated as successAny 2xx
Consecutive failures before the webhook is switched off10
Manual retry attempts allowed per delivery10
Cooldown between manual retries5 seconds
Replay rate limit, per organisation10 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:

EndpointPurpose
GET /api/v1/webhooks/deliveriesList 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}/retryRe-send one failed delivery. Same event ID, so an idempotent handler is safe.
POST /api/v1/transactions/{transaction_id}/replay-webhooksRe-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.

Next Stepsโ€‹