Webhooki
Rejestracja adresu, podpisywanie i dostarczanie.
- W sandboxiepodpisywanie
- W sandboxiedostarczanie
- W sandboxierejestracja
Prostymi słowami
Webhook pozwala eurinvoice wysyłać każdą zmianę statusu na adres internetowy, który obsługuje klient, więc system klienta nie musi o nią pytać. Adres rejestruje się przez API, każde dostarczenie jest podpisane, a nieudane dostarczenia są ponawiane. Stronę odbierającą budują programiści klienta.
Webhook wysyła każde zdarzenie statusu na adres URL, który Państwo obsługują. Treść to zdarzenie statusu bez zmian (zob. statusy). Adres URL rejestruje się przez API (zob. niżej), każde dostarczenie jest podpisane, a nieudane dostarczenie jest ponawiane.
Podpis
W sandboxiepodpisywanieKażda dostarczana wiadomość zawiera te nagłówki.
| Nagłówek | Wartość |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | event_id zdarzenia. |
X-Eurinvoice-Delivery-Attempt | 1 dla pierwszego dostarczenia, 2 dla pierwszego ponowienia i tak dalej. |
Content-Type | application/json |
v1 to HMAC-SHA256 zapisany jako 64 małe znaki szesnastkowe. Kluczem jest sekret webhooka jako ciąg UTF-8. Wiadomość to t, kropka i surowa treść. Należy odrzucić podpis, którego t różni się od wskazania Państwa zegara o więcej niż 300 sekund w dowolną stronę, a porównanie wykonywać w stałym czasie.
Po rotacji sekretu nagłówek przez 24 godziny zawiera dwie wartości v1: jedną podpisaną nowym sekretem, a potem jedną starym. Dostarczenie należy przyjąć, jeśli którakolwiek wartość v1 zgadza się z Państwa sekretem. Jeśli odbiorca odczyta tylko pierwszą, a przeszedł już na nowy sekret, przez ten dzień odrzuci każde dostarczenie.
Ostrzeżenie
Treść należy sprawdzać dokładnie w takiej postaci, w jakiej dotarła. Kopia sparsowana i ponownie zserializowana nie będzie zgodna.
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
}Przykład to sekret sample-secret, treść {"event_id":"evt_12345678","sequence":1} i nagłówek t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Sprawdzony w chwili 1790000100 jest prawidłowy. W chwili 1790000400 jest za stary, a z "sequence":2 treść przestaje się zgadzać.
Oba fragmenty kodu dają ten sam wynik co własna kontrola usługi w 28 przypadkach. Obejmują one nagłówek sprzed 301 sekund, zmienioną treść, błędny sekret, brakującą część, treść z tekstem spoza ASCII i nagłówek z dwoma podpisami po rotacji.
Zadania odbiorcy
- Udostępnić adres URL przez HTTPS. Dostarczanie odbywa się tylko na adresy publiczne.
- Sprawdzić podpis i jego wiek przed odczytaniem treści.
- Odpowiedzieć kodem
2xxw ciągu 10 sekund. Każda inna odpowiedź lub brak odpowiedzi w tym czasie to niepowodzenie, po którym dostarczenie jest ponawiane. - Zapisywać
event_idkażdego obsłużonego zdarzenia i pomijać zdarzenia już widziane. Ponowienie może dostarczyć to samo zdarzenie jeszcze raz.
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);Dostarczanie
W sandboxiedostarczanieDostarczenie, które się nie powiedzie, jest ponawiane po 1 minucie, potem po 5 minutach, potem po 30 minutach, a następnie co godzinę, aż do 24 godzin od pierwszego niepowodzenia. Potem trafia na listę dead letter. Zapisane zdarzenie nigdy się nie zmienia. Każda próba jest podpisywana ponownie z bieżącym czasem, więc ponowienie nie zostaje odrzucone jako za stare.
Rejestracja
W sandboxierejestracjaKlucz klienta z zakresem admin rejestruje własny adres klienta. Rejestracja to trzy wywołania:
| Wywołanie | Co robi |
|---|---|
PUT /webhook | Ustawia url i tworzy sekret, który jest zwracany tylko raz. Wysłanie rotate_secret: true tworzy nowy sekret; przez 24 godziny każde dostarczenie zawiera podpis dla nowego sekretu i dla starego, więc można przełączyć się bez utraty zdarzeń. contact_email wskazuje, kogo powiadomić, gdy dostarczenia przestają się udawać. |
GET /webhook | Pokazuje ustawienia: url, active, kiedy sekret był ostatnio rotowany oraz czas i status HTTP ostatniego dostarczenia. Sekret nigdy nie jest pokazywany ponownie. |
POST /webhook/test | Wysyła na adres podpisane zdarzenie testowe. Nie dotyczy żadnej faktury. |
Adres musi być https, a każdy adres, na który wskazuje jego host, musi być publiczny. Adresy pętli zwrotnej, prywatne, link-local, multicast i metadanych chmury są odrzucane, przy ustawianiu i ponownie przed każdym dostarczeniem, a przekierowania nie są śledzone. Dostarczane są tylko zdarzenia zapisane po pierwszym PUT.
Wskazówka
Aby nadrobić zaległości po przerwie w działaniu, należy odczytać GET /invoices/{id}/events (zob. odczyt faktury). Zwraca każde zdarzenie faktury, więc w razie nieodebranego dostarczenia nic nie ginie.
Jeszcze niedostępne
- Własne nagłówki.
- Wzajemne uwierzytelnianie TLS.
- Dziennik dostarczeń.
- Stały adres źródłowy.
Prosimy dać nam znać, której z tych funkcji potrzebuje Państwa pilotaż.