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 first built. UTC, ISO 8601, with microseconds and no timezone suffix โ parse it as UTC. An automatic retry repeats the first attempt's value. |
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. You can read the full catalogue of accepted names from the API rather than from this page โ see Reading the event list from the API.
| 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.retrying | An outgoing document failed for a reason worth another attempt, and will be sent again. |
transaction.failed | An outgoing document failed terminally. For an Omani invoice that GoRoute reports another way, only if that also fails โ see Omani invoices. |
transaction.received | A document addressed to you arrived from the Peppol network. |
inbound.delivered | A document that arrived for you was placed in your accounting system as a draft bill. |
response.* | The buyer's side answered a document you sent with a status message: in Oman, the buyer's access point accepted, rejected or otherwise answered your invoice. Seven named variants, listed below. |
tdd.reported_via_substitute | An Oman invoice could not be delivered, so it was reported to the tax authority under a substitute participant. |
tdd.reported_via_bis_copy | An Oman invoice reached a buyer that accepts only BIS Billing 3.0 as a BIS 3.0 copy, and the PINT OM invoice was reported to the tax authority. |
mls.received | A status message (MLS) about a document you sent arrived and was matched to it, whatever it said: in Oman, the buyer's access point's answer to your invoice. Sent with the matching response.* event. |
tdd.cleared | The Omani Tax Authority accepted the tax report for your invoice. |
tdd.rejected | The Omani Tax Authority rejected the tax report for your invoice. |
tdd.failed | GoRoute could not file the Oman tax report for your invoice, even after retrying it automatically. |
authority.* | A tax report that runs in GoRoute's tax-authority layer was submitted, acknowledged, rejected, skipped, failed, held, or found to have passed its filing deadline. The named variants are listed below. |
test.ping | You called the test endpoint for a webhook. |
The response.*, tdd.* and authority.* names above sit in categories the catalogue
labels premium. That label says which part of the product a name belongs to. It does not
mean the event stays silent, and it is not something you have to have switched on. See
what the tier actually tells you.
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.submitted, transaction.accepted, retry.scheduled, retry.succeeded and
retry.exhausted were in the catalogue and were dispatched by nothing. They are retired: the
create call still accepts them, and stores each as the live event that carries the same
outcome โ transaction.queued, transaction.delivered, transaction.retrying,
transaction.delivered and transaction.failed respectively โ so an integration written
against the old names keeps working and starts receiving something. Subscribe to the live
names.
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. |
delivered_via | Only for an Omani invoice delivered as its BIS 3.0 copy: bis_copy. Absent otherwise. |
bis_copy_transaction_id | Only with delivered_via: UUID of the copy's transaction, whose transmission receipt_message_id belongs to. |
There is no nested delivery-receipt object and no receiver endpoint URL in this message. See Omani invoices the buyer cannot take as PINT OM for the Omani case.
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. |
fallback | Present only on an Omani invoice whose fallback failed (see below): bis_copy or substitute, the fallback that could not report the invoice. |
fallback_error | Present only with fallback: a plain-language sentence saying what could not be done. |
Omani invoices the buyer cannot take as PINT OMโ
An Omani invoice can fail at the Peppol network because the buyer cannot receive PINT OM: the buyer is not on Peppol, or is on Peppol for BIS Billing 3.0 only. GoRoute resolves that failure itself: it either delivers a BIS 3.0 copy and reports the invoice to the Oman Tax Authority, or reports the invoice under the ยง10.3.1 stand-in participant. See Buyers outside Oman who accept only BIS 3.0.
transaction.failed is not sent for that failure. The event that reports the outcome is
sent instead:
| What happened | Events you receive, in order |
|---|---|
| The buyer accepts only BIS 3.0, and the copy was delivered | transaction.delivered for the copy's transaction; then, once the invoice is reported, transaction.delivered for your invoice (once, with delivered_via: "bis_copy", see below) and tdd.reported_via_bis_copy for your invoice. Your invoice then shows as delivered. |
| The buyer is not on Peppol | tdd.reported_via_substitute for your invoice. |
| The buyer accepts only BIS 3.0, but no copy could be made, the buyer's Access Point refused the copy, or GoRoute gave up sending the copy before handing it to the Peppol transport | tdd.reported_via_substitute for your invoice. The copy's own transaction.failed is not sent either. If the copy's send was retried first, you may see transaction.retrying for the copy. A copy GoRoute gave up sending shows as failed with error_code SEND_DEAD_LETTERED. |
| GoRoute gave up sending the copy after handing it to the Peppol transport, so it may have reached the buyer | Nothing yet. The copy keeps its status and GoRoute's operators check whether it was delivered, then file the report that matches: the invoice's report through the delivered copy (transaction.delivered and tdd.reported_via_bis_copy), or the stand-in (tdd.reported_via_substitute). A report to the Oman Tax Authority cannot be withdrawn, so it is never filed on a guess. |
| The copy was delivered, but the invoice could not then be reported to the Oman Tax Authority | transaction.delivered for the copy's transaction, then transaction.failed for your invoice, once, with fallback set to bis_copy. The buyer has the invoice; the report is outstanding. Contact support. |
| The fallback itself failed: the stand-in report could not be filed and is no longer retried (after the buyer was not on Peppol, or after the copy could not be made, sent or delivered) | transaction.failed for your invoice, once, with fallback and fallback_error. Only that one: the copy's own transaction.failed is not sent as well. |
A report step that fails is retried with increasing delays (up to every 6 hours) for as long as
the report is owed, and no event is sent while it is retried: this includes a stand-in report
that could not be filed on a run, for whatever reason (storage, the Oman Tax Authority's
endpoint, the invoice data). GoRoute's operators are alerted while it keeps failing. The late
transaction.failed rows above are for a report that is no longer retried.
If GoRoute gives up sending your invoice itself (it then shows as failed with error_code
SEND_DEAD_LETTERED), the same rules apply: when a fallback reports it you receive that
fallback's event and no transaction.failed; otherwise transaction.failed with
SEND_DEAD_LETTERED.
When your invoice is delivered as its BIS 3.0 copy, its own transaction.delivered carries the
usual fields for your invoice's transaction, with the receipt of the copy's transmission (the
only one that took place), plus delivered_via and the copy's transaction id:
{
"id": "evt_2b3c4d5e6f708192a3b4c5d6e7f80912",
"event_type": "transaction.delivered",
"created_at": "2026-01-15T10:34:05.120044",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "delivered",
"message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890@goroute.ai",
"receipt_message_id": "c3d4e5f6-a7b8-9012-cdef-345678901234@buyer-ap.example",
"receiver_id": "0088:5790000435951",
"delivered_via": "bis_copy",
"bis_copy_transaction_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
}
It is sent once, together with tdd.reported_via_bis_copy. No transaction.delivered is sent
for an invoice reported under the ยง10.3.1 stand-in: that invoice was not delivered over Peppol,
and tdd.reported_via_substitute is its outcome.
error_code and error_message in that late transaction.failed are the ones from the
original PINT OM send (for example SMP_404, or TRANSPORT_ERROR for a buyer that does not
accept PINT OM). fallback says which fallback failed, and fallback_error says what it
could not do:
{
"id": "evt_4d5e6f708192a3b4c5d6e7f809122334",
"event_type": "transaction.failed",
"created_at": "2026-01-15T10:36:12.004211",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"error_code": "SMP_404",
"error_message": "Participant not found",
"retry_count": 0,
"fallback": "substitute",
"fallback_error": "The ยง10.3.1 stand-in report could not be filed, so the invoice was not reported to the Oman Tax Authority."
}
}
If your integration waits for transaction.delivered or transaction.failed on Omani
invoices, also subscribe to tdd.reported_via_substitute. Otherwise an invoice reported
under the stand-in produces no event you listen for. (An invoice delivered as its BIS 3.0
copy does send transaction.delivered for your invoice.)
Any other failure of an Omani invoice, such as an Access Point that refused the PINT OM
invoice itself, is sent as transaction.failed straight away, as for any other country. The
same applies to every failure when Oman tax reporting is not switched on for the platform
you use, because no fallback runs there.
If you retry a failed Omani invoice and it fails the same way,
you receive the outcome of that attempt: the fallback's event if a new fallback reports it,
otherwise transaction.failed again. A late transaction.failed without fallback means
the send failed before any fallback could be started.
transaction.retryingโ
Sent when a send failed for a reason worth another attempt โ a recipient Access Point that
timed out or answered 5xx โ and GoRoute will try again. It is not a terminal state and
requires nothing of you: transaction.delivered or transaction.failed follows. Use it to
show that a document is taking longer than usual rather than to change your own state.
{
"id": "evt_5e6f708192a3b4c5d6e7f80912233445",
"event_type": "transaction.retrying",
"created_at": "2026-01-15T10:33:00.417300",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "retrying",
"error_code": "AS4_DELIVERY_FAILED",
"error_message": "Recipient endpoint returned HTTP 503",
"retry_count": 1
}
}
| Field | Description |
|---|---|
transaction_id | UUID of the transaction. |
status | Always retrying for this event. |
error_code | Machine-readable code for the attempt that failed. |
error_message | Human-readable detail of that attempt. |
retry_count | How many send attempts the transaction has made so far. Not webhook delivery attempts. |
The same four fields as transaction.failed, and the same meanings โ the difference is that
this one is not the end of the story.
Omani tax reports. When the Omani Tax Authority's access point cannot be reached, GoRoute
sends the Tax Data Document again automatically for about 20 hours (see
tdd.failed). Each time it is queued again, the report's own transaction sends
transaction.retrying with one more field, next_attempt_at (ISO 8601, UTC), and retry_count
stays at 3. The report's transaction sends transaction.failed only when it will not be sent
again, and nothing at all while reporting for your taxpayer is on hold. Your invoice's outcome
is tdd.failed, or tdd.cleared once the report is accepted.
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",
"inbound_document_id": "7c1e9b04-23f6-4a8d-9c2b-5f0a1d8e6b33",
"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. |
inbound_document_id | UUID of the tracked inbound-document record, or null. Treat it as optional: the record is created best-effort when the document arrives, and if that fails the platform logs it and still delivers this webhook with the field set to null. See Inbound Document Events. |
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 nine 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 XML with
GET /api/v1/transactions/{transaction_id}/document/content, which returns it as the response body, and read what
you need from it. The plain /document call returns a JSON download link on GoRoute's service, not the XML; see
Processing Documents.
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, together with
mls.received. The event name depends on the response code, so subscribe
to the names you care about rather than to one generic name.
They go to the organisation that owns the document, whoever the message was addressed to.
In Oman the buyer's access point addresses its status message to GoRoute's Service Provider
identity (0242:000991), not to you; GoRoute matches it to your invoice by the message ID it
quotes back and sends the events to your organisation, naming your invoice. A webhook scoped
with api_key_id receives them when that key created the invoice, as for every other event
about it. A status message on the BIS 3.0 copy GoRoute delivered for you (see
tdd.reported_via_bis_copy) is about your PINT OM invoice, and
names it. Each answer is sent once: a status message that arrives twice (the same mls_id) is
not sent again.
A buyer's answer is not the Tax Authority's verdict on your invoice's tax report, and it never
arrives as tdd.cleared or tdd.rejected: those are the Authority's alone (see
Oman Tax-Reporting Events).
| 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 six 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": "7f1c2e3a-58b0-4c1d-9e2f-0a1b2c3d4e5f@peppol.goroute.ai",
"mls_id": "mls-abc123",
"description": "Invoice approved for payment",
"received_at": "2026-01-15T11:00:00.398215+00:00"
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your original outgoing transaction, not of the response message. For an answer on a BIS 3.0 copy, your PINT OM invoice. |
response_code | The two-letter code from the table above. |
reference_id | The reference the response quotes back: the transmission message ID of the document it answers (your transaction's message_id; for an answer on a BIS 3.0 copy, the copy's). |
mls_id | Identifier of the response message itself. |
description | Free-text reason from the sender, where one was given. May be null. |
received_at | When GoRoute recorded the response against your transaction, ISO 8601 UTC with an offset (metadata.oman_reporting.mls_received_at on the transaction holds the latest one). |
reference_id, mls_id and description come from the incoming message and are null when
the sender did not supply them. received_at is newer than the other five: a GoRoute service
that does not have it yet sends the other five, so read it with a default.
Approval Eventsโ
No invoice is ever held for a human decision. You can store approval rules, and you can ask whether a proposed invoice would match one, but nothing in GoRoute puts an invoice into an approval queue and no send path consults your rules. A document you submit is sent, whatever your rules say, and the approval step has to be yours.
Three event names exist and behave in a way that is easy to misread. approval.requested,
approval.approved and approval.rejected are accepted when you create a subscription, and
GET /api/v1/events/catalog reports all three as status: live. None of them is ever
delivered, because nothing creates the approval request whose creation would send them. If you
have already subscribed to these names, that subscription is silent and will stay silent. See
what status does and does not promise.
What the approval endpoints do todayโ
- Rules work. The five calls under
/api/v1/approvals/rulescreate, list, read, update and delete rules, and what you store is kept. POST /api/v1/approvals/checkworks, and you have to call it yourself. Give it invoice data and it answers whether your stored rules would require approval, and which rule matched. It is the only call that reads your rules. Sending a document does not.- The six calls under
/api/v1/approvals/requestsโ list, pending, read, approve, reject and cancel โ run against a queue that nothing fills, so there is never anything in it to act on.
An approval step you can build todayโ
Keep the document as a draft and send it only after your own sign-off:
POST /api/v1/draftsto create the document without sending it.- Your own review.
POST /api/v1/approvals/checkcan tell you whether your stored rules say this invoice needs one. POST /api/v1/drafts/{draft_id}/sendonce it is approved.
Nothing leaves GoRoute until you make that third call, so the control is real.
Invoice Drafts covers what a draft is, how to read one, and one thing worth knowing before you build this: validating a draft writes the result back onto it by default, which can move the draft's status.
Oman Tax-Reporting Eventsโ
For Oman, GoRoute reports invoices to the tax authority as well as delivering them over Peppol. Six tax-reporting events are documented here: one for a report filed under a substitute participant, one for an invoice delivered as a BIS 3.0 copy and reported, three for the Tax Authority's answer to a report you filed, and one for a report GoRoute could not file at all.
The Tax Authority answers a filed report with a Message Level Status, or MLS โ a short
machine-readable acknowledgement carrying a response code. When the code says accepted or
rejected, GoRoute sends tdd.cleared for accepted and tdd.rejected for rejected, to the
organisation that owns the invoice, naming the invoice. The Authority addresses its answers
on reports to GoRoute's Service Provider identity, not to you, so for those answers subscribe
to tdd.cleared and tdd.rejected: the Authority's status message itself stays with GoRoute
and is not sent to you as mls.received.
The buyer's access point answers your invoice itself with a status message too, also
addressed to GoRoute's Service Provider identity. GoRoute sends that answer to the
organisation that owns the invoice, as mls.received and the matching
response.* event, both naming the invoice. It never sends a buyer's
answer as tdd.cleared or tdd.rejected, so you receive each of those only for the
Authority's verdict.
reason_code as an open setSeveral events below name specific reason_code values, and those are the ones worth writing
code for. The set grows as reporting gains new cases, though, and your own service may be a
release behind or ahead of this page. So branch on the codes you have deliberately handled, and
for anything else show the event's reason sentence to the person rather than a fallback of
your own. reason is written to be read by a human being and is never an internal error
message, so it is always safe to show. A switch with no default is the one thing to avoid
here: a code you have not seen should produce a useful message, not silence.
tdd.reported_via_substituteโ
Sent when an Oman invoice could not be delivered over the Peppol network โ the recipient is not registered for the document type โ so GoRoute reported it to the tax authority under a substitute participant instead, as Fawtara ยง10.3.1 allows.
No transaction.failed comes before it for the same invoice: this event is the outcome of
that failed send. See Omani invoices the buyer cannot take as PINT OM.
The document was not delivered to the buyer. Only the tax reporting happened. Treat this event as an unsuccessful delivery that has nonetheless met the reporting obligation, and arrange to get the invoice to the buyer by another route.
{
"id": "evt_c5d6e7f8091223344556677889a0b1c2",
"event_type": "tdd.reported_via_substitute",
"created_at": "2026-01-15T12:04:18.771903",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"substitute_participant": "0248:997770000095",
"scope": "D",
"clearance_status": "reported_via_substitute",
"delivered": false
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your original outgoing transaction. |
substitute_participant | The stand-in buyer identifier the report was filed under, as scheme:identifier. The scheme is always 0248; the identifier is one of the fixed ยง10.3.1 values โ 997770000095 for a domestic buyer who is not on Peppol, 997770000097 for an export to a buyer who is not registered on Peppol. |
scope | The TDD document scope code the report was filed under: D for domestic, INP for international not exchanged over Peppol. A substitute report is never IP, because by definition it did not go over the network. |
clearance_status | Always reported_via_substitute for this event. |
delivered | Always false. It is here so that code reading the payload does not have to infer non-delivery from the event name. |
tdd.reported_via_bis_copyโ
Sent when an Oman invoice was addressed to a buyer outside Oman that is on Peppol but accepts only Peppol BIS Billing 3.0, not PINT OM. GoRoute delivered a BIS 3.0 copy of the invoice to the buyer and then reported the PINT OM invoice to the tax authority. See Buyers outside Oman who accept only BIS 3.0.
The invoice was delivered, as the copy. The PINT OM send was refused first, because the
buyer does not accept PINT OM, but no transaction.failed was sent for that: this event is
the outcome. After it, your original transaction shows as delivered. The copy is a
transaction of its own, and its transaction.delivered event carries the copy's transaction
ID. Your invoice's own transaction.delivered, with delivered_via: "bis_copy", is sent once,
normally just before this event; if it could not be queued then, it follows shortly after, when
GoRoute retries it. See
Omani invoices the buyer cannot take as PINT OM.
{
"id": "evt_d6e7f8091223344556677889a0b1c2d3",
"event_type": "tdd.reported_via_bis_copy",
"created_at": "2026-01-15T12:09:41.204518",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"bis_copy_transaction_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"tdd_transaction_id": "0f8fad5b-d9cb-469f-a165-70867728950e",
"scope": "IP",
"clearance_status": "reported",
"delivered": true
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your original outgoing transaction, the PINT OM invoice. |
bis_copy_transaction_id | UUID of the transaction that carried the BIS 3.0 copy to the buyer. |
tdd_transaction_id | UUID of the transaction that carried the tax report. |
scope | The TDD document scope code the report was filed under, normally IP (international, exchanged over Peppol). |
clearance_status | Always reported for this event. The Tax Authority's answer on the report follows as tdd.cleared or tdd.rejected; the buyer's answer on the copy, if its access point sends one, as mls.received and response.*, naming your invoice. |
delivered | Always true. |
mls.receivedโ
Sent every time a Message Level Status about a document you sent is matched to it, whatever
it said, to the organisation that owns the document. In Oman this is the buyer's access
point's answer to your invoice (or to the BIS 3.0 copy GoRoute delivered for it): addressed to
GoRoute's Service Provider identity, matched to your invoice by the message ID it quotes, and
sent to you naming your invoice. The matching response.* event is sent
beside it, with the same fields bar clearance_status. It is the one event you can rely on
arriving for every answer, so it is the right name to subscribe to if you want a complete log
of what the buyer's side said.
The Tax Authority's own answers on your reports do not arrive as mls.received: they come as
tdd.cleared and tdd.rejected.
{
"id": "evt_7a1b2c3d4e5f60718293a4b5c6d7e8f9",
"event_type": "mls.received",
"created_at": "2026-01-15T12:04:18.771903",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"response_code": "IP",
"mls_id": "MLS-2026-0115-000431",
"clearance_status": null,
"reference_id": "7f1c2e3a-58b0-4c1d-9e2f-0a1b2c3d4e5f@peppol.goroute.ai",
"description": "Under review by accounts payable",
"received_at": "2026-01-15T12:04:18.750114+00:00"
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your original outgoing transaction โ the document the status message answers (for an answer on a BIS 3.0 copy, your PINT OM invoice), not the status message. |
response_code | The code, verbatim. AP is accepted and RE is rejected; other codes are neither, and carry a null clearance status. |
mls_id | The sender's identifier for the status message itself. Use it to tell two answers about the same invoice apart. |
clearance_status | cleared when response_code is AP, failed when it is RE, and null for every other code. It restates the code; it is not the state of your invoice's tax report, which tdd.cleared, tdd.rejected and tdd.failed tell you. See the warning below. |
reference_id | The reference the status message quotes back: the transmission message ID of the document it answers. null when it gave none. |
description | The sender's own text, or null. |
received_at | When GoRoute recorded the status message against your transaction, ISO 8601 UTC with an offset. |
reference_id, description and received_at are newer than the other four: a GoRoute
service that does not have them yet sends the other four, so read them with a default.
clearance_status is null more often than you may expectmls.received fires for every matched status message, including codes that are neither an
acceptance nor a rejection. For those, clearance_status is null. Code that reads this
field as a two-valued flag, or that treats "not cleared" as "failed", will misreport an
invoice that is merely still in progress. Branch on response_code. For the Tax Authority's
decision on your invoice's report, subscribe to tdd.cleared and tdd.rejected, which only
arrive for its two decided outcomes.
tdd.clearedโ
Sent when the Omani Tax Authority accepted the report โ response code AP โ or accepted the
corrected report GoRoute filed after a rejection. It names your invoice
(transaction_id), sent or received, and goes to the organisation that owns it. The report's
own transaction is tdd_transaction_id.
{
"id": "evt_8b2c3d4e5f60718293a4b5c6d7e8f9a0",
"event_type": "tdd.cleared",
"created_at": "2026-01-15T12:04:18.783551",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"tdd_transaction_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"regime": "OM",
"leg": "tdd",
"response_code": "AP",
"mls_id": "MLS-2026-0115-000431",
"description": null,
"clearance_status": "cleared",
"reporting_state": null,
"reason_code": null,
"reason": null
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your invoice. |
tdd_transaction_id | UUID of the Tax Data Document's own transaction, the one the Authority answered. |
regime | Always OM for this event. |
leg | tdd for the invoice's report. tdd_r for the corrected report (Resubmit) GoRoute filed after a rejection, tdd_d for a Disregard. |
response_code | Always AP for this event. |
mls_id | The Authority's identifier for the status message that carried the acceptance. |
description | The Authority's own text, or null. |
clearance_status | Always cleared for this event. |
reporting_state, reason_code, reason | null for this event. |
tdd.rejectedโ
Sent when the Omani Tax Authority rejected the report โ response code RE. Like
tdd.cleared, it names your invoice and goes to the organisation that owns it.
Where GoRoute can file the corrected report (a Resubmit) the Authority's rules call for, it
does so by itself, without you asking. It can only for the report of an invoice you received.
The event tells you it happened; it is not a request for you to resubmit. This event
then carries clearance_status rejected, and until the Authority answers the Resubmit so does
the invoice, with clearance_reason_code and clearance_reason saying why, with the
Authority's own code and description. rejected is not final: when the Authority accepts the
Resubmit, tdd.cleared follows (leg tdd_r) and the invoice reads cleared.
Where GoRoute cannot file a Resubmit automatically (the report of an invoice you sent, of a POS
sale, or of one filed under the stand-in participant), the invoice is in error at once: its
clearance_status is failed and its reporting_state is error with reason_code
C5_OM_REJECTED_NO_RESUBMIT. This event carries the same, and tdd.failed
follows. The same happens when the Authority rejects the Resubmit too, with reason_code
C5_OM_REJECTED_RESUBMIT_FAILED: GoRoute does not correct it again. Contact GoRoute support for
an invoice in either error.
{
"id": "evt_9c3d4e5f60718293a4b5c6d7e8f9a0b1",
"event_type": "tdd.rejected",
"created_at": "2026-01-15T12:07:02.114908",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"tdd_transaction_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"regime": "OM",
"leg": "tdd",
"response_code": "RE",
"mls_id": "MLS-2026-0115-000518",
"description": "BR-OM-10: the buyer VAT number is not registered.",
"clearance_status": "rejected",
"reporting_state": null,
"reason_code": "C5_OM_REJECTED",
"reason": "The Oman Tax Authority rejected the Tax Data Document for this invoice (response code RE): BR-OM-10: the buyer VAT number is not registered."
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your invoice. |
tdd_transaction_id | UUID of the Tax Data Document's own transaction, the one the Authority rejected. |
regime | Always OM for this event. |
leg | tdd for the invoice's report, tdd_r for its Resubmit, tdd_d for a Disregard. |
response_code | Always RE for this event. |
mls_id | The Authority's identifier for the status message that carried the rejection. |
description | The Authority's own text about the rejection, or null when it gave none. |
clearance_status | rejected for the invoice's report (leg tdd) while GoRoute's Resubmit is under way; not final. failed when the invoice is then in error: the invoice's report with no Resubmit under way, or the Resubmit (leg tdd_r). failed also for a Disregard (leg tdd_d), which leaves the invoice's report as it was: read leg before updating your own record. |
reporting_state | error when the invoice is in error (no Resubmit under way, or the Resubmit rejected); otherwise null. |
reason_code | C5_OM_REJECTED for the invoice's report with a Resubmit under way, C5_OM_REJECTED_NO_RESUBMIT for one without, C5_OM_REJECTED_RESUBMIT_FAILED for its Resubmit, null for a Disregard. |
reason | One sentence with the Authority's code and description, safe to show to a person, or null. |
An answer GoRoute cannot tie to one of your invoices (the report's transaction names no
invoice of the same organisation) is sent as it was before these fields existed: to the
organisation the answer arrived under, with only transaction_id (the answered transaction),
response_code, mls_id and clearance_status. That organisation is GoRoute's own, so in
practice you do not receive it. A buyer's access point's answer on an invoice itself is never
sent to you as tdd.cleared or tdd.rejected: it reaches you as
mls.received and response.*.
tdd.failedโ
Sent when GoRoute could not file the Tax Data Document for your invoice, or a correction to
it, with the Omani Tax Authority. When the Authority's access point cannot be reached, GoRoute
sends the same report again automatically, with growing gaps between tries, for about 20
hours. When a send may have reached the Authority without a receipt coming back, GoRoute first
waits 15 minutes for the Authority's answer. Throughout those retries the invoice's
clearance_status stays reported, and the report's own transaction sends
transaction.retrying, not transaction.failed. This event means those
retries ran out, or the report failed in a way that trying again would not fix. reason_code says whether the report
was not filed, may not have been filed, or whether it is a correction that is missing while the
invoice's own report stands.
It names your invoice (transaction_id), not the report's own transaction. From then on
the invoice's clearance_status is failed and its reporting_state is error, and
GET /transactions/{id}/reporting returns the same reporting_state, reason_code and
reason as this event. GoRoute sees the error too.
If a later attempt is delivered, or the Authority accepts the report after all, the error is
cleared and the invoice reads cleared.
This event is not sent while reporting for your taxpayer is on hold. The invoice's
reporting_state is then held, with reason_code C5_OM_TDD_REDRIVE_HELD, and GoRoute sends
the report again automatically once the hold is lifted.
It is also sent when the Tax Data Document for your invoice could not be built or filed at all
(leg c2, the report of an invoice GoRoute delivered, or substitute, the stand-in report of
one that could not be delivered over Peppol). The invoice reads failed with reporting_state
error from the first such failure, and this event follows once the same reason has repeated
three times (the default). tdd_transaction_id is then null, because no report exists yet.
GoRoute keeps trying automatically: once the cause is resolved, the report is filed and the
error is cleared. This case too is not sent while reporting for your taxpayer is on hold: the
invoice then does not read failed, because the report would be held rather than filed. Once
the hold is lifted, the next failure makes it read failed, and this event follows at once if
the same reason has already repeated three times. It is sent only while the invoice reads
failed for that reason: never while it reads held because GoRoute's seller-side reporting was
switched off when it was delivered (reason_code C5_OM_REPORTING_DISABLED).
{
"id": "evt_0d4e5f60718293a4b5c6d7e8f9a0b1c2",
"event_type": "tdd.failed",
"created_at": "2026-01-16T08:31:44.302115",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"tdd_transaction_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"regime": "OM",
"leg": "tdd",
"reporting_state": "error",
"clearance_status": "failed",
"reason_code": "C5_OM_TDD_TRANSPORT_FAILED",
"reason": "The Tax Data Document could not be delivered to the Oman Tax Authority because its access point stayed unreachable through every automatic retry, so it has not been filed.",
"attempts": 10
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your invoice. |
tdd_transaction_id | UUID of the Tax Data Document's own transaction. null when leg is c2 or substitute, because no report exists yet. |
regime | Always OM for this event. |
leg | tdd for the invoice's report. tdd_d or tdd_r for a correction that GoRoute raised after a rejection. c2 or substitute for a report that could not be built or filed at all (see above). |
reporting_state | Always error for this event. |
clearance_status | Always failed for this event, as the invoice's own clearance_status reads. |
reason_code | C5_OM_TDD_TRANSPORT_FAILED: the Authority's access point stayed unreachable through every retry, so the report has not been filed. C5_OM_TDD_SEND_FAILED: the send failed in a way that trying again would not fix, so the report has not been filed. C5_OM_TDD_DELIVERY_UNCONFIRMED: the report was sent but the Authority never confirmed receiving it, so it may not have been filed. C5_OM_TDD_CORRECTION_FAILED and C5_OM_TDD_CORRECTION_UNCONFIRMED: the same for a correction (leg is tdd_d or tdd_r); the invoice's original report stands. C5_OM_REJECTED_NO_RESUBMIT: the Authority rejected the report (leg tdd) and GoRoute cannot file a corrected report (Resubmit) for it automatically; tdd.rejected came just before. C5_OM_REJECTED_RESUBMIT_FAILED: the Authority rejected the report and then the corrected report (Resubmit) GoRoute filed (leg tdd_r); it is not corrected again automatically, and tdd.rejected came just before. With leg c2 or substitute, none of which has been filed: C5_OM_REPORT_SOURCE_MISSING, the invoice document the report is built from could not be found; C5_OM_TDD_BUILD_FAILED, the report could not be built from it; C5_OM_TDD_KEY_TAKEN, the report's filing reference is held by another document. |
reason | One sentence describing the reason, safe to show to a person. |
attempts | How many times GoRoute sent the report again after the first four tries (0 for a rejection by the Authority). With leg c2 or substitute: how many times in a row it failed for this reason. |
One name in this category is never sentโ
tdd.reported appears in the catalogue you can read from the API, and the create call will
accept a subscription to it. Nothing in the platform dispatches it, so that subscription
would stay silent forever. If you want to know that a report was filed, watch the
transaction's own status rather than waiting for this name.
Tax-Authority Leg Eventsโ
GoRoute is moving its tax-authority reporting into one layer that every country shares. In that layer each report a document owes a tax authority is a leg, and these events say what happened to it. Oman's reporting moves there report by report, and where it has moved an Omani invoice gets these events as well as the Oman tax-reporting events above, which do not change.
Check whether it applies to your document, rather than reading a date off this page. Fetch
GET /api/v1/transactions/{transaction_id} for something you have sent. While its authority
field is null, that document owes no report in this layer: none of the events below will be
sent for it, and the Oman tax-reporting events above are still the ones to watch. When
authority is an object, this layer is handling the document and these events apply to it.
The field is the reliable answer because it is filled in by the same build that would send you
the event.
| Event | Sent when |
|---|---|
authority.submitted | The report was sent to the tax authority. Its answer is still to come. |
authority.acknowledged | The tax authority accepted the report. |
authority.rejected | The tax authority rejected the report. |
authority.skipped | No report was filed, for the reason given: for example, the Peppol delivery it depends on failed. |
authority.failed | GoRoute could not file the report and has stopped trying. reason_code and reason say why. |
authority.held | The report is owed but must wait before it can be filed, for the reason given. It is filed once that reason no longer applies. |
authority.overdue | The report passed its filing deadline and is not finished yet. It is still being worked on (or still waits, if it is held); reason_code and reason carry its last reason. Sent once per report. |
Each event is sent once, when the report enters that status. A report that stays held but for
a different reason does not send authority.held again; its new reason shows on the
transaction's authority field (below). authority.overdue is not a status: it is sent once,
when GoRoute finds the report past its deadline, and the report keeps its status.
{
"id": "evt_5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e",
"event_type": "authority.held",
"created_at": "2026-01-16T08:31:44.302115",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"leg_id": "2f1e0d9c-8b7a-4695-8473-625140392817",
"regime": "OM",
"leg_type": "c2_tdd",
"status": "held",
"external_ref": null,
"skip_reason": null,
"reason_code": "pack_disabled_on_estate",
"reason": "Reporting to this tax authority is switched off on this GoRoute platform; the report waits and is filed once it is switched on again.",
"next_attempt_at": null,
"deadline_at": null,
"overdue_since": null
}
}
| Field | Description |
|---|---|
transaction_id | UUID of your document. |
leg_id | UUID of the report (the leg). One document can owe more than one. |
regime | The tax authority's country code, for example OM. |
leg_type | Which report this is. The values depend on the country. |
status | The report's status: submitted, acknowledged, rejected, skipped, failed or held. A held report can also carry a status that names what it waits for; it is sent as authority.held too. |
external_ref | The tax authority's own reference for the report, once it has given one. |
skip_reason | Why no report was filed. Only for authority.skipped. |
reason_code | A stable code for why the report is held, failed or skipped, or null. A country's own codes have the form C5_<country>_<CODE>. GoRoute's own codes include pack_disabled_on_estate (reporting to that authority is switched off on this platform), child_failed:<error code> (the report could not be sent and is tried again), peppol_failed and peppol_simulated_delivery (nothing is reported for a delivery that failed or was simulated), unknown_regime and missing_reason (the report stopped and GoRoute is looking into it). |
reason | One sentence describing the reason, safe to show to a person, or null when no text is registered for the code. It is never an internal error message. |
next_attempt_at | When GoRoute next tries the report, or null. UTC, ISO 8601. |
deadline_at | When the report is due, where the country sets a deadline, or null. UTC, ISO 8601. |
overdue_since | When GoRoute found the report past its deadline and not finished, or null. UTC, ISO 8601. |
The transaction's authority field (in GET /api/v1/transactions/{transaction_id} and the transaction
list) shows the same, per country: state, reason_code, reason, next_attempt_at,
deadline_at and overdue for the country's reports as a whole, and the same for each report
in legs. state is one of owed, in_progress, held, reported, error and not_owed.
Where a document owes several reports, the country's state is the one that most needs your
attention: error first, then held.
Which of these names your own service will accept, check in your own catalogue. The names
POST /api/v1/webhooks accepts are exactly the ones your
GET /api/v1/events/catalog lists, plus the handful of
retired names it rewrites for you. A name that is in neither is refused with
422 Unprocessable Entity โ you get an error, not a silent subscription. The names in the
table above ship with the layer, so a name missing from your catalogue today means your service
does not have that part of it yet, not that the name is wrong.
authority.overdue is sent at most once per report, when GoRoute's reporting checker (it runs
every few minutes) finds a report past its deadline and not finished. From then on the
report's overdue_since says when it was found overdue, and overdue is true until the
report finishes. Where a country sets no deadline, it is never sent. Whether your service will
accept it in a subscription is the same catalogue question as above โ look for it in your own
GET /api/v1/events/catalog rather than assuming either answer.
Oman Importsโ
An Omani importer reports each foreign supplier's invoice to the Oman Tax Authority as a
self-billed import invoice. GoRoute does that for a foreign invoice you receive over Peppol and
for one you record yourself with POST /api/v1/oman/imports (with its PDF). An import that lacks
data the Authority requires, such as the customs data, waits for you:
GET /api/v1/oman/imports?status=awaiting_buyer_data lists them, with what each one is missing,
and PATCH /api/v1/oman/imports/{transaction_id} completes one, after which GoRoute files it, once
per foreign invoice. These endpoints are served only on GoRoute platforms that report Oman, and
answer 409 with error_code IMPORT_SB_NOT_LIVE until Oman imports are switched on there.
There are no import-specific events. The import's outcomes are its
tax-authority leg events (regime OM, leg_type import_sb,
transaction_id the import): authority.held with reason_code
C5_OM_CUSTOMS_DATA_MISSING, C5_OM_IMPORT_RATE_MISSING (an import not in OMR without its
exchange rate) or C5_OM_IMPORT_DATA_INCOMPLETE while it waits for you, then
authority.submitted, authority.acknowledged or authority.rejected.
Inbound Document Eventsโ
A supplier's invoice or credit note arriving for you becomes a tracked record and, if you have an active Odoo connector, is placed in your Odoo as an unposted draft bill exactly as sent. Accounts Payable explains that flow; this section is only about its webhook.
The event carries the inbound-document record in data. The fields worth routing on:
Field in data | Use it for |
|---|---|
id | The inbound document's UUID. This is the field to key on โ it is the same value that transaction.received reports as inbound_document_id, and it is what every inbound-document call takes as {id}. |
transaction_id | The transaction the document arrived on, to join to transaction.received and to the transaction endpoints. |
status | draft_created once the draft bill exists. |
needs_attention | true when something on the document is worth a person's look โ for example a suspected duplicate, or a currency not active in your Odoo. |
open_exception_count | How many unresolved exceptions there are. |
exceptions | The open exceptions only, each with its code. Every code is listed, with what raises it and what GoRoute did about it, under Exception codes. |
envelope | Twelve identifying fields about the document as it arrived: network_document_id, source_sha256, received_at, sender_ap, document_type, supplier_peppol_id, supplier_vat, supplier_name, document_number, issue_date, currency, payable_amount. |
delivery | Where it landed โ adapter, connector id, timestamp and Odoo's own identifiers for the bill and attachments. |
parse_error, created_at, updated_at | Diagnosis and ordering. |
The invoice itself is not in the webhook. Fetch it with
GET /api/v1/inbound-documents/{id}, which returns the parsed invoice, the full exception list
and the evidence trail.
inbound.deliveredโ
Sent when a received document has been placed in your accounting system as an unposted draft bill, with its attachments. It is dispatched after the delivery is committed, so a document that failed to deliver produces no event, and a retry that succeeds produces one. A redelivery of a document already delivered does not create a second bill.
GoRoute sends no later event for the bill: posting, paying or deleting it happens in your accounting system and is not reported back.
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 six 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. The same on every attempt of one event. |
X-Event-Type | The event name, matching event_type in the body. |
X-Webhook-Attempt | Which attempt of this event this is: 1 for the first, 2 and up for a retry (automatic or one you asked for). |
X-Webhook-Delivery-ID | UUID of this attempt's delivery record, the id that GET /api/v1/webhooks/deliveries lists. |
The last two of those six need a GoRoute service that retries automatically.
X-Webhook-Attempt and X-Webhook-Delivery-ID are sent by a deployment that has automatic
retry; a deployment that does not have it yet sends the other four and nothing else, so a
handler that requires either header will reject every message it receives. Read them with a
default rather than as required values, and see
Delivery, failure and recovery for a one-line check of
which kind of service is calling you.
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. GoRoute generates it and
you cannot choose it. It is a string beginning
whsec_, shown once in that201response and never again โ later reads return only the first eight characters, assecret_preview. Six of those eight characters are thewhsec_prefix every secret carries, so the preview identifies almost nothing; it is not a value you can verify against. If you no longer have the secret, 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โ
The automatic retries described below, the 10-second response timeout, the 15-second limit on
a whole attempt, and the X-Webhook-Attempt and X-Webhook-Delivery-ID headers all need a
deployment that has automatic retry. Not every GoRoute deployment does.
A deployment without it behaves differently in four ways that matter to your code. Each
event is attempted once. The response timeout is 30 seconds, not 10, and there is no
separate limit on the attempt as a whole. Four headers are sent, not six โ X-Webhook-Attempt
and X-Webhook-Delivery-ID are absent. And a delivery that fails is not sent again on its
own: that event is finished, and getting it is yours to trigger with a manual retry or a
per-transaction replay (both listed at the end of this section).
The check, which costs you nothing: look for X-Webhook-Attempt on any delivery your
endpoint receives, including a test event from
POST /api/v1/webhooks/{webhook_id}/test. If that header is there, you are talking to a
service that retries. If your handler never sees it, you are not, and you should build the
recovery yourself rather than wait for a second attempt that is not coming. Log whether you
saw it; that answer is worth more than this page, because it is about the service you are
actually calling.
Failed deliveries are retried automatically. A non-2xx response, a connection error or a
timeout is recorded against the delivery, and GoRoute sends the same event again later:
the same event ID (id, X-Event-ID), the same created_at and the same data, with
X-Webhook-Attempt counting up and a new signature. An event is attempted at most six times:
| Attempt | Sent |
|---|---|
| 1 | When the event happens |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes after attempt 2 failed |
| 4 | 30 minutes after attempt 3 failed |
| 5 | 2 hours after attempt 4 failed |
| 6 | 6 hours after attempt 5 failed |
So an endpoint that is down for up to about nine hours misses nothing. The first 2xx ends the
event's retries, and so does a
manual retry
that succeeds first. Retries can arrive after later events, so do not rely on arrival order;
key on id and read the state you need from the API. Each attempt is its own entry in the delivery list, with its attempt_number.
| Behaviour | Value |
|---|---|
| Attempts per event, made automatically | Six: the first and five retries (schedule above) |
| Response timeout | 10 seconds (5-second connect timeout); the whole attempt ends 15 seconds after it starts |
| Response treated as success | Any 2xx status code |
| Response body read | At most the first 1,000 bytes, kept on the delivery record; it does not change the outcome |
| Events in a row not delivered before the webhook is switched off | 10 |
| Attempts per event in all, automatic and manual, before manual retry is refused | 10 |
| Cooldown between manual retries | 5 seconds |
| Replay rate limit, per organisation | 10 per minute |
Ten undelivered events in a row switch the webhook off. An event counts as undelivered
once its last automatic attempt has failed, so one dead event is one failure, not six.
A test event or a manual retry that fails counts as one too (neither is retried
automatically). Any successful delivery resets the count to zero; consecutive_failures
on the webhook is that count. On reaching ten, the webhook's status becomes disabled and
its last_error says why:
Disabled automatically: the last 10 events could not be delivered, each after its automatic
retries (last error: HTTP 500). No events are sent while it is disabled. Fix the endpoint,
then set status to active to switch it back on.
Because only an event's last attempt counts, an endpoint that stops answering keeps its
webhook active for at least eight and a half hours (60 + 300 + 1,800 + 7,200 + 21,600
seconds: its first event's full retry schedule), and the webhook is switched off only once
ten events have each run out of retries. Until then every new event is attempted six times.
While it is disabled no event is sent to it: events that happen are not queued for it, and
a queued retry that comes due is dropped. last_error keeps that reason until you switch it
back on, even if you send it a test event. Fix the endpoint, check it with
POST /api/v1/webhooks/{webhook_id}/test, then set status back to active with
PATCH /api/v1/webhooks/{webhook_id}: the count and the reason are cleared, retries that
come due from then on are sent, and the events you missed can be replayed per transaction
(below).
A webhook you paused is treated the same way while paused. GoRoute does not email you
when a webhook is switched off, and the catalogue's account.webhook_disabled event is
planned and not sent yet; poll status and last_error.
A retry goes only where the event would be sent now: once a webhook no longer lists the
event's type in events, or is scoped with api_key_id to a key other than the one that
created the event's document, its pending retries of that event are dropped.
Earlier revisions of this page said there was no automatic retry and gave a 30-second timeout. That is not only a description of the past: it is still what a deployment without automatic retry does, which is what the caution at the top of this section is about. Those revisions also counted ten failed attempts rather than events. Where an event is attempted six times those are different numbers, and the one that switches a webhook off is events; where an event is attempted once they are the same number.
Beyond the automatic retries, recovery is something you trigger โ and on a deployment without automatic retry it is the only recovery there is. A replayed event is retried automatically like any other; a manual retry is a single attempt.
| 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.
Reading the event list from the APIโ
Seven endpoints let you read the event names and your own event history straight from GoRoute, rather than copying a list out of this page, manage the read flag on the feed, and ask about the categories that are not live yet. All seven need an API key and are subject to the standard rate limit.
GET /api/v1/events/catalogโ
Returns every event name the create call will accept, grouped into categories. This is the
same list POST /api/v1/webhooks validates your events array against, so a name that is
absent here is a name that will be rejected with 422 Unprocessable Entity.
{
"catalog": [
{
"category": "transaction",
"label": "Transaction",
"tier": "basic",
"icon": "exchange",
"events": [
{
"type": "transaction.queued",
"title": "Transaction Queued",
"description": "Document has been queued for processing and delivery.",
"severity": "info",
"enabled": true,
"status": "live"
}
]
}
]
}
The example above is trimmed to one category and one event so that the shape is readable.
A real response lists every category, and carries total_events alongside catalog.
| Field | Description |
|---|---|
catalog[].category | Short category name, and the value you pass to the category filter on the activity feed. |
catalog[].label | Display name of the category. |
catalog[].tier | basic or premium. See below. |
catalog[].icon | An icon name used by the GoRoute dashboard. Ignore it. |
catalog[].events[].type | The event name you put in events when you create a webhook. |
catalog[].events[].title | Short display name. |
catalog[].events[].description | One sentence on what the event means. |
catalog[].events[].severity | One of info, success, warning, error. |
catalog[].events[].enabled | See the warning below โ it does not mean what it looks like. |
catalog[].events[].status | live if the platform dispatches this event to webhooks today, planned if the name is accepted but nothing sends it. This is the field to read. |
total_events | Count of every event across every category. A top-level field, beside catalog. |
This page prints no totals, on purpose. How many names the catalogue holds, and how many
of them are live, changes with every release, and the answer that matters to you is the one
your own service gives. Read total_events and each name's status from the catalogue you
fetch: it is served by the same build that would send you the event, so it is right about that
build in a way this page cannot be. If you are comparing environments, fetch it from each.
tier tells you what a name is for. status tells you whether anything sends itThese two fields answer different questions, and reading tier as though it answered
"will this ever arrive?" will mislead you.
tierplaces a name in the product. One category isbasicโtransaction, six names โ and the other seventeen arepremium.statusis the only field that says whether a line of the platform dispatches the event.livemeans one does;plannedmeans none does.
Premium does not mean silent. Most of the live events are in premium categories, not the
basic one. Four premium categories carry live names: response (all seven of them),
approval (all three), tax_reporting and authority. Those events are dispatched to any
subscription that asks for them, with the exception of the three approval names, which are
never sent at all โ see Approval Events. All six names in the basic
transaction category are live too.
live is a statement about the code, not a promise of delivery. It means the platform
contains a line that dispatches the event. It does not mean any path a customer can trigger
reaches that line, and for the three approval names none does. Nor, until a country's
reporting runs in the tax-authority layer, does any reach the
authority.* lines.
What is true of premium is the other way round: most premium names are planned and send
nothing. Thirteen of the seventeen premium categories have no live event at all. So check
status for the name you want, rather than reasoning from the category it sits in.
There is no API call that switches a tier on, and none is needed to receive a live premium
event. If you want a category that is planned built, tell us with
POST /api/v1/events/premium-inquiry.
enabled describes the tier, not your accountenabled is computed from the category's tier alone. It is true for every event in a
basic category and false for every event in a premium one, for every caller. It is not a
statement about what your organisation has been granted, and it does not change when a
premium category is enabled for you.
Nor does it gate anything: the create call accepts every name in the catalogue from every
account, including the premium ones, so a webhook subscribing to a premium event will be
created successfully whatever enabled says. Read status for the question you actually have.
The catalogue is the list of names the API will accept. It is longer than the list of events the platform actually emits. Subscribing to a catalogued name that nothing emits gives you a webhook that is created, is healthy, and stays silent forever.
Every event carries status, which answers this: live means a line of the platform
dispatches it, planned means nothing does. The platform holds itself to that field โ the
catalogue names, beside each live event, the function that sends it, and a test refuses any
change that lets the two drift apart.
The events with a section on this page are the ones traced to a line of the platform that sends them. For anything else, confirm with support before you build on it.
GET /api/v1/eventsโ
Returns your organisation's activity feed, newest first. It is the place to look first when a webhook did not arrive: it tells you whether an event was recorded at all, which separates "we never raised it" from "we raised it and the delivery failed".
Filters, all optional:
| Parameter | Type | Description |
|---|---|---|
category | string | One category name, as spelled in the catalogue โ for example transaction. |
event_type | string | One exact event name, for example transaction.delivered. |
severity | string | info, success, warning or error. |
is_read | boolean | Filter on the read flag. |
limit | integer | 1 to 100. Defaults to 50. |
offset | integer | 0 or more. Defaults to 0. |
{
"events": [
{
"id": "6f4d2a11-8c3e-4b7a-9f10-2d5e8c1b4a63",
"event_type": "transaction.delivered",
"category": "transaction",
"severity": "success",
"title": "Transaction Delivered",
"description": "Document successfully delivered to the recipient.",
"resource_type": "transaction",
"resource_id": "550e8400-e29b-41d4-a716-446655440000",
"data": {},
"actor_id": null,
"actor_name": null,
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"is_read": false,
"created_at": "2026-01-15T10:32:00.884210"
}
],
"total": 1,
"limit": 50,
"offset": 0,
"unread_count": 1
}
| Field | Description |
|---|---|
events[].id | UUID of the feed entry. This is not the evt_... id of a webhook message. |
events[].event_type | The event name. |
events[].category | The catalogue category the name belongs to. |
events[].severity | info, success, warning or error. |
events[].title | Short display name. |
events[].description | Longer text. May be null. |
events[].resource_type | What the entry is about, where one was recorded. May be null. |
events[].resource_id | Identifier of that thing. May be null. |
events[].data | Free-form object. Defaults to {} and is not the webhook message body. |
events[].actor_id | Who caused it, where a user was involved. May be null. |
events[].actor_name | Their display name. May be null. |
events[].transaction_id | Related transaction UUID, where there is one. May be null. |
events[].is_read | Read flag, set from the dashboard or through the mark-read call. |
events[].created_at | ISO 8601 timestamp. |
total | Matching entries, ignoring limit and offset. |
limit, offset | Echoed back from your request. |
unread_count | Unread entries for the whole organisation, not just this page of results. |
The activity feed and webhook delivery are written by two different parts of the platform.
Not every event that is dispatched to a webhook is also written to the feed, and the feed
entry's data is not the webhook message body. Treat the feed as a useful indication of
what happened, not as an audit trail of what we delivered.
For what was actually sent, and for its request and response bodies, use the delivery endpoints in the table above.
POST /api/v1/events/mark-readโ
Sets the read flag on feed entries. The body takes one field, event_ids, and the response
tells you how many rows it changed:
curl -X POST https://app.goroute.ai/peppol-api/api/v1/events/mark-read \
-H "X-API-Key: $GOROUTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_ids": ["6f4d2a11-8c3e-4b7a-9f10-2d5e8c1b4a63"]}'
{
"marked": 1
}
| Field | Description |
|---|---|
event_ids | The feed entry UUIDs to mark read โ the events[].id values from GET /api/v1/events, not the evt_... id of a webhook message. An empty list means every event in your organisation. |
marked | How many rows were changed. |
A body is required. POST with no body at all is rejected with 422 Unprocessable Entity;
{} is accepted and is the same as an empty event_ids.
IDs that do not belong to your organisation are silently ignored rather than refused, so
marked can be lower than the number of IDs you sent. With an empty list, marked counts only
the entries that were still unread, so calling it twice returns a number and then 0.
event_ids marks your whole feed read{"event_ids": []} and {} both mean "mark everything read for this organisation" โ not
"mark nothing". This is the behaviour of the service as it runs today (September 2026). A loop
that builds an ID list and happens to build an empty one on a quiet day will clear the unread
state of the entire feed rather than doing nothing. Check the list is non-empty before you
call, and treat the empty-list form as a deliberate "mark all read" button, which is what the
dashboard uses it for.
GET /api/v1/events/unread-countโ
Returns the number of unread entries in your organisation's feed, and nothing else. It is the
cheap call for a badge or a poll; GET /api/v1/events returns the same number in its
unread_count field if you need the entries as well.
{
"unread_count": 12
}
| Field | Description |
|---|---|
unread_count | Unread entries for the whole organisation. Takes no filters โ there is no per-category or per-severity unread count. |
DELETE /api/v1/eventsโ
Deletes every activity event for your organisation and returns how many it destroyed.
{
"deleted": 1843
}
| Field | Description |
|---|---|
deleted | How many feed entries were deleted. |
Three things about this call are worth knowing before you use it:
- It takes no arguments. There is no ID list, no date range and no category filter. One call removes every activity event your organisation has, however many that is.
- It cannot be undone. There is no soft delete, no recycle bin and no export step built
into it. If you want the history, take
GET /api/v1/events/exportfirst. - No permission check stands in front of it. Unlike, for example, participant
registration, which requires
participants:manage, this route requires no named permission and no scope. Any credential your organisation accepts can call it โ including a read-only API key and a viewer's dashboard session. That is the service as it runs today (September 2026); we have asked for a permission to be added.
The feed is not an audit trail (see the warning above), so deleting it does not destroy compliance records โ your transactions, documents and delivery history are held separately and are unaffected. It does destroy the record you would use to work out whether an event was ever raised.
GET /api/v1/events/exportโ
Returns the same activity feed as GET /api/v1/events, as a CSV file rather than JSON.
The response is text/csv with
Content-Disposition: attachment; filename=goroute-activity-events.csv, so a browser or an
HTTP client will save it as a file rather than show it.
Filters, both optional and spelled exactly as they are on GET /api/v1/events:
| Parameter | Type | Description |
|---|---|---|
category | string | One category name, as spelled in the catalogue โ for example transaction. |
severity | string | info, success, warning or error. |
There are no other filters. In particular there is no date range, no event_type filter
and no is_read filter, even though GET /api/v1/events has the last two.
The file has a header row and eleven columns, in this order:
| Column | Contents |
|---|---|
Event ID | UUID of the feed entry. |
Event Type | The event name. |
Category | The catalogue category. |
Severity | info, success, warning or error. |
Title | Short display name. |
Description | Longer text. Empty when there is none. |
Resource Type | What the entry is about. Empty when there is none. |
Resource ID | Identifier of that thing. Empty when there is none. |
Transaction ID | Related transaction UUID. Empty when there is none. |
Read | Yes or No. |
Created At | ISO 8601 timestamp. |
Event ID,Event Type,Category,Severity,Title,Description,Resource Type,Resource ID,Transaction ID,Read,Created At
6f4d2a11-8c3e-4b7a-9f10-2d5e8c1b4a63,transaction.delivered,transaction,success,Transaction Delivered,Document successfully delivered to the recipient.,transaction,550e8400-e29b-41d4-a716-446655440000,550e8400-e29b-41d4-a716-446655440000,No,2026-01-15T10:32:00.884210
The call returns your 10,000 most recent matching events, newest first, and there is no
paging: no offset, no cursor, no "more results" flag. A busy month will not fit, and a
truncated file looks exactly like a complete one.
If you might have more than 10,000 events, take several narrower exports โ one per
category, or one per severity โ rather than one wide one, and check the row count of
each against the total that GET /api/v1/events reports for the same filter.
POST /api/v1/events/premium-inquiryโ
Asks GoRoute to look at enabling one or more premium event categories for your organisation. It sends your request to the GoRoute team by email. It does not change anything about your account, and nothing starts dispatching because you called it โ someone reads it and replies.
You do not need this to receive a premium event that is already live; those are dispatched
to any subscription that asks for them. Use it for a category whose events are planned.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
full_name | string | yes | Your name. 1 to 200 characters. |
email | string | yes | Where we should reply. 5 to 254 characters. |
company_name | string | yes | Your company. 1 to 200 characters. |
event_categories | array of strings | yes | The categories you want, as spelled in the catalogue โ for example batch. At least one. |
phone | string | no | Up to 30 characters. Defaults to empty. |
use_case | string | no | What you are building and why you need these events. Up to 2000 characters. |
estimated_volume | string | no | Free text, for example ~5,000 invoices/month. Up to 100 characters. |
Your organisation ID is taken from your API key and included automatically; do not put it in the body.
curl -X POST https://app.goroute.ai/peppol-api/api/v1/events/premium-inquiry \
-H "X-API-Key: $GOROUTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"full_name": "Anna Vermeulen",
"email": "anna@example.com",
"company_name": "Example Trading BV",
"event_categories": ["batch", "compliance"],
"phone": "+31 20 123 4567",
"use_case": "We reconcile batch uploads nightly and want an event instead of polling.",
"estimated_volume": "~5,000 invoices/month"
}'
{
"success": true,
"message": "Your inquiry has been submitted. Our team will respond within 1 business day."
}
| Field | Description |
|---|---|
success | true when the inquiry was sent. |
message | Confirmation text, as above. |
If the inquiry could not be emailed, the call returns 502 with a detail telling you to
try again or write to the address in the message. A 502 means nobody received your
inquiry, so retry it rather than assuming it landed.