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 first built. UTC, ISO 8601, with microseconds and no timezone suffix โ€” parse it as UTC. An automatic retry repeats the first attempt's value.
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. 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.

EventSent when
transaction.queuedYou submitted a document and GoRoute accepted it for processing.
transaction.deliveredAn outgoing document reached the recipient's Access Point.
transaction.retryingAn outgoing document failed for a reason worth another attempt, and will be sent again.
transaction.failedAn outgoing document failed terminally. For an Omani invoice that GoRoute reports another way, only if that also fails โ€” see Omani invoices.
transaction.receivedA document addressed to you arrived from the Peppol network.
inbound.deliveredA 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_substituteAn Oman invoice could not be delivered, so it was reported to the tax authority under a substitute participant.
tdd.reported_via_bis_copyAn 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.receivedA 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.clearedThe Omani Tax Authority accepted the tax report for your invoice.
tdd.rejectedThe Omani Tax Authority rejected the tax report for your invoice.
tdd.failedGoRoute 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.pingYou 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"
}
}
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.
delivered_viaOnly for an Omani invoice delivered as its BIS 3.0 copy: bis_copy. Absent otherwise.
bis_copy_transaction_idOnly 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
}
}
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.
fallbackPresent only on an Omani invoice whose fallback failed (see below): bis_copy or substitute, the fallback that could not report the invoice.
fallback_errorPresent 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 happenedEvents you receive, in order
The buyer accepts only BIS 3.0, and the copy was deliveredtransaction.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 Peppoltdd.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 transporttdd.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 buyerNothing 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 Authoritytransaction.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
}
}
FieldDescription
transaction_idUUID of the transaction.
statusAlways retrying for this event.
error_codeMachine-readable code for the attempt that failed.
error_messageHuman-readable detail of that attempt.
retry_countHow 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"
}
}
FieldDescription
transaction_idUUID of the transaction. Fetch the document with it.
inbound_document_idUUID 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.
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 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 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 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"
}
}
FieldDescription
transaction_idUUID of your original outgoing transaction, not of the response message. For an answer on a BIS 3.0 copy, your PINT OM invoice.
response_codeThe two-letter code from the table above.
reference_idThe 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_idIdentifier of the response message itself.
descriptionFree-text reason from the sender, where one was given. May be null.
received_atWhen 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/rules create, list, read, update and delete rules, and what you store is kept.
  • POST /api/v1/approvals/check works, 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:

  1. POST /api/v1/drafts to create the document without sending it.
  2. Your own review. POST /api/v1/approvals/check can tell you whether your stored rules say this invoice needs one.
  3. POST /api/v1/drafts/{draft_id}/send once 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.

Treat reason_code as an open set

Several 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
}
}
FieldDescription
transaction_idUUID of your original outgoing transaction.
substitute_participantThe 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.
scopeThe 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_statusAlways reported_via_substitute for this event.
deliveredAlways 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
}
}
FieldDescription
transaction_idUUID of your original outgoing transaction, the PINT OM invoice.
bis_copy_transaction_idUUID of the transaction that carried the BIS 3.0 copy to the buyer.
tdd_transaction_idUUID of the transaction that carried the tax report.
scopeThe TDD document scope code the report was filed under, normally IP (international, exchanged over Peppol).
clearance_statusAlways 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.
deliveredAlways 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"
}
}
FieldDescription
transaction_idUUID 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_codeThe code, verbatim. AP is accepted and RE is rejected; other codes are neither, and carry a null clearance status.
mls_idThe sender's identifier for the status message itself. Use it to tell two answers about the same invoice apart.
clearance_statuscleared 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_idThe reference the status message quotes back: the transmission message ID of the document it answers. null when it gave none.
descriptionThe sender's own text, or null.
received_atWhen 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 expect

mls.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
}
}
FieldDescription
transaction_idUUID of your invoice.
tdd_transaction_idUUID of the Tax Data Document's own transaction, the one the Authority answered.
regimeAlways OM for this event.
legtdd for the invoice's report. tdd_r for the corrected report (Resubmit) GoRoute filed after a rejection, tdd_d for a Disregard.
response_codeAlways AP for this event.
mls_idThe Authority's identifier for the status message that carried the acceptance.
descriptionThe Authority's own text, or null.
clearance_statusAlways cleared for this event.
reporting_state, reason_code, reasonnull 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."
}
}
FieldDescription
transaction_idUUID of your invoice.
tdd_transaction_idUUID of the Tax Data Document's own transaction, the one the Authority rejected.
regimeAlways OM for this event.
legtdd for the invoice's report, tdd_r for its Resubmit, tdd_d for a Disregard.
response_codeAlways RE for this event.
mls_idThe Authority's identifier for the status message that carried the rejection.
descriptionThe Authority's own text about the rejection, or null when it gave none.
clearance_statusrejected 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_stateerror when the invoice is in error (no Resubmit under way, or the Resubmit rejected); otherwise null.
reason_codeC5_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.
reasonOne 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
}
}
FieldDescription
transaction_idUUID of your invoice.
tdd_transaction_idUUID of the Tax Data Document's own transaction. null when leg is c2 or substitute, because no report exists yet.
regimeAlways OM for this event.
legtdd 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_stateAlways error for this event.
clearance_statusAlways failed for this event, as the invoice's own clearance_status reads.
reason_codeC5_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.
reasonOne sentence describing the reason, safe to show to a person.
attemptsHow 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.

EventSent when
authority.submittedThe report was sent to the tax authority. Its answer is still to come.
authority.acknowledgedThe tax authority accepted the report.
authority.rejectedThe tax authority rejected the report.
authority.skippedNo report was filed, for the reason given: for example, the Peppol delivery it depends on failed.
authority.failedGoRoute could not file the report and has stopped trying. reason_code and reason say why.
authority.heldThe 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.overdueThe 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
}
}
FieldDescription
transaction_idUUID of your document.
leg_idUUID of the report (the leg). One document can owe more than one.
regimeThe tax authority's country code, for example OM.
leg_typeWhich report this is. The values depend on the country.
statusThe 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_refThe tax authority's own reference for the report, once it has given one.
skip_reasonWhy no report was filed. Only for authority.skipped.
reason_codeA 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).
reasonOne 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_atWhen GoRoute next tries the report, or null. UTC, ISO 8601.
deadline_atWhen the report is due, where the country sets a deadline, or null. UTC, ISO 8601.
overdue_sinceWhen 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 dataUse it for
idThe 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_idThe transaction the document arrived on, to join to transaction.received and to the transaction endpoints.
statusdraft_created once the draft bill exists.
needs_attentiontrue 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_countHow many unresolved exceptions there are.
exceptionsThe open exceptions only, each with its code. Every code is listed, with what raises it and what GoRoute did about it, under Exception codes.
envelopeTwelve 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.
deliveryWhere it landed โ€” adapter, connector id, timestamp and Odoo's own identifiers for the bill and attachments.
parse_error, created_at, updated_atDiagnosis 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.

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. The same on every attempt of one event.
X-Event-TypeThe event name, matching event_type in the body.
X-Webhook-AttemptWhich 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-IDUUID 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:

  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. GoRoute generates it and you cannot choose it. It is a string beginning whsec_, shown once in that 201 response and never again โ€” later reads return only the first eight characters, as secret_preview. Six of those eight characters are the whsec_ 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.
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โ€‹

Automatic retry needs a GoRoute service that has it โ€” check before you rely on it

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:

AttemptSent
1When the event happens
21 minute after attempt 1 failed
35 minutes after attempt 2 failed
430 minutes after attempt 3 failed
52 hours after attempt 4 failed
66 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.

BehaviourValue
Attempts per event, made automaticallySix: the first and five retries (schedule above)
Response timeout10 seconds (5-second connect timeout); the whole attempt ends 15 seconds after it starts
Response treated as successAny 2xx status code
Response body readAt 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 off10
Attempts per event in all, automatic and manual, before manual retry is refused10
Cooldown between manual retries5 seconds
Replay rate limit, per organisation10 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.

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.

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.

FieldDescription
catalog[].categoryShort category name, and the value you pass to the category filter on the activity feed.
catalog[].labelDisplay name of the category.
catalog[].tierbasic or premium. See below.
catalog[].iconAn icon name used by the GoRoute dashboard. Ignore it.
catalog[].events[].typeThe event name you put in events when you create a webhook.
catalog[].events[].titleShort display name.
catalog[].events[].descriptionOne sentence on what the event means.
catalog[].events[].severityOne of info, success, warning, error.
catalog[].events[].enabledSee the warning below โ€” it does not mean what it looks like.
catalog[].events[].statuslive 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_eventsCount 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 it

These two fields answer different questions, and reading tier as though it answered "will this ever arrive?" will mislead you.

  • tier places a name in the product. One category is basic โ€” transaction, six names โ€” and the other seventeen are premium.
  • status is the only field that says whether a line of the platform dispatches the event. live means one does; planned means 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 account

enabled 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.

A name in the catalogue is not a promise that we send it

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:

ParameterTypeDescription
categorystringOne category name, as spelled in the catalogue โ€” for example transaction.
event_typestringOne exact event name, for example transaction.delivered.
severitystringinfo, success, warning or error.
is_readbooleanFilter on the read flag.
limitinteger1 to 100. Defaults to 50.
offsetinteger0 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
}
FieldDescription
events[].idUUID of the feed entry. This is not the evt_... id of a webhook message.
events[].event_typeThe event name.
events[].categoryThe catalogue category the name belongs to.
events[].severityinfo, success, warning or error.
events[].titleShort display name.
events[].descriptionLonger text. May be null.
events[].resource_typeWhat the entry is about, where one was recorded. May be null.
events[].resource_idIdentifier of that thing. May be null.
events[].dataFree-form object. Defaults to {} and is not the webhook message body.
events[].actor_idWho caused it, where a user was involved. May be null.
events[].actor_nameTheir display name. May be null.
events[].transaction_idRelated transaction UUID, where there is one. May be null.
events[].is_readRead flag, set from the dashboard or through the mark-read call.
events[].created_atISO 8601 timestamp.
totalMatching entries, ignoring limit and offset.
limit, offsetEchoed back from your request.
unread_countUnread entries for the whole organisation, not just this page of results.
The feed is a separate record, not a copy of what we sent you

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
}
FieldDescription
event_idsThe 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.
markedHow 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.

An empty 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
}
FieldDescription
unread_countUnread 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
}
FieldDescription
deletedHow many feed entries were deleted.
This deletes your entire activity feed, permanently, and nothing stands in its way

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/export first.
  • 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:

ParameterTypeDescription
categorystringOne category name, as spelled in the catalogue โ€” for example transaction.
severitystringinfo, 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:

ColumnContents
Event IDUUID of the feed entry.
Event TypeThe event name.
CategoryThe catalogue category.
Severityinfo, success, warning or error.
TitleShort display name.
DescriptionLonger text. Empty when there is none.
Resource TypeWhat the entry is about. Empty when there is none.
Resource IDIdentifier of that thing. Empty when there is none.
Transaction IDRelated transaction UUID. Empty when there is none.
ReadYes or No.
Created AtISO 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 export stops at 10,000 rows and does not tell you it truncated

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:

FieldTypeRequiredDescription
full_namestringyesYour name. 1 to 200 characters.
emailstringyesWhere we should reply. 5 to 254 characters.
company_namestringyesYour company. 1 to 200 characters.
event_categoriesarray of stringsyesThe categories you want, as spelled in the catalogue โ€” for example batch. At least one.
phonestringnoUp to 30 characters. Defaults to empty.
use_casestringnoWhat you are building and why you need these events. Up to 2000 characters.
estimated_volumestringnoFree 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."
}
FieldDescription
successtrue when the inquiry was sent.
messageConfirmation 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.

Next Stepsโ€‹