Docs
9

Webhooks

Enregistrer une adresse, signature et livraison.

  • Dans le bac à sablesignature
  • Dans le bac à sablelivraison
  • Dans le bac à sableenregistrement

En termes simples

Un webhook permet à eurinvoice de pousser chaque changement de statut vers une adresse web gérée par le client, pour que le système du client n’ait pas à le demander. Vous enregistrez l’adresse par l’API, chaque livraison est signée, et les livraisons en échec sont retentées. Les développeurs du client réalisent la partie réceptrice.

Un webhook pousse chaque événement de statut vers une URL que vous gérez. Le corps est l’événement de statut, inchangé (voir statuts). Vous enregistrez l’URL par l’API (voir ci-dessous), chaque livraison est signée, et une livraison en échec est retentée.

Signature

Dans le bac à sablesignature

Chaque livraison comporte ces en-têtes.

En-têteValeur
X-Eurinvoice-Signaturet=<unix seconds>,v1=<signature>
X-Eurinvoice-Event-IdLe event_id de l’événement.
X-Eurinvoice-Delivery-Attempt1 pour la première livraison, 2 pour la première nouvelle tentative, et ainsi de suite.
Content-Typeapplication/json

v1 est un HMAC-SHA256, écrit en 64 caractères hexadécimaux minuscules. La clé est le secret du webhook, sous forme de chaîne UTF-8. Le message est t, un point, puis le corps brut. Refusez une signature dont le t s’écarte de plus de 300 secondes de votre horloge, dans un sens comme dans l’autre, et comparez en temps constant.

Après une rotation du secret, l’en-tête contient deux valeurs v1 pendant 24 heures : l’une signée avec le nouveau secret, puis l’autre avec l’ancien. Acceptez la livraison si l’une des valeurs v1 correspond à votre secret. Si vous ne lisez que la première, un récepteur qui est déjà passé au nouveau secret refuse toutes les livraisons de ce jour-là.

Attention

Vérifiez le corps exactement tel qu’il est arrivé. Une copie analysée puis resérialisée ne correspondra pas.

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
    }
}
À exécuter tels quels : java Verify.java, node verify.mjs. L’en-tête d’exemple a été produit par le code de signature du service lui-même.

L’exemple utilise le secret sample-secret, le corps {"event_id":"evt_12345678","sequence":1} et l’en-tête t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Vérifié à 1790000100, il est valide. À 1790000400, il est trop ancien, et avec "sequence":2 le corps ne correspond plus.

Les deux extraits concordent avec la vérification du service lui-même sur 28 cas. Ils comprennent un en-tête vieux de 301 secondes, un corps modifié, un mauvais secret, une partie manquante, un corps contenant du texte non ASCII et l’en-tête à deux signatures d’une rotation.

Ce que fait un récepteur

  1. Servez l’URL en HTTPS. La livraison se fait uniquement vers des adresses publiques.
  2. Vérifiez la signature et son âge avant de lire le corps.
  3. Répondez par un 2xx dans les 10 secondes. Toute autre réponse, ou une absence de réponse dans le délai, est un échec et donne lieu à une nouvelle tentative.
  4. Conservez le event_id de chaque événement traité, et ignorez celui que vous avez déjà vu. Une nouvelle tentative peut livrer à nouveau le même événement.
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);
Un récepteur Node. Il a été exécuté sur un port local et a répondu 204 à un événement signé, et 401 à un corps modifié, à une signature périmée et à l’absence de signature.

Livraison

Dans le bac à sablelivraison

Une livraison qui échoue est retentée au bout de 1 minute, puis de 5 minutes, puis de 30 minutes, puis toutes les heures jusqu’à 24 heures après le premier échec. Ensuite, elle passe en lettre morte. L’événement stocké n’est jamais modifié. Chaque tentative est signée à nouveau avec l’heure courante : une nouvelle tentative n’est donc pas refusée comme trop ancienne.

Enregistrement

Dans le bac à sableenregistrement

La clé d’un client dotée de la portée admin enregistre l’adresse propre au client. L’enregistrement se fait en trois appels :

AppelCe qu’il fait
PUT /webhookDéfinit l’url et crée le secret, qui n’est renvoyé qu’une fois. Envoyez rotate_secret: true pour créer un nouveau secret ; pendant 24 heures, chaque livraison porte une signature pour le nouveau secret et une pour l’ancien, ce qui vous permet de basculer sans perdre d’événements. contact_email indique qui prévenir quand des livraisons meurent.
GET /webhookAffiche les réglages : url, active, la date de la dernière rotation du secret, ainsi que l’heure et le statut HTTP de la dernière livraison. Le secret n’est jamais affiché à nouveau.
POST /webhook/testEnvoie un événement de test signé à l’adresse. Il ne concerne aucune facture.

L’adresse doit être en https, et chaque adresse que son hôte résout doit être publique. Les adresses de bouclage, privées, de lien local, de multidiffusion et de métadonnées cloud sont refusées, à l’enregistrement puis avant chaque livraison, et les redirections ne sont pas suivies. Seuls les événements écrits après le premier PUT sont livrés.

Conseil

Lisez GET /invoices/{id}/events pour vous remettre à jour après une interruption (voir lire une facture). Il renvoie chaque événement de la facture : une livraison manquée ne fait donc rien perdre.

Pas encore proposé

  • Des en-têtes personnalisés.
  • Le TLS mutuel.
  • Un journal des livraisons.
  • Une adresse source fixe.

Indiquez-nous celui dont votre pilote a besoin.

Sur cette page