> ## Documentation Index
> Fetch the complete documentation index at: https://docs.e-invoice.be/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Create a webhook, verify the signature of each event in your receiver, and debug deliveries with the test call and the delivery history.

A [webhook](/glossary#webhook) is an HTTP `POST` request that the API sends to your server when a document event occurs. Use webhooks to learn that a document was sent, received or failed without polling the API.

Each webhook belongs to one company. A sandbox company and a production company each have their own API key and their own webhooks.

## Set up a webhook

<Steps>
  <Step title="Create the webhook">
    Send the URL of your receiver and the events that you want. See [Create a webhook](#create-a-webhook).
  </Step>

  <Step title="Store the signing secret">
    The response contains the `secret`. Put it in a secret manager or an environment variable. See [Get the signing secret](#get-the-signing-secret).
  </Step>

  <Step title="Implement the receiver">
    Verify the `X-Signature` header, return a `2xx` status, then process the event. See [Implement the receiver](#implement-the-receiver).
  </Step>

  <Step title="Send a test event">
    Call the test endpoint to deliver an event that you define. See [Send a test event](#send-a-test-event).
  </Step>

  <Step title="Check the result">
    Read the response of the test call and the [delivery history](#delivery-history).
  </Step>
</Steps>

## Create a webhook

<ParamField body="url" type="string" required>
  The address of your receiver. See [Receiver URL requirements](#receiver-url-requirements).
</ParamField>

<ParamField body="events" type="string[]" required>
  The event types that this webhook receives. See [Events](#events). An unknown value gives HTTP `422`.
</ParamField>

<ParamField body="enabled" type="boolean" default="true">
  A flag that the API stores and returns with the webhook.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.e-invoice.be/api/webhooks/" \
    -H "Authorization: Bearer $E_INVOICE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://example.com/webhook",
      "events": ["document.received", "document.sent", "document.sent.failed"],
      "enabled": true
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch("https://api.e-invoice.be/api/webhooks/", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.E_INVOICE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://example.com/webhook",
      events: ["document.received", "document.sent", "document.sent.failed"],
      enabled: true,
    }),
  });

  const webhook = await response.json();
  console.log(webhook.id);
  ```

  ```python Python theme={null}
  import os

  import requests

  response = requests.post(
      "https://api.e-invoice.be/api/webhooks/",
      headers={"Authorization": f"Bearer {os.environ['E_INVOICE_API_KEY']}"},
      json={
          "url": "https://example.com/webhook",
          "events": ["document.received", "document.sent", "document.sent.failed"],
          "enabled": True,
      },
      timeout=30,
  )
  response.raise_for_status()
  webhook = response.json()
  print(webhook["id"])
  ```

  ```php PHP theme={null}
  <?php
  $curl = curl_init("https://api.e-invoice.be/api/webhooks/");
  curl_setopt_array($curl, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => [
          "Authorization: Bearer " . getenv("E_INVOICE_API_KEY"),
          "Content-Type: application/json",
      ],
      CURLOPT_POSTFIELDS => json_encode([
          "url" => "https://example.com/webhook",
          "events" => ["document.received", "document.sent", "document.sent.failed"],
          "enabled" => true,
      ]),
  ]);

  $webhook = json_decode(curl_exec($curl), true);
  curl_close($curl);
  echo $webhook["id"];
  ```

  ```csharp C# theme={null}
  using System.Net.Http.Headers;
  using System.Net.Http.Json;
  using System.Text.Json;

  using var client = new HttpClient { BaseAddress = new Uri("https://api.e-invoice.be") };
  client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
      "Bearer", Environment.GetEnvironmentVariable("E_INVOICE_API_KEY"));

  var response = await client.PostAsJsonAsync("/api/webhooks/", new
  {
      url = "https://example.com/webhook",
      events = new[] { "document.received", "document.sent", "document.sent.failed" },
      enabled = true,
  });
  response.EnsureSuccessStatusCode();

  var webhook = await response.Content.ReadFromJsonAsync<JsonElement>();
  Console.WriteLine(webhook.GetProperty("id").GetString());
  ```
</CodeGroup>

The API returns HTTP `201`:

```json theme={null}
{
  "id": "webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "url": "https://example.com/webhook",
  "events": ["document.received", "document.sent", "document.sent.failed"],
  "enabled": true,
  "secret": "5f2b8c1e9d4a7036b1c8e5f2a9d6037441be7c0a3d8f6e2915c4b7a0d3e6f921"
}
```

A company can have more than one webhook. Each webhook that subscribes to an event type receives its own request for that event.

## Get the signing secret

The API makes the secret when you create the webhook. The secret is a string of 64 hexadecimal characters. You cannot supply your own value.

* The `secret` field is in the response of the create, get, list and update calls. You can read it again at any time.
* You cannot change the secret of a webhook. To rotate the secret, create a new webhook, deploy the new secret to your receiver, then delete the old webhook.
* Keep the secret in a secret manager or an environment variable. Do not put it in source code, in logs or in client-side code.

<Warning>
  Each person or system that has the API key of the company can read the secret with the get and list calls. Protect the API key with the same care as the secret.
</Warning>

## Receiver URL requirements

* **Method**: the receiver accepts `POST` requests with a JSON body.
* **Protocol**: `http` and `https` URLs are accepted. Use `https` for a production company.
* **Certificate**: an `https` receiver must have a certificate from a trusted certificate authority. Delivery to a receiver with a self-signed certificate fails.
* **Redirects**: the API does not follow redirects. A `3xx` response is a failed delivery.
* **Basic authentication**: you can put credentials in the URL, for example `https://username:password@example.com/webhook`.

<Warning>
  Register the final address of your receiver. If the address answers with a redirect (for example from `http://` to `https://`, or from the apex domain to `www`), each delivery fails.
</Warning>

<Warning>
  The get and list calls return the webhook URL in clear text, credentials included. If you use Basic authentication in the URL, use credentials that are only for this receiver.
</Warning>

## Event payload

The API sends each event as a `POST` request with these headers:

| Header | Value |
| - | - |
| `X-Signature` | `sha256=` followed by the HMAC-SHA256 digest in hexadecimal. See [Verify the signature](#verify-the-signature). |
| `X-Event-Type` | The event type, for example `document.sent`. |
| `Content-Type` | `application/json` |
| `User-Agent` | `e-invoice-be-webhook-service` |

The body is a JSON object:

```json theme={null}
{
  "id": "evt-e7wyc7gtpqx4z73x2wqmwhebhbb3r8n3ovfhcsdbulxr3s2awf49de76yglrnri3",
  "tenant_id": "ten-abc123",
  "created_at": 1762780249,
  "type": "document.sent",
  "data": {
    "document_id": "doc-1"
  },
  "text": "\u26a1\ufe0f New webhook event: document.sent\n\n{\n  \"document_id\": \"doc-1\"\n}"
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Identifier of this delivery attempt. It is not stable across retries. See [Duplicates and order](#duplicates-and-order). |
| `tenant_id` | string | Identifier of the company ([tenant](/glossary#tenant)) that owns the webhook. |
| `created_at` | integer | Unix time in seconds at which the API built this delivery attempt. |
| `type` | string | The event type. |
| `data` | object | The event data. For document events it contains `document_id` only. |
| `text` | string | A text summary of the event. It contains non-ASCII characters and line breaks. |

The payload does not contain the document. Use `data.document_id` to get it:

```bash cURL theme={null}
curl "https://api.e-invoice.be/api/documents/doc-1" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY"
```

For a failure event, get the document and read its state. To find the cause, see [A document is in state FAILED](/guides/errors#a-document-is-in-state-failed) and [Document lifecycle and delivery tracking](/guides/document-lifecycle).

## Events

Document events are sent to your webhooks. The `data` object of each one is `{"document_id": "doc-..."}`.

| Event | Sent when | `data` fields |
| - | - | - |
| `document.received` | The API received a document for your company and stored it. In a sandbox company, Simulate inbound sends this event. | `document_id` |
| `document.received.failed` | The API could not process a document that it received for your company. | `document_id` |
| `document.sent` | A document was sent. In a sandbox company, the email delivery of a send gives this event. | `document_id` |
| `document.sent.failed` | A send of a document failed. | `document_id` |

To get the document that a `document.received` event announces, see [Receive documents](/guides/receiving-documents).

The `events` field of the create call also accepts the values below. They are reserved: do not build a receiver that depends on them.

`document.error`, `email.received`, `email.received.failed`, `email.sent`, `email.sent.failed`, `peppol.received`, `peppol.received.failed`, `peppol.sent`, `peppol.sent.failed`

<Note>
  The OpenAPI specification gives `events` as a list of strings without an enumeration. This page is the reference for the permitted values.
</Note>

## Verify the signature

Each request has an `X-Signature` header. Verify it before you process the event. A request without a correct signature did not come from e-invoice.be.

The API computes the signature as follows:

1. It serialises the complete event (all fields, not only `data`) to a canonical JSON string.
2. It encodes that string as UTF-8.
3. It computes the HMAC-SHA256 of those bytes with the webhook secret as the key.
4. It sends `sha256=` followed by the digest in lowercase hexadecimal.

### Canonical JSON string

The canonical string is the output of Python `json.dumps(payload, sort_keys=True)`:

* The keys of each object are in ascending order, at each level.
* The separator between items is a comma followed by one space (`, `). The separator between a key and its value is a colon followed by one space (`: `). There are no line breaks and there is no other white space.
* Each character outside the printable ASCII range is written as a `\uXXXX` escape with lowercase hexadecimal digits. A character above U+FFFF is written as a surrogate pair.
* The characters `"` and `\` are escaped with a backslash. Line feed, carriage return, tab, backspace and form feed are written as `\n`, `\r`, `\t`, `\b` and `\f`. The character `/` is not escaped.

<Warning>
  Do not compute the HMAC of the raw request body. The body that the API sends is not the canonical string: it has no spaces after the separators, its keys are not sorted and non-ASCII characters are not escaped. Parse the body, build the canonical string, then compute the HMAC.
</Warning>

<Warning>
  `JSON.stringify` in JavaScript, `json_encode` in PHP and `JsonSerializer` in C# do not give the canonical string. They do not sort the keys and they do not put a space after the separators. Use the serialiser functions in the samples below.
</Warning>

### Test vector

Use this vector to test your code before you connect it to the API.

Secret:

```text theme={null}
secret
```

Event: the payload in [Event payload](#event-payload).

Canonical string (one line):

```text theme={null}
{"created_at": 1762780249, "data": {"document_id": "doc-1"}, "id": "evt-e7wyc7gtpqx4z73x2wqmwhebhbb3r8n3ovfhcsdbulxr3s2awf49de76yglrnri3", "tenant_id": "ten-abc123", "text": "\u26a1\ufe0f New webhook event: document.sent\n\n{\n  \"document_id\": \"doc-1\"\n}", "type": "document.sent"}
```

In this string, `\n`, `\"` and `\u26a1\ufe0f` are literal escape sequences. They are not line breaks or decoded characters.

Signature:

```text theme={null}
sha256=2f8ec8fab5adedd8f82a2b4064f559c40b14aed0c6da27ed394e51176b10bd1b
```

## Implement the receiver

Each sample is a complete receiver. It reads the secret from the environment variable `E_INVOICE_WEBHOOK_SECRET`, parses the body, builds the canonical string, compares the signature in constant time and returns a status code.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";
  import { createServer } from "node:http";

  const secret = process.env.E_INVOICE_WEBHOOK_SECRET;

  // Write a string as Python json.dumps does (ensure_ascii=True).
  function quote(text) {
    let out = '"';
    for (const unit of text.split("")) {
      const code = unit.charCodeAt(0);
      if (unit === '"') out += '\\"';
      else if (unit === "\\") out += "\\\\";
      else if (unit === "\n") out += "\\n";
      else if (unit === "\r") out += "\\r";
      else if (unit === "\t") out += "\\t";
      else if (unit === "\b") out += "\\b";
      else if (unit === "\f") out += "\\f";
      else if (code < 0x20 || code > 0x7e) out += "\\u" + code.toString(16).padStart(4, "0");
      else out += unit;
    }
    return out + '"';
  }

  // Sorted keys, ", " between items, ": " between key and value.
  function canonicalJson(value) {
    if (value === null) return "null";
    if (typeof value === "string") return quote(value);
    if (typeof value !== "object") return String(value);
    if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(", ") + "]";
    const members = Object.keys(value)
      .sort()
      .map((key) => quote(key) + ": " + canonicalJson(value[key]));
    return "{" + members.join(", ") + "}";
  }

  function expectedSignature(body, secret) {
    const canonical = canonicalJson(JSON.parse(body));
    return "sha256=" + createHmac("sha256", secret).update(canonical, "utf8").digest("hex");
  }

  function isValid(received, expected) {
    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  }

  createServer((request, response) => {
    if (request.method !== "POST" || request.url !== "/webhook") {
      response.writeHead(404).end();
      return;
    }

    const chunks = [];
    request.on("data", (chunk) => chunks.push(chunk));
    request.on("end", () => {
      const body = Buffer.concat(chunks).toString("utf8");
      const received = request.headers["x-signature"] ?? "";

      let expected;
      try {
        expected = expectedSignature(body, secret);
      } catch {
        response.writeHead(400).end();
        return;
      }

      if (!isValid(received, expected)) {
        response.writeHead(401).end();
        return;
      }

      // Reply first, then do the work.
      response.writeHead(200).end();

      const event = JSON.parse(body);
      setImmediate(() => handleEvent(event));
    });
  }).listen(3000);

  function handleEvent(event) {
    // Put the event on your queue. Get the document with event.data.document_id.
    console.log(event.type, event.data.document_id);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import os

  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["E_INVOICE_WEBHOOK_SECRET"]


  def expected_signature(body: bytes, secret: str) -> str:
      # json.dumps with sort_keys=True gives the canonical string.
      # Do not add separators= or ensure_ascii=False.
      canonical = json.dumps(json.loads(body), sort_keys=True)
      digest = hmac.new(
          secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
      ).hexdigest()
      return f"sha256={digest}"


  @app.post("/webhook")
  def webhook():
      body = request.get_data()
      received = request.headers.get("X-Signature", "")

      try:
          expected = expected_signature(body, SECRET)
      except ValueError:
          return "", 400

      if not hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8")):
          return "", 401

      event = json.loads(body)
      enqueue(event)  # Keep this fast. Do the work in a background job.
      return "", 200


  def enqueue(event: dict) -> None:
      # Put the event on your queue. Get the document with event["data"]["document_id"].
      print(event["type"], event["data"]["document_id"])
  ```

  ```php PHP theme={null}
  <?php
  // Write a string as Python json.dumps does (ensure_ascii=True).
  function canonical_string(string $text): string
  {
      $json = json_encode($text, JSON_UNESCAPED_SLASHES);
      return str_replace("\x7f", '\u007f', $json);
  }

  // Sorted keys, ", " between items, ": " between key and value.
  function canonical_json($value): string
  {
      if ($value instanceof stdClass) {
          $members = (array) $value;
          ksort($members, SORT_STRING);
          $parts = [];
          foreach ($members as $key => $member) {
              $parts[] = canonical_string((string) $key) . ': ' . canonical_json($member);
          }
          return '{' . implode(', ', $parts) . '}';
      }
      if (is_array($value)) {
          return '[' . implode(', ', array_map('canonical_json', $value)) . ']';
      }
      if (is_string($value)) {
          return canonical_string($value);
      }
      return json_encode($value);
  }

  function expected_signature(string $body, string $secret): string
  {
      // Decode objects as stdClass, so that {} and [] stay different.
      $canonical = canonical_json(json_decode($body, false, 512, JSON_THROW_ON_ERROR));
      return 'sha256=' . hash_hmac('sha256', $canonical, $secret);
  }

  $secret = getenv('E_INVOICE_WEBHOOK_SECRET');
  $body = file_get_contents('php://input');
  $received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

  try {
      $expected = expected_signature($body, $secret);
  } catch (JsonException $e) {
      http_response_code(400);
      exit;
  }

  if (!hash_equals($expected, $received)) {
      http_response_code(401);
      exit;
  }

  $event = json_decode($body, true);
  // Put the event on your queue. Get the document with $event['data']['document_id'].
  // Keep this fast. Do the work in a background job.
  http_response_code(200);
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;
  using System.Text.Json;

  var secret = Environment.GetEnvironmentVariable("E_INVOICE_WEBHOOK_SECRET")!;
  var app = WebApplication.CreateBuilder(args).Build();

  app.MapPost("/webhook", async (HttpRequest request) =>
  {
      using var reader = new StreamReader(request.Body, Encoding.UTF8);
      var body = await reader.ReadToEndAsync();
      var received = request.Headers["X-Signature"].ToString();

      JsonDocument document;
      try
      {
          document = JsonDocument.Parse(body);
      }
      catch (JsonException)
      {
          return Results.BadRequest();
      }

      using (document)
      {
          var expected = ExpectedSignature(document.RootElement, secret);
          if (!CryptographicOperations.FixedTimeEquals(
                  Encoding.UTF8.GetBytes(received), Encoding.UTF8.GetBytes(expected)))
          {
              return Results.Unauthorized();
          }

          var type = document.RootElement.GetProperty("type").GetString();
          // Put the event on your queue. Keep this fast. Do the work in a background job.
          Console.WriteLine(type);
      }

      return Results.Ok();
  });

  app.Run();

  static string ExpectedSignature(JsonElement payload, string secret)
  {
      var canonical = Canonical(payload);
      using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
      var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
      return "sha256=" + Convert.ToHexString(digest).ToLowerInvariant();
  }

  // Sorted keys, ", " between items, ": " between key and value.
  static string Canonical(JsonElement element) => element.ValueKind switch
  {
      JsonValueKind.Object => "{" + string.Join(", ", element.EnumerateObject()
          .OrderBy(member => member.Name, StringComparer.Ordinal)
          .Select(member => Quote(member.Name) + ": " + Canonical(member.Value))) + "}",
      JsonValueKind.Array => "[" + string.Join(", ", element.EnumerateArray().Select(Canonical)) + "]",
      JsonValueKind.String => Quote(element.GetString()!),
      JsonValueKind.Number => element.GetRawText(),
      JsonValueKind.True => "true",
      JsonValueKind.False => "false",
      _ => "null",
  };

  // Write a string as Python json.dumps does (ensure_ascii=True).
  static string Quote(string text)
  {
      var builder = new StringBuilder("\"");
      foreach (var unit in text)
      {
          switch (unit)
          {
              case '"': builder.Append("\\\""); break;
              case '\\': builder.Append("\\\\"); break;
              case '\n': builder.Append("\\n"); break;
              case '\r': builder.Append("\\r"); break;
              case '\t': builder.Append("\\t"); break;
              case '\b': builder.Append("\\b"); break;
              case '\f': builder.Append("\\f"); break;
              default:
                  if (unit < 0x20 || unit > 0x7e) builder.Append("\\u").Append(((int)unit).ToString("x4"));
                  else builder.Append(unit);
                  break;
          }
      }
      return builder.Append('"').ToString();
  }
  ```
</CodeGroup>

The Node.js sample uses only built-in modules. The Python sample uses Flask. The PHP sample is a script behind a web server. The C# sample is an ASP.NET Core minimal API.

<Note>
  The document events contain only strings and integers, and the samples give the correct canonical string for them. If you send a test event with decimal numbers in `data`, the JavaScript serialiser can give a different result, because JavaScript does not keep the difference between `1.0` and `1`.
</Note>

Rules for the receiver:

* Verify the signature before you use the payload.
* Compare the signatures with a constant-time function.
* Return a `2xx` status in less than 10 seconds. Do slow work (get the document, update your database) in a background job.
* Return `401` for a wrong signature. Do not return a redirect.

## Delivery and retries

* A delivery is successful when your receiver returns a `2xx` status in less than 10 seconds.
* Each other result is a failure: a status that is not `2xx` (redirects included), a timeout, a connection error or a certificate error.
* The API makes a maximum of 3 attempts for each event. The wait time between attempts increases. After the last failed attempt, the API does not send the event again.
* The wait time between attempts is not a fixed value. Do not build logic that depends on it.

If your receiver was unavailable for a longer time, get the missed documents with the inbox and outbox calls. See [Receive documents](/guides/receiving-documents).

## Duplicates and order

* **Duplicates**: your receiver can get the same event more than once, for example when it processed the request but answered too late. Each attempt has a new `id`, a new `created_at` and a new signature. Thus the `id` cannot identify a duplicate. De-duplicate on the combination of `type` and `data.document_id`.
* **More than one webhook**: if two webhooks subscribe to the same event type, each one gets a request with its own `id`.
* **Order**: the order of delivery is not guaranteed. A `document.sent.failed` event can arrive after a later `document.sent` event for the same document. Do not derive the state of a document from the order of the events. Get the document and read its `state`.

## Test on your computer

<Steps>
  <Step title="Start your receiver">
    Run one of the receiver samples on a local port, for example port 3000.
  </Step>

  <Step title="Open a tunnel">
    The API cannot reach `localhost`. Use a tunnel tool (for example ngrok or Cloudflare Tunnel) to get a public `https` address that forwards to your local port.
  </Step>

  <Step title="Create a webhook in a sandbox company">
    Use the API key of a sandbox company and the public address of the tunnel as `url`. Set `E_INVOICE_WEBHOOK_SECRET` to the `secret` from the response and start the receiver again.
  </Step>

  <Step title="Send events">
    Send a test event with the test call below. To get a `document.received` event with a document that you can get from the API, use Simulate inbound in the sandbox company. To get a `document.sent` event, send a document from the sandbox company. See [Test mode and sandbox companies](/environments#testing-received-documents).
  </Step>
</Steps>

<Tip>
  Before you open a tunnel, run your signature function on the [test vector](#test-vector). If the result is not the given digest, the canonical string is wrong.
</Tip>

### Send a test event

The test call sends an event with the `event_type` and `data` that you supply. The webhook must subscribe to the event type.

```bash cURL theme={null}
curl -X POST "https://api.e-invoice.be/api/webhooks/webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d/test" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "document.sent",
    "data": {
      "document_id": "doc-test-123"
    }
  }'
```

The API returns HTTP `200`. The response has one of two forms.

If the API delivered the event during the call, `webhook_delivery_result` contains the result of that one attempt:

```json theme={null}
{
  "success": true,
  "message": "Webhook test completed successfully",
  "webhook_delivery_result": {
    "success": true,
    "status_code": 200,
    "error": null,
    "elapsed_seconds": 0.21
  }
}
```

If the API put the event on the delivery queue, the response confirms only that:

```json theme={null}
{
  "success": true,
  "message": "Webhook test event enqueued for delivery via the retry queue",
  "webhook_delivery_result": {
    "success": true,
    "enqueued": true,
    "max_retries": 3
  }
}
```

A failed delivery during the call also gives HTTP `200`, with `success` set to `false`:

```json theme={null}
{
  "success": false,
  "message": "Webhook test completed with delivery failure",
  "webhook_delivery_result": {
    "success": false,
    "status_code": 401,
    "error": "HTTP 401: Unauthorized",
    "elapsed_seconds": 0.18
  }
}
```

The `error` field contains the status code and the first 100 characters of the response body of your receiver. An `event_type` that is unknown, or that the webhook does not subscribe to, gives HTTP `422`.

<Note>
  The `document_id` in a test event is the value that you supply. It is not necessarily a document that exists.
</Note>

## Delivery history

The history call returns the recorded delivery results of one webhook.

```bash cURL theme={null}
curl "https://api.e-invoice.be/api/webhooks/webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d/history" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY"
```

```json theme={null}
{
  "history": [
    {
      "event_type": "document.sent",
      "event_data": {
        "document_id": "doc-1"
      },
      "result": {
        "success": true,
        "status_code": 200,
        "error": null,
        "elapsed_seconds": 0.42
      }
    },
    {
      "event_type": "document.received",
      "event_data": {
        "document_id": "doc-2"
      },
      "result": {
        "success": false,
        "status_code": 500,
        "error": "HTTP 500: Internal Server Error",
        "elapsed_seconds": 0.37
      }
    }
  ]
}
```

| Field | Description |
| - | - |
| `event_type` | The event type. |
| `event_data` | The `data` object of the event. |
| `result.success` | `true` if the receiver returned a `2xx` status. |
| `result.status_code` | The HTTP status of the receiver, or `null` if there was no response. |
| `result.error` | `null` on success. On failure: the status code and the first 100 characters of the response body, or the timeout or connection error. |
| `result.elapsed_seconds` | The duration of the attempt in seconds. |

Limits of the history:

* There is no pagination and there are no filters. The call returns all recorded rows of the webhook.
* A row has no timestamp and no event `id`.
* The history has one row for each event: the successful attempt, or the last failed attempt. Failed attempts that were followed by a retry are not recorded.
* Not each delivery is recorded. An empty history is not proof that the API sent no events.
* An unknown webhook identifier gives an empty `history` list, not HTTP `404`.

## Manage webhooks

Each call needs the API key of the company that owns the webhook. A wrong or absent key gives HTTP `401`. An unknown webhook identifier gives HTTP `404` on the get, update, delete and test calls. See [Errors and troubleshooting](/guides/errors).

### List webhooks

```bash cURL theme={null}
curl "https://api.e-invoice.be/api/webhooks/" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY"
```

```json theme={null}
[
  {
    "id": "webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
    "url": "https://example.com/webhook",
    "events": ["document.received", "document.sent", "document.sent.failed"],
    "enabled": true,
    "secret": "5f2b8c1e9d4a7036b1c8e5f2a9d6037441be7c0a3d8f6e2915c4b7a0d3e6f921"
  }
]
```

### Get a webhook

```bash cURL theme={null}
curl "https://api.e-invoice.be/api/webhooks/webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY"
```

```json theme={null}
{
  "id": "webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "url": "https://example.com/webhook",
  "events": ["document.received", "document.sent", "document.sent.failed"],
  "enabled": true,
  "secret": "5f2b8c1e9d4a7036b1c8e5f2a9d6037441be7c0a3d8f6e2915c4b7a0d3e6f921"
}
```

### Update a webhook

The update call accepts `url`, `events` and `enabled`. All fields are optional; the API changes only the fields that you send. The secret does not change.

```bash cURL theme={null}
curl -X PUT "https://api.e-invoice.be/api/webhooks/webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/e-invoice",
    "events": ["document.received", "document.received.failed"]
  }'
```

```json theme={null}
{
  "id": "webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "url": "https://example.com/webhooks/e-invoice",
  "events": ["document.received", "document.received.failed"],
  "enabled": true,
  "secret": "5f2b8c1e9d4a7036b1c8e5f2a9d6037441be7c0a3d8f6e2915c4b7a0d3e6f921"
}
```

<Warning>
  Send only the event types from the [Events](#events) section. The update call does not check the values in `events` in the same way as the create call.
</Warning>

### Delete a webhook

```bash cURL theme={null}
curl -X DELETE "https://api.e-invoice.be/api/webhooks/webhook-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d" \
  -H "Authorization: Bearer $E_INVOICE_API_KEY"
```

```json theme={null}
{
  "is_deleted": true
}
```

After the delete call, the webhook receives no new events and the get call gives HTTP `404`. To stop all deliveries to a receiver, delete its webhook.

## Security checklist

* Use an `https` URL with a certificate from a trusted certificate authority.
* Verify the `X-Signature` header of each request, with a constant-time comparison.
* Keep the secret out of source code and logs.
* Treat the webhook URL as readable by each holder of the API key. Credentials in the URL are returned in clear text by the get and list calls.
* Do not trust the payload for business decisions. Get the document from the API with `data.document_id`.
* To rotate the secret, create a new webhook and delete the old one.

## Next Steps

<CardGroup cols={2}>
  <Card title="Receive documents" icon="inbox" href="/guides/receiving-documents">
    Get the documents that a `document.received` event announces.
  </Card>

  <Card title="Document lifecycle and delivery tracking" icon="arrows-rotate" href="/guides/document-lifecycle">
    Read the document states behind the sent and failed events.
  </Card>

  <Card title="Errors and troubleshooting" icon="triangle-exclamation" href="/guides/errors">
    Find the cause of an error response from the API.
  </Card>

  <Card title="Test mode and sandbox companies" icon="flask" href="/environments">
    Test webhooks in a sandbox company with Simulate inbound.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.