> For the complete documentation index, see [llms.txt](https://api.qvalia.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://api.qvalia.io/api-documentation/apis/partner-api/webhooks.md).

# Webhooks

Qvalia delivers platform events to your server through HTTPS POST requests.

Qvalia can POST an event to your endpoint when a document arrives, changes delivery status, or fails. This page covers subscribing, what arrives, and what to expect of delivery. Signing is covered separately under Verifying a delivery.

> **NB! All subscribed endpoints must use `https`!**

### Subscribing

Each subscription covers exactly one account:

| Endpoint                                                               | Covers                                                     |
| ---------------------------------------------------------------------- | ---------------------------------------------------------- |
| `PUT /account/{accountRegNo}/webhook/configure`                        | your own account                                           |
| `PUT /partner/{partnerRegNo}/webhook/configure`                        | the partner's own account only — not its children          |
| `PUT /partner/{partnerRegNo}/account/{accountRegNo}/webhook/configure` | one child account, as the partner's own subscription to it |

A partner that wants its children's events subscribes to each child individually; there is no subscription to all of them at once.

An account and its partner can both subscribe to the same account. The partner's subscription to a child is separate from the child's own — neither can see or change the other's — and an event for the child is delivered to both, with the same `eventId`. A partner's subscription to a child delivers only while the partner may act for it (the child's `parentAccount` is the partner and the partner holds the `webhook` scope), which is checked on every delivery. `accountRegNo` in the body says which account an event is about.

The first `PUT` creates the subscription and returns a generated `id` (UUID v4). Subsequent `PUT` requests update the same subscription's `url` and `types`. The `url` must be `https`.

Use the returned `id` with `.../webhook/{webhookId}/auth` to attach outbound authentication, and with `.../webhook/{webhookId}/secret` to manage the signing secret.

A `PUT` that creates a subscription also returns a `signingSecret` in clear text, in that response only.

### Event types

Choose which to receive in `types`:

| Type                | Sent when                                        |
| ------------------- | ------------------------------------------------ |
| `new_document`      | a document is received or created for an account |
| `document_delivery` | a document's delivery status changes             |
| `document_delayed`  | a delivery is being retried and has not finished |
| `document_error`    | a document's delivery fails                      |

`document_delayed` arrives once per retry cycle while a Peppol send works through its retry window, which can run up to 24 hours. A terminal `document_delivery` or `document_error` follows. Leave the type out of `types` if you do not want that traffic; the terminal events arrive either way.

To distinguish a retry from a real failure, branch on the event type rather than on the text of `error`.

### Delivery

Events are delivered as an HTTP `POST` with a JSON body. Respond with `2xx`; the request times out after 10 seconds.

Every delivery carries:

```
X-Qvalia-Event-Id: 4d1c9f84-3a2b-5c6d-9e0f-1a2b3c4d5e6f
X-Qvalia-Signature: t=1757846400,v1=5257a869e7ecebeda32affa62cdca3fa...
```

`X-Qvalia-Signature` is present once the subscription has a signing secret. Any outbound authentication you configured is applied as request headers on the same request.

**Delivery is at-least-once.** Make your endpoint idempotent and deduplicate on `eventId`, which identifies the event rather than the delivery attempt and is identical across redeliveries. Do not deduplicate on the whole body: redeliveries differ in `status.updatedAt`.

**A failed delivery is not retried.** A non-2xx response, a timeout or an unreachable endpoint is recorded against the document and not attempted again.

**A missed event can be fetched again for 30 days.** Every delivered body is kept and served by its `eventId`:

| Endpoint                                                               | For                      |
| ---------------------------------------------------------------------- | ------------------------ |
| `GET /account/{accountRegNo}/webhook/{eventId}`                        | the account's own events |
| `GET /partner/{partnerRegNo}/webhook/{eventId}`                        | the partner's own events |
| `GET /partner/{partnerRegNo}/account/{accountRegNo}/webhook/{eventId}` | a child account's events |

The response is the body exactly as it was POSTed, same `eventId` included, so it deduplicates against a delivery you did receive. It is not signed; the request is authenticated like the rest of the API. An event is served only over the route for the account it is about (`accountRegNo` in the body): a partner fetches a child's event over the child route, even one delivered to its own subscription to that child. An unknown, expired or another account's `eventId` answers `204`.

To find what you missed, read the document's `GET /transaction/{accountRegNo}/{documents}/{direction}/status`: every delivery attempt is listed under `metadata.webhooks_dispatched`, with its `eventId`, `success` and the HTTP `status`/`statusText` your endpoint returned.

{% hint style="warning" %}
Webhooks still cannot be your only source of truth. After 30 days an event is gone, and you only learn a missed `eventId` from `/status`. Keep polling `GET /transaction/…` as a safety net, on whatever interval your reconciliation needs. Signing and deduplication make a delivery you *receive* trustworthy; neither makes delivery guaranteed.
{% endhint %}

Ordering is not guaranteed. A `document_delivery` can arrive before the `new_document` for the same document. Order by `occurredAt`, not by arrival time.

### Payload

One flat JSON object: the event fields, plus the document detail on the same level.

| Field                 | Notes                                                                                                                                                                                                                                                                                     |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventId`             | Identifies the event, not the attempt. Deduplicate on this.                                                                                                                                                                                                                               |
| `eventType`           | One of the four types above.                                                                                                                                                                                                                                                              |
| `accountRegNo`        | The account the document belongs to.                                                                                                                                                                                                                                                      |
| `documentType`        | `Invoice`, `CreditNote`, `Order`.                                                                                                                                                                                                                                                         |
| `direction`           | `incoming` or `outgoing`.                                                                                                                                                                                                                                                                 |
| `integrationId`       | Qvalia's document id. Use it to `GET` the document.                                                                                                                                                                                                                                       |
| `occurredAt`          | When the event happened, ISO-8601.                                                                                                                                                                                                                                                        |
| `documentId`          | Document number. Usually on `new_document`, often absent later.                                                                                                                                                                                                                           |
| `globalTransactionId` | Platform-wide id for the document exchange.                                                                                                                                                                                                                                               |
| `status`              | Delivery-lifecycle detail; see below.                                                                                                                                                                                                                                                     |
| `error`               | Failure reason. Present on `document_error`.                                                                                                                                                                                                                                              |
| `peppol_metadata`     | Peppol exchange detail recorded for the document so far: message id, access point, document type, process, exchange time and the sender/receiver participant ids. `null` for non-Peppol deliveries; `{}` when nothing is known yet, such as a retry report before any exchange succeeded. |

Fields are omitted when they have no value, so treat a missing key and an empty value the same. `peppol_metadata` is the exception: it is explicitly `null`.

#### The `status` object

| Field            | Notes                                                           |
| ---------------- | --------------------------------------------------------------- |
| `status`         | Delivery status from the upstream channel. Open set; see below. |
| `event`          | Internal lifecycle event. Maps 1:1 onto `eventType`.            |
| `deliveryMethod` | `peppol`, `print`, `email`, `api`, `kivra`, and others.         |
| `updatedAt`      | When we recorded this status. Differs between redeliveries.     |

`status.status` is **not an enum**. It is free text reported by the delivery channel, and a new channel can introduce a value not listed here. The values emitted today are:

| Value                    | Final? | Meaning                                   |
| ------------------------ | ------ | ----------------------------------------- |
| `pending`                | no     | Logged; nothing attempted yet.            |
| `delayed`                | no     | Being retried.                            |
| `warning`                | no     | Reopened by a rules-processor reset.      |
| `processed`              | yes    | Delivered.                                |
| `processed_with_warning` | yes    | Delivered, with something worth flagging. |
| `error`                  | yes    | Delivery failed.                          |

It may also be absent. Branch on the values you care about and treat anything unrecognised, or a missing `status`, as "no change".

#### Examples

`new_document`:

```json
{
  "eventId": "4d1c9f84-3a2b-5c6d-9e0f-1a2b3c4d5e6f",
  "eventType": "new_document",
  "accountRegNo": "SE5560004755",
  "documentType": "Invoice",
  "direction": "outgoing",
  "integrationId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "occurredAt": "2026-08-19T09:25:36.512Z",
  "documentId": "123456-INV",
  "globalTransactionId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "status": {
    "event": "message-log/create",
    "deliveryMethod": "peppol",
    "updatedAt": "2026-08-19T09:25:37.228Z"
  },
  "peppol_metadata": {
    "messageId": "9cab8ba5-d2a4-45d0-842d-8ede91dcac9f@QVALIA-PSE000094",
    "accessPoint": "PSE000094",
    "docTypeId": "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",
    "processId": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
    "exchangeDateTime": "2026-08-19T09:25:34.226Z"
  }
}
```

`document_delayed`, one per retry cycle:

```json
{
  "eventId": "2a9d5c31-7b8e-5f4a-9c1d-3e5f7a9b1c3d",
  "eventType": "document_delayed",
  "accountRegNo": "SE5560004755",
  "documentType": "Invoice",
  "direction": "outgoing",
  "integrationId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "occurredAt": "2026-08-19T11:26:10.104Z",
  "globalTransactionId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "documentId": "123456-INV",
  "status": {
    "status": "delayed",
    "event": "message-log/delayed",
    "deliveryMethod": "peppol",
    "updatedAt": "2026-08-19T11:26:09.881Z"
  },
  "peppol_metadata": {}
}
```

`document_delivery`, a status transition on a document already announced by `new_document`. `documentId` is often absent at this stage:

```json
{
  "eventId": "8b3e1a07-6c5d-5e4f-9a2b-7c8d9e0f1a2b",
  "eventType": "document_delivery",
  "accountRegNo": "SE5560004755",
  "documentType": "Invoice",
  "direction": "outgoing",
  "integrationId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "occurredAt": "2026-08-19T09:26:10.104Z",
  "globalTransactionId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "status": {
    "status": "processed",
    "event": "message-log/update",
    "deliveryMethod": "peppol",
    "updatedAt": "2026-08-19T09:26:09.881Z"
  },
  "peppol_metadata": {
    "messageId": "9cab8ba5-d2a4-45d0-842d-8ede91dcac9f@QVALIA-PSE000094",
    "accessPoint": "PSE000094",
    "docTypeId": "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",
    "processId": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
    "exchangeDateTime": "2026-08-19T09:25:34.226Z"
  }
}
```

`document_error`, with `error` carrying the reason:

```json
{
  "eventId": "c71f2d93-4e8a-5b6c-8d7e-2f3a4b5c6d7e",
  "eventType": "document_error",
  "accountRegNo": "SE5560004755",
  "documentType": "Invoice",
  "direction": "outgoing",
  "integrationId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "occurredAt": "2026-08-19T09:26:10.104Z",
  "globalTransactionId": "6b928ef1-fb0d-4b9a-a56f-b5dbca7a0fd4",
  "status": {
    "status": "error",
    "event": "message-log/error",
    "deliveryMethod": "peppol",
    "updatedAt": "2026-08-19T09:26:09.881Z"
  },
  "error": "Peppol validation failed: invoice does not conform to UBL 2.1",
  "peppol_metadata": null
}
```

### Verifying a delivery

Deliveries are signed with HMAC-SHA256 once the subscription has a signing secret, so your endpoint can confirm the body came from Qvalia unaltered. This is independent of the outbound credential under `.../auth`, which authenticates Qvalia to your endpoint. Use both.

The secret is symmetric, so treat it like a password. There is no public key to fetch.

To verify:

1. Parse `t` and every `v1=` value from `X-Qvalia-Signature`, ignoring prefixes you do not recognise.
2. Reject the delivery if `|now - t|` exceeds your tolerance. 5 minutes is recommended.
3. Compute `HMAC-SHA256(secret, "<t>.<raw body>")` and hex-encode it.
4. Compare against each `v1=` value in constant time. One match is enough.
5. Reject the delivery if you have already processed its `X-Qvalia-Event-Id`.

Sign the exact bytes you received, not a re-serialised JSON object. Key order and whitespace change the hash, and this is the most common cause of a failing first implementation.

Steps 2 and 5 are both needed. The timestamp caps how long a captured delivery stays usable; the event-id check is what rejects a replay inside that window. The signature itself proves only origin and integrity.

#### Rotation

`POST .../webhook/{webhookId}/secret` issues a new secret and expires the previous ones `overlapHours` (default 24) from now. While the window is open each delivery carries a `v1=` part per active secret, so you can deploy the new one at your own pace.

Use `overlapHours: 0` if a secret has leaked. A subscription holds at most 5 secrets.

Subscriptions created before signing existed carry no secret and stay unsigned until you call that endpoint.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://api.qvalia.io/api-documentation/apis/partner-api/webhooks.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
