Webhooks
Een adres registreren, ondertekening en aflevering.
- In de sandboxondertekening
- In de sandboxaflevering
- In de sandboxregistratie
In gewone woorden
Met een webhook stuurt eurinvoice elke statuswijziging naar een webadres dat de eindklant beheert, zodat het systeem van de eindklant er niet om hoeft te vragen. U registreert het adres via de API, elke aflevering is ondertekend, en mislukte afleveringen worden opnieuw geprobeerd. De ontwikkelaars van de eindklant bouwen de ontvangende kant.
Een webhook stuurt elk statusbericht naar een URL die u beheert. De body is het statusbericht, ongewijzigd (zie statussen). U registreert de URL via de API (zie hieronder), elke aflevering is ondertekend, en een mislukte aflevering wordt opnieuw geprobeerd.
Handtekening
In de sandboxondertekeningElke aflevering bevat deze headers.
| Header | Waarde |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | De event_id van het statusbericht. |
X-Eurinvoice-Delivery-Attempt | 1 voor de eerste aflevering, 2 voor de eerste herhaalpoging, enzovoort. |
Content-Type | application/json |
v1 is een HMAC-SHA256, geschreven als 64 hexadecimale tekens in kleine letters. De sleutel is het webhookgeheim, als UTF-8-string. Het bericht is t, een punt en de onbewerkte body. Weiger een handtekening waarvan t meer dan 300 seconden van uw klok afwijkt, in beide richtingen, en vergelijk in constante tijd.
Na het roteren van een geheim bevat de header 24 uur lang twee waarden voor v1: eerst een ondertekend met het nieuwe geheim, daarna een met het oude. Accepteer de aflevering als een van de v1-waarden bij uw geheim past. Leest u alleen de eerste, dan weigert een ontvanger die al op het nieuwe geheim is overgestapt die dag elke aflevering.
Waarschuwing
Controleer de body precies zoals die binnenkwam. Een geparste en opnieuw geserialiseerde kopie komt niet overeen.
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
}
}import { createHmac, timingSafeEqual } from 'node:crypto';
import { fileURLToPath } from 'node:url';
// 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, as bytes. A parsed and re-serialised copy will not match.
export function verify(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = String(header ?? '').split(',').map((part) => part.trim().split('='));
const seconds = Number(parts.find(([name]) => name === 't')?.[1]);
const signatures = parts.filter(([name]) => name === 'v1').map(([, value]) => value);
if (!Number.isInteger(seconds) || Math.abs(nowSeconds - seconds) > 300) return false;
const expected = createHmac('sha256', secret).update(`${seconds}.`).update(rawBody).digest();
let ok = false;
for (const signature of signatures) {
if (/^[0-9a-f]{64}$/.test(signature ?? '') && timingSafeEqual(Buffer.from(signature, 'hex'), expected)) ok = true;
}
return ok;
}
// Check it against the sample on this page: node verify.mjs
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const secret = 'sample-secret';
const body = Buffer.from('{"event_id":"evt_12345678","sequence":1}');
const header = 't=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e';
console.log(verify(secret, header, body, 1790000100)); // true
console.log(verify(secret, header, body, 1790000400)); // false: older than five minutes
console.log(verify(secret, header, Buffer.from('{"event_id":"evt_12345678","sequence":2}'), 1790000100)); // false: body changed
}Het voorbeeld bestaat uit het geheim sample-secret, de body {"event_id":"evt_12345678","sequence":1} en de header t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Gecontroleerd op 1790000100 is het geldig. Op 1790000400 is het te oud, en met "sequence":2 komt de body niet meer overeen.
Beide fragmenten komen in 28 gevallen overeen met de eigen controle van de dienst. Daaronder zitten een header van 301 seconden oud, een gewijzigde body, een verkeerd geheim, een ontbrekend deel, een body met niet-ASCII-tekst en de header met twee handtekeningen na een rotatie.
Wat een ontvanger doet
- Bied de URL aan via HTTPS. Aflevering gaat alleen naar openbare adressen.
- Controleer de handtekening en de leeftijd ervan voordat u de body leest.
- Antwoord binnen 10 seconden met een
2xx. Elk ander antwoord, of geen tijdig antwoord, geldt als mislukt en wordt opnieuw geprobeerd. - Bewaar de
event_idvan elk statusbericht dat u verwerkt, en negeer een bericht dat u al hebt gezien. Een herhaalpoging kan hetzelfde bericht opnieuw afleveren.
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);Aflevering
In de sandboxafleveringEen mislukte aflevering wordt opnieuw geprobeerd na 1 minuut, daarna na 5 minuten, daarna na 30 minuten en daarna elk uur, tot 24 uur na de eerste mislukking. Daarna gaat ze naar de dead-letterlijst. Het opgeslagen statusbericht wordt nooit gewijzigd. Elke poging wordt opnieuw ondertekend met de huidige tijd, zodat een herhaalpoging niet als te oud wordt geweigerd.
Registratie
In de sandboxregistratieDe sleutel van een eindklant met de scope admin registreert het eigen adres van de eindklant. Registreren bestaat uit drie aanroepen:
| Aanroep | Wat hij doet |
|---|---|
PUT /webhook | Stelt de url in en maakt het geheim aan, dat één keer wordt teruggegeven. Stuur rotate_secret: true om een nieuw geheim te maken; 24 uur lang bevat elke aflevering een handtekening voor het nieuwe geheim en een voor het oude, zodat u kunt overstappen zonder statusberichten te verliezen. contact_email zegt wie moet worden ingelicht als afleveringen definitief mislukken. |
GET /webhook | Toont de instellingen: url, active, wanneer het geheim het laatst is geroteerd en het tijdstip en de HTTP-status van de laatste aflevering. Het geheim wordt nooit opnieuw getoond. |
POST /webhook/test | Stuurt een ondertekend testbericht naar het adres. Het meldt geen factuur. |
Het adres moet https zijn, en elk adres waarnaar de host verwijst, moet openbaar zijn. Loopback-, privé-, link-local-, multicast- en cloudmetadata-adressen worden geweigerd, zowel wanneer u het instelt als vóór elke aflevering, en redirects worden niet gevolgd. Alleen statusberichten die na de eerste PUT zijn geschreven, worden afgeleverd.
Tip
Lees GET /invoices/{id}/events om na een onderbreking weer bij te zijn (zie een factuur lezen). Die aanroep geeft elk statusbericht voor de factuur terug, zodat er bij een gemiste aflevering niets verloren gaat.
Nog niet aangeboden
- Eigen headers.
- Mutual TLS.
- Een afleveringslogboek.
- Een vast bronadres.
Laat ons weten welke uw pilot nodig heeft.