Webhook-uri
Înregistrarea unei adrese, semnare și livrare.
- În sandboxsemnarea
- În sandboxlivrarea
- În sandboxînregistrarea
În cuvinte simple
Un webhook îi permite lui eurinvoice să trimită fiecare schimbare de stare la o adresă web administrată de client, astfel încât sistemul clientului să nu fie nevoit să întrebe. Înregistrați adresa prin API, fiecare livrare este semnată, iar livrările eșuate sunt reîncercate. Dezvoltatorii clientului construiesc partea care primește mesajele.
Un webhook trimite fiecare eveniment de stare la un URL administrat de dumneavoastră. Corpul este evenimentul de stare, nemodificat (consultați stările). Înregistrați URL-ul prin API (vedeți mai jos), fiecare livrare este semnată, iar o livrare eșuată este reîncercată.
Semnătura
În sandboxsemnareaFiecare livrare poartă aceste antete.
| Antet | Valoare |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | Valoarea event_id a evenimentului. |
X-Eurinvoice-Delivery-Attempt | 1 pentru prima livrare, 2 pentru prima reîncercare și așa mai departe. |
Content-Type | application/json |
v1 este un HMAC-SHA256, scris ca 64 de caractere hexazecimale cu litere mici. Cheia este secretul webhook-ului, ca șir UTF-8. Mesajul este t, un punct și corpul brut. Refuzați o semnătură al cărei t diferă cu mai mult de 300 de secunde de ceasul dumneavoastră, în oricare direcție, și comparați în timp constant.
După rotirea unui secret, antetul conține două valori v1 timp de 24 de ore: una semnată cu noul secret, apoi una cu cel vechi. Acceptați livrarea dacă oricare v1 se potrivește cu secretul dumneavoastră. Dacă citiți doar prima valoare, un receptor care a trecut deja la noul secret refuză toate livrările din acea zi.
Atenție
Verificați corpul exact așa cum a sosit. O copie parsată și serializată din nou nu se va potrivi.
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
}Exemplul folosește secretul sample-secret, corpul {"event_id":"evt_12345678","sequence":1} și antetul t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Verificat la 1790000100, este valid. La 1790000400 este prea vechi, iar cu "sequence":2 corpul nu se mai potrivește.
Ambele fragmente de cod dau același rezultat ca verificarea proprie a serviciului în 28 de cazuri. Printre ele: un antet vechi de 301 secunde, un corp modificat, un secret greșit, o parte lipsă, un corp cu text non-ASCII și antetul cu două semnături de la o rotire.
Ce face receptorul
- Serviți URL-ul prin HTTPS. Livrarea se face doar către adrese publice.
- Verificați semnătura și vechimea ei înainte de a citi corpul.
- Răspundeți cu un
2xxîn cel mult 10 secunde. Orice alt răspuns sau lipsa unui răspuns la timp este un eșec și se reîncearcă. - Păstrați valoarea
event_ida fiecărui eveniment pe care îl procesați și ignorați un eveniment deja văzut. O reîncercare poate livra din nou același eveniment.
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);Livrarea
În sandboxlivrareaO livrare eșuată este încercată din nou după 1 minut, apoi după 5 minute, apoi după 30 de minute, apoi în fiecare oră, până la 24 de ore după primul eșec. După aceea, livrarea trece în dead-letter. Evenimentul stocat nu se schimbă niciodată. Fiecare încercare este semnată din nou cu ora curentă, așa că o reîncercare nu este refuzată ca prea veche.
Înregistrarea
În sandboxînregistrareaCheia unui client cu domeniul admin înregistrează adresa propriului client. Înregistrarea înseamnă trei apeluri:
| Apel | Ce face |
|---|---|
PUT /webhook | Setează url și creează secretul, care este returnat o singură dată. Trimiteți rotate_secret: true pentru a crea un secret nou; timp de 24 de ore, fiecare livrare poartă o semnătură pentru noul secret și una pentru cel vechi, astfel încât puteți trece la cel nou fără să pierdeți evenimente. contact_email spune pe cine anunțăm când livrările eșuează definitiv. |
GET /webhook | Arată setările: url, active, când a fost rotit ultima dată secretul, precum și ora și starea HTTP ale ultimei livrări. Secretul nu mai este afișat niciodată. |
POST /webhook/test | Trimite la adresă un eveniment de test semnat. Nu raportează nicio factură. |
Adresa trebuie să fie https, iar fiecare adresă la care se rezolvă gazda ei trebuie să fie publică. Adresele de loopback, private, link-local, multicast și cele de metadate cloud sunt refuzate, atât când o setați, cât și înainte de fiecare livrare, iar redirecționările nu sunt urmate. Sunt livrate doar evenimentele scrise după primul PUT.
Sfat
Citiți GET /invoices/{id}/events pentru a recupera evenimentele după o întrerupere (consultați citirea unei facturi). Apelul returnează toate evenimentele facturii, așa că, dacă o livrare este ratată, nu se pierde nimic.
Ce nu oferim încă
- Antete personalizate.
- TLS mutual.
- Un jurnal al livrărilor.
- O adresă sursă fixă.
Spuneți-ne de care dintre ele are nevoie proiectul dumneavoastră pilot.