Webhookovi
Registracija adrese, potpisivanje i isporuka.
- U sandboxupotpisivanje
- U sandboxuisporuka
- U sandboxuregistracija
Jednostavnim riječima
Webhook omogućuje usluzi eurinvoice da svaku promjenu statusa pošalje na web-adresu kojom upravlja klijent, pa klijentov sustav ne mora sam slati upite. Adresu registrirate putem API-ja, svaka je isporuka potpisana, a neuspjele isporuke ponavljaju se. Stranu koja prima poruke izrađuju klijentovi programeri.
Webhook šalje svaki statusni događaj na URL kojim upravljate. Tijelo je nepromijenjeni statusni događaj (pogledajte statuse). URL registrirate putem API-ja (pogledajte u nastavku), svaka je isporuka potpisana, a neuspjela isporuka ponavlja se.
Potpis
U sandboxupotpisivanjeSvaka isporuka nosi ova zaglavlja.
| Zaglavlje | Vrijednost |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | event_id događaja. |
X-Eurinvoice-Delivery-Attempt | 1 za prvu isporuku, 2 za prvi ponovni pokušaj i tako dalje. |
Content-Type | application/json |
v1 je HMAC-SHA256, zapisan kao 64 heksadecimalna znaka malim slovima. Ključ je tajna webhooka, kao UTF-8 niz znakova. Poruka je t, točka i neobrađeno tijelo. Odbijte potpis čiji t odstupa od vašeg sata više od 300 sekundi, u bilo kojem smjeru, i uspoređujte u konstantnom vremenu.
Nakon rotacije tajne zaglavlje 24 sata nosi dvije vrijednosti v1: jednu potpisanu novom tajnom, a zatim jednu starom. Prihvatite isporuku ako se bilo koja vrijednost v1 podudara s vašom tajnom. Ako čitate samo prvu, primatelj koji je već prešao na novu tajnu odbijat će svaku isporuku tog dana.
Upozorenje
Provjeravajte tijelo točno onakvo kakvo je stiglo. Parsirana i ponovno serijalizirana kopija neće se podudarati.
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
}Primjer čine tajna sample-secret, tijelo {"event_id":"evt_12345678","sequence":1} i zaglavlje t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Provjereno u trenutku 1790000100, valjano je. U trenutku 1790000400 prestaro je, a sa "sequence":2 tijelo se više ne podudara.
Oba isječka slažu se s vlastitom provjerom usluge u 28 slučajeva. Među njima su zaglavlje staro 301 sekundu, izmijenjeno tijelo, pogrešna tajna, dio koji nedostaje, tijelo s tekstom koji nije ASCII i zaglavlje s dva potpisa nakon rotacije.
Što radi primatelj
- Poslužujte URL preko HTTPS-a. Isporuka ide samo na javne adrese.
- Provjerite potpis i njegovu starost prije čitanja tijela.
- Odgovorite s
2xxu roku od 10 sekundi. Sve ostalo, kao i izostanak odgovora na vrijeme, smatra se neuspjehom i ponovno se pokušava. - Spremite
event_idsvakog događaja koji obradite i zanemarite onaj koji ste već vidjeli. Ponovni pokušaj može ponovno isporučiti isti događaj.
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);Isporuka
U sandboxuisporukaIsporuka koja ne uspije ponovno se pokušava nakon 1 minute, zatim nakon 5 minuta, zatim nakon 30 minuta, a zatim svaki sat do 24 sata nakon prvog neuspjeha. Nakon toga premješta se među neisporučive (dead letter). Spremljeni događaj nikad se ne mijenja. Svaki se pokušaj ponovno potpisuje s trenutačnim vremenom, pa se ponovni pokušaj ne odbija kao prestar.
Registracija
U sandboxuregistracijaKljuč klijenta s opsegom admin registrira klijentovu vlastitu adresu. Registracija su tri poziva:
| Poziv | Što radi |
|---|---|
PUT /webhook | Postavlja url i izrađuje tajnu, koja se vraća jednom. Pošaljite rotate_secret: true za novu tajnu; 24 sata svaka isporuka nosi potpis za novu tajnu i jedan za staru, pa možete prijeći bez gubitka događaja. contact_email kaže koga obavijestiti kad isporuke odumru. |
GET /webhook | Prikazuje postavke: url, active, kada je tajna zadnji put rotirana te vrijeme i HTTP status zadnje isporuke. Tajna se nikad više ne prikazuje. |
POST /webhook/test | Šalje potpisani testni događaj na adresu. Ne prijavljuje nijedan račun. |
Adresa mora biti https, a svaka adresa na koju se njezin host razrješava mora biti javna. Adrese loopback, privatne, link-local, multicast i adrese metapodataka oblaka odbijaju se, i pri postavljanju i ponovno prije svake isporuke, a preusmjeravanja se ne slijede. Isporučuju se samo događaji zapisani nakon prvog PUT.
Savjet
Čitajte GET /invoices/{id}/events da nadoknadite propušteno nakon prekida rada (pogledajte čitanje računa). Vraća svaki događaj za račun, pa propuštena isporuka ne znači gubitak.
Još se ne nudi
- Prilagođena zaglavlja.
- Uzajamni TLS.
- Zapisnik isporuka.
- Fiksna izvorišna adresa.
Recite nam što od toga treba vašem pilotu.