Webhooky
Registrácia adresy, podpisovanie a doručovanie.
- V sandboxepodpisovanie
- V sandboxedoručovanie
- V sandboxeregistrácia
Jednoducho povedané
Webhook umožňuje eurinvoice posielať každú zmenu stavu na webovú adresu, ktorú prevádzkuje klient, takže systém klienta sa nemusí pýtať. Adresu zaregistrujete cez API, každé doručenie je podpísané a neúspešné doručenia sa opakujú. Prijímaciu stranu si budujú vývojári klienta.
Webhook posiela každú stavovú udalosť na URL, ktorú prevádzkujete. Telo je stavová udalosť bez zmeny (pozri stavy). URL zaregistrujete cez API (pozri nižšie), každé doručenie je podpísané a neúspešné doručenie sa opakuje.
Podpis
V sandboxepodpisovanieKaždé doručenie obsahuje tieto hlavičky.
| Hlavička | Hodnota |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | event_id udalosti. |
X-Eurinvoice-Delivery-Attempt | 1 pri prvom doručení, 2 pri prvom opakovanom pokuse a tak ďalej. |
Content-Type | application/json |
v1 je HMAC-SHA256 zapísaný ako 64 hexadecimálnych znakov malými písmenami. Kľúčom je tajný kľúč webhooku ako reťazec UTF-8. Správa je t, bodka a nespracované telo. Odmietnite podpis, ktorého t sa od vašich hodín líši o viac ako 300 sekúnd v ktoromkoľvek smere, a porovnávajte v konštantnom čase.
Po rotácii tajného kľúča obsahuje hlavička 24 hodín dve hodnoty v1: jednu podpísanú novým tajným kľúčom, potom jednu starým. Doručenie prijmite, ak sa niektorá hodnota v1 zhoduje s vaším tajným kľúčom. Ak prečítate iba prvú, príjemca, ktorý už prešiel na nový tajný kľúč, odmietne v ten deň každé doručenie.
Varovanie
Kontrolujte telo presne tak, ako prišlo. Kópia po parsovaní a opätovnej serializácii sa nebude zhodovať.
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
}Vzorku tvorí tajný kľúč sample-secret, telo {"event_id":"evt_12345678","sequence":1} a hlavička t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Pri kontrole v čase 1790000100 je platná. V čase 1790000400 je príliš stará a s "sequence":2 sa telo už nezhoduje.
Obe ukážky kódu sa zhodujú s vlastnou kontrolou služby v 28 prípadoch. Patria medzi ne hlavička stará 301 sekúnd, zmenené telo, nesprávny tajný kľúč, chýbajúca časť, telo s textom mimo ASCII a hlavička s dvoma podpismi po rotácii.
Čo robí príjemca
- Poskytujte URL cez HTTPS. Doručovanie ide len na verejné adresy.
- Pred čítaním tela overte podpis a jeho vek.
- Odpovedzte
2xxdo 10 sekúnd. Čokoľvek iné alebo žiadna odpoveď včas je zlyhanie a pokus sa zopakuje. - Uchovávajte
event_idkaždej spracovanej udalosti a udalosť, ktorú ste už videli, ignorujte. Opakovaný pokus môže doručiť tú istú udalosť znova.
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);Doručovanie
V sandboxedoručovanieNeúspešné doručenie sa zopakuje po 1 minúte, potom po 5 minútach, potom po 30 minútach a potom každú hodinu až do 24 hodín od prvého zlyhania. Potom sa presunie do dead-letter. Uložená udalosť sa nikdy nemení. Každý pokus sa znova podpíše s aktuálnym časom, takže opakovaný pokus sa neodmietne ako príliš starý.
Registrácia
V sandboxeregistráciaKľúč klienta s oprávnením admin zaregistruje adresu vlastného klienta. Registrácia sú tri volania:
| Volanie | Čo robí |
|---|---|
PUT /webhook | Nastaví url a vytvorí tajný kľúč, ktorý sa vráti raz. Pošlite rotate_secret: true, aby ste vytvorili nový tajný kľúč; 24 hodín obsahuje každé doručenie podpis pre nový tajný kľúč a jeden pre starý, takže môžete prejsť bez straty udalostí. contact_email určuje, koho informovať, keď doručenia zlyhávajú natrvalo. |
GET /webhook | Zobrazí nastavenia: url, active, kedy sa tajný kľúč naposledy rotoval a čas a stav HTTP posledného doručenia. Tajný kľúč sa už nikdy nezobrazí. |
POST /webhook/test | Pošle na adresu podpísanú testovaciu udalosť. Nehlási žiadnu faktúru. |
Adresa musí byť https a každá adresa, na ktorú sa jej hostiteľ preloží, musí byť verejná. Adresy loopback, súkromné, link-local, multicast a metadátové adresy cloudu sa odmietnu, pri nastavení aj znova pred každým doručením, a presmerovania sa nesledujú. Doručujú sa len udalosti zapísané po prvom PUT.
Tip
Na dobehnutie zmien po výpadku čítajte GET /invoices/{id}/events (pozri čítanie faktúry). Vráti všetky udalosti faktúry, takže zmeškané doručenie nič nestratí.
Zatiaľ sa neponúka
- Vlastné hlavičky.
- Vzájomné TLS.
- Log doručení.
- Pevná zdrojová adresa.
Napíšte nám, ktorú z týchto možností potrebuje váš pilotný projekt.