Webhooks
Eine Adresse registrieren, Signierung und Zustellung.
- In der SandboxSignierung
- In der SandboxZustellung
- In der SandboxRegistrierung
In einfachen Worten
Mit einem Webhook schickt eurinvoice jede Statusänderung an eine Webadresse, die der Kunde betreibt, sodass das System des Kunden nicht nachfragen muss. Sie registrieren die Adresse über die API, jede Zustellung ist signiert, und fehlgeschlagene Zustellungen werden wiederholt. Die Empfangsseite bauen die Entwickler des Kunden.
Ein Webhook schickt jedes Statusereignis an eine URL, die Sie betreiben. Der Body ist das Statusereignis, unverändert (siehe Status). Sie registrieren die URL über die API (siehe unten), jede Zustellung ist signiert, und eine fehlgeschlagene Zustellung wird wiederholt.
Signatur
In der SandboxSignierungJede Zustellung trägt diese Header.
| Header | Wert |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | Die event_id des Ereignisses. |
X-Eurinvoice-Delivery-Attempt | 1 für die erste Zustellung, 2 für die erste Wiederholung und so weiter. |
Content-Type | application/json |
v1 ist ein HMAC-SHA256, geschrieben als 64 hexadezimale Zeichen in Kleinbuchstaben. Der Schlüssel ist das Webhook-Secret, als UTF-8-String. Die Nachricht ist t, ein Punkt und der Roh-Body. Weisen Sie eine Signatur ab, deren t in die eine oder andere Richtung um mehr als 300 Sekunden von Ihrer Uhrzeit abweicht, und vergleichen Sie in konstanter Zeit.
Nach einer Rotation des Secrets trägt der Header 24 Stunden lang zwei v1-Werte: einen mit dem neuen Secret signierten, dann einen mit dem alten. Akzeptieren Sie die Zustellung, wenn irgendein v1 zu Ihrem Secret passt. Wer nur den ersten liest, dessen Empfänger bereits auf das neue Secret umgestellt hat, weist an diesem Tag jede Zustellung ab.
Warnung
Prüfen Sie den Body genau so, wie er angekommen ist. Eine geparste und neu serialisierte Kopie stimmt nicht überein.
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
}Das Beispiel besteht aus dem Secret sample-secret, dem Body {"event_id":"evt_12345678","sequence":1} und dem Header t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Zum Zeitpunkt 1790000100 geprüft, ist er gültig. Bei 1790000400 ist er zu alt, und mit "sequence":2 stimmt der Body nicht mehr überein.
Beide Snippets stimmen in 28 Fällen mit der eigenen Prüfung des Service überein. Darunter sind ein 301 Sekunden alter Header, ein geänderter Body, ein falsches Secret, ein fehlender Teil, ein Body mit Nicht-ASCII-Text und der Header mit zwei Signaturen nach einer Rotation.
Was ein Empfänger tut
- Stellen Sie die URL über HTTPS bereit. Zugestellt wird nur an öffentliche Adressen.
- Prüfen Sie die Signatur und ihr Alter, bevor Sie den Body lesen.
- Antworten Sie innerhalb von 10 Sekunden mit einem
2xx. Alles andere oder keine Antwort in dieser Zeit gilt als Fehlschlag, und die Zustellung wird wiederholt. - Speichern Sie die
event_idjedes Ereignisses, das Sie verarbeiten, und ignorieren Sie eines, das Sie schon gesehen haben. Eine Wiederholung kann dasselbe Ereignis erneut zustellen.
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);Zustellung
In der SandboxZustellungEine fehlgeschlagene Zustellung wird nach 1 Minute erneut versucht, dann nach 5 Minuten, dann nach 30 Minuten, dann stündlich bis 24 Stunden nach dem ersten Fehlschlag. Danach kommt sie auf die Dead-Letter-Liste. Das gespeicherte Ereignis wird nie verändert. Jeder Versuch wird mit der aktuellen Zeit neu signiert, damit eine Wiederholung nicht als zu alt abgewiesen wird.
Registrierung
In der SandboxRegistrierungDer Schlüssel eines Kunden mit dem Scope admin registriert die eigene Adresse des Kunden. Die Registrierung besteht aus drei Aufrufen:
| Aufruf | Was er tut |
|---|---|
PUT /webhook | Setzt die url und erstellt das Secret, das einmal zurückgegeben wird. Senden Sie rotate_secret: true, um ein neues Secret zu erstellen; 24 Stunden lang trägt jede Zustellung eine Signatur für das neue Secret und eine für das alte, sodass Sie umstellen können, ohne Ereignisse zu verlieren. contact_email sagt, wen wir informieren, wenn Zustellungen endgültig fehlschlagen. |
GET /webhook | Zeigt die Einstellungen: url, active, wann das Secret zuletzt rotiert wurde sowie Zeitpunkt und HTTP-Status der letzten Zustellung. Das Secret wird nie wieder angezeigt. |
POST /webhook/test | Sendet ein signiertes Testereignis an die Adresse. Es meldet keine Rechnung. |
Die Adresse muss https sein, und jede Adresse, auf die ihr Host auflöst, muss öffentlich sein. Loopback-, private, Link-Local-, Multicast- und Cloud-Metadaten-Adressen werden abgewiesen, wenn Sie sie setzen und vor jeder Zustellung erneut, und Weiterleitungen werden nicht verfolgt. Zugestellt werden nur Ereignisse, die nach dem ersten PUT geschrieben wurden.
Tipp
Lesen Sie GET /invoices/{id}/events, um nach einer Ausfallzeit aufzuholen (siehe Eine Rechnung lesen). Der Aufruf liefert jedes Ereignis der Rechnung, durch eine verpasste Zustellung geht also nichts verloren.
Noch nicht angeboten
- Eigene Header.
- Mutual TLS.
- Ein Zustellprotokoll.
- Eine feste Quelladresse.
Sagen Sie uns, welches davon Ihr Pilot braucht.