Docs
9

Webhooks

Registering an address, signing and delivery.

  • In the sandboxsigning
  • In the sandboxdelivery
  • In the sandboxregistration

In plain words

A webhook lets eurinvoice push each status change to a web address the client runs, so the client's system does not have to ask. You register the address through the API, every delivery is signed, and failed deliveries are retried. The client's developers build the receiving side.

A webhook pushes each status event to a URL you run. The body is the status event, unchanged (see statuses). You register the URL through the API (see below), every delivery is signed, and a failed delivery is retried.

Signature

In the sandboxsigning

Every delivery carries these headers.

HeaderValue
X-Eurinvoice-Signaturet=<unix seconds>,v1=<signature>
X-Eurinvoice-Event-IdThe event's event_id.
X-Eurinvoice-Delivery-Attempt1 for the first delivery, 2 for the first retry, and so on.
Content-Typeapplication/json

v1 is an HMAC-SHA256, written as 64 lowercase hex characters. The key is the webhook secret, as a UTF-8 string. The message is t, a dot, and the raw body. Refuse a signature whose t is more than 300 seconds from your clock, in either direction, and compare in constant time.

After a secret rotation the header carries two v1 values for 24 hours: one signed with the new secret, then one with the old. Accept the delivery if any v1 matches your secret. Read the first one only and a receiver that has already switched to the new secret refuses every delivery for that day.

Warning

Check the body exactly as it arrived. A parsed and re-serialised copy will not match.

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Verify {
    /**
     * True when one of the header's v1 values matches the raw body and the header is at most five minutes old.
     * For 24 hours after a secret rotation the header carries two v1 values, so check them all.
     * Pass the body exactly as it arrived.
     */
    static boolean verify(String secret, String header, byte[] rawBody, long nowSeconds) throws Exception {
        String t = null;
        java.util.List<String> signatures = new java.util.ArrayList<>();
        for (String part : header == null ? new String[0] : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length == 2 && pair[0].equals("t")) t = pair[1];
            if (pair.length == 2 && pair[0].equals("v1")) signatures.add(pair[1]);
        }
        if (t == null) return false;
        long seconds;
        try {
            seconds = Long.parseLong(t);
        } catch (NumberFormatException e) {
            return false;
        }
        if (Math.abs(nowSeconds - seconds) > 300) return false;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((seconds + ".").getBytes(StandardCharsets.UTF_8));
        byte[] expected = mac.doFinal(rawBody);
        boolean ok = false;
        for (String signature : signatures) {
            if (signature.matches("[0-9a-f]{64}") && MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) ok = true;
        }
        return ok;
    }

    // Check it against the sample on this page: java Verify.java
    public static void main(String[] args) throws Exception {
        String secret = "sample-secret";
        byte[] body = "{\"event_id\":\"evt_12345678\",\"sequence\":1}".getBytes(StandardCharsets.UTF_8);
        String header = "t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e";
        System.out.println(verify(secret, header, body, 1790000100L)); // true
        System.out.println(verify(secret, header, body, 1790000400L)); // false: older than five minutes
        byte[] changed = "{\"event_id\":\"evt_12345678\",\"sequence\":2}".getBytes(StandardCharsets.UTF_8);
        System.out.println(verify(secret, header, changed, 1790000100L)); // false: body changed
    }
}
Run as they are: java Verify.java, node verify.mjs. The sample header was made by the service's own signing code.

The sample is the secret sample-secret, the body {"event_id":"evt_12345678","sequence":1} and the header t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Checked at 1790000100 it is valid. At 1790000400 it is too old, and with "sequence":2 the body no longer matches.

Both snippets agree with the service's own check on 28 cases. They include a header 301 seconds old, a changed body, a wrong secret, a missing part, a body with non-ASCII text and the two-signature header from a rotation.

What a receiver does

  1. Serve the URL over HTTPS. Delivery goes to public addresses only.
  2. Verify the signature and its age before reading the body.
  3. Answer with a 2xx within 10 seconds. Anything else, or no answer in time, is a failure and is retried.
  4. Keep the event_id of each event you handle, and ignore one you have seen. A retry can deliver the same event again.
receiver.mjs
import http from 'node:http';
import { verify } from './verify.mjs';

const seen = new Set(); // keep these in a database in real use

http
  .createServer(async (req, res) => {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const body = Buffer.concat(chunks);
    if (!verify(process.env.WEBHOOK_SECRET, req.headers['x-eurinvoice-signature'], body)) {
      res.writeHead(401).end();
      return;
    }
    const event = JSON.parse(body);
    if (!seen.has(event.event_id)) {
      seen.add(event.event_id);
      // hand the event to your own queue here, then answer quickly
    }
    res.writeHead(204).end(); // any 2xx within 10 seconds counts as delivered
  })
  .listen(3000);
A Node receiver. It was run on a local port and answered 204 to a signed event and 401 to a changed body, a stale signature and no signature.

Delivery

In the sandboxdelivery

A delivery that fails is tried again after 1 minute, then 5 minutes, then 30 minutes, then every hour until 24 hours after the first failure. After that it is dead-lettered. The stored event is never changed. Each attempt is signed again with the current time, so a retry is not refused as too old.

Registration

In the sandboxregistration

A client's key with the admin scope registers the client's own address. Registration is three calls:

CallWhat it does
PUT /webhookSets the url and creates the secret, which is returned once. Send rotate_secret: true to make a new secret; for 24 hours each delivery carries a signature for the new secret and one for the old, so you can switch without losing events. contact_email says who to tell when deliveries die.
GET /webhookShows the settings: url, active, when the secret was last rotated and the last delivery's time and HTTP status. The secret is never shown again.
POST /webhook/testSends a signed test event to the address. It reports no invoice.

The address must be https, and every address its host resolves to must be public. Loopback, private, link-local, multicast and cloud metadata addresses are refused, when you set it and again before each delivery, and redirects are not followed. Only events written after the first PUT are delivered.

Tip

Read GET /invoices/{id}/events to catch up after downtime (see read an invoice). It returns every event for the invoice, so a missed delivery loses nothing.

Not offered yet

  • Custom headers.
  • Mutual TLS.
  • A delivery log.
  • A fixed source address.

Tell us which one your pilot needs.

On this page