Webhook
Registrazione di un indirizzo, firma e consegna.
- Nella sandboxfirma
- Nella sandboxconsegna
- Nella sandboxregistrazione
In parole semplici
Un webhook permette a eurinvoice di inviare ogni cambio di stato a un indirizzo web gestito dal cliente, così il sistema del cliente non deve interrogare il servizio. L’indirizzo si registra tramite l’API, ogni consegna è firmata e le consegne non riuscite vengono ritentate. Gli sviluppatori del cliente realizzano la parte ricevente.
Un webhook invia ogni evento di stato a un URL gestito in proprio. Il corpo è l’evento di stato, invariato (vedere stati). L’URL si registra tramite l’API (vedere sotto), ogni consegna è firmata e una consegna non riuscita viene ritentata.
Firma
Nella sandboxfirmaOgni consegna riporta queste intestazioni.
| Intestazione | Valore |
|---|---|
X-Eurinvoice-Signature | t=<unix seconds>,v1=<signature> |
X-Eurinvoice-Event-Id | L’event_id dell’evento. |
X-Eurinvoice-Delivery-Attempt | 1 per la prima consegna, 2 per il primo nuovo tentativo, e così via. |
Content-Type | application/json |
v1 è un HMAC-SHA256, scritto come 64 caratteri esadecimali minuscoli. La chiave è il segreto del webhook, come stringa UTF-8. Il messaggio è t, un punto e il corpo grezzo. Rifiutare una firma il cui t dista più di 300 secondi dal proprio orologio, in entrambe le direzioni, e confrontare in tempo costante.
Dopo una rotazione del segreto l’intestazione riporta due valori v1 per 24 ore: uno firmato con il nuovo segreto, poi uno con il vecchio. Accettare la consegna se un qualsiasi v1 corrisponde al proprio segreto. Chi legge solo il primo, con un ricevitore che è già passato al nuovo segreto, rifiuta ogni consegna di quel giorno.
Avvertenza
Controllare il corpo esattamente come è arrivato. Una copia analizzata e serializzata di nuovo non corrisponderà.
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
}L’esempio usa il segreto sample-secret, il corpo {"event_id":"evt_12345678","sequence":1} e l’intestazione t=1790000000,v1=bd9a6a2d85d7e3706ff8209156fdc43b8291094a4c7af458ed31e7ae9174449e. Controllata al tempo 1790000100 è valida. Al tempo 1790000400 è troppo vecchia, e con "sequence":2 il corpo non corrisponde più.
Entrambi gli snippet concordano con il controllo del servizio stesso su 28 casi. Tra questi ci sono un’intestazione vecchia di 301 secondi, un corpo modificato, un segreto errato, una parte mancante, un corpo con testo non ASCII e l’intestazione con due firme di una rotazione.
Cosa fa un ricevitore
- Esporre l’URL su HTTPS. La consegna avviene solo verso indirizzi pubblici.
- Verificare la firma e la sua età prima di leggere il corpo.
- Rispondere con un
2xxentro 10 secondi. Qualsiasi altra risposta, o l’assenza di risposta in tempo, conta come errore e la consegna viene ritentata. - Conservare l’
event_iddi ogni evento gestito, e ignorare un evento già visto. Un nuovo tentativo può consegnare di nuovo lo stesso evento.
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);Consegna
Nella sandboxconsegnaUna consegna non riuscita viene ritentata dopo 1 minuto, poi dopo 5 minuti, poi dopo 30 minuti, poi ogni ora fino a 24 ore dopo il primo errore. Dopodiché passa in dead letter. L’evento archiviato non viene mai modificato. Ogni tentativo viene firmato di nuovo con l’ora corrente, quindi un nuovo tentativo non viene rifiutato come troppo vecchio.
Registrazione
Nella sandboxregistrazioneLa chiave di un cliente con lo scope admin registra l’indirizzo del proprio cliente. La registrazione si fa con tre chiamate:
| Chiamata | Cosa fa |
|---|---|
PUT /webhook | Imposta l’url e crea il segreto, che viene restituito una sola volta. Inviare rotate_secret: true per creare un nuovo segreto; per 24 ore ogni consegna riporta una firma per il nuovo segreto e una per il vecchio, così si può passare all’altro senza perdere eventi. contact_email indica chi avvisare quando le consegne si interrompono. |
GET /webhook | Mostra le impostazioni: url, active, quando il segreto è stato ruotato l’ultima volta, e l’orario e lo stato HTTP dell’ultima consegna. Il segreto non viene mai mostrato di nuovo. |
POST /webhook/test | Invia all’indirizzo un evento di test firmato. Non riguarda alcuna fattura. |
L’indirizzo deve essere https, e ogni indirizzo a cui il suo host si risolve deve essere pubblico. Gli indirizzi di loopback, privati, link-local, multicast e dei metadati del cloud vengono rifiutati, sia quando lo si imposta sia prima di ogni consegna, e i reindirizzamenti non vengono seguiti. Vengono consegnati solo gli eventi scritti dopo il primo PUT.
Suggerimento
Leggere GET /invoices/{id}/events per mettersi in pari dopo un’interruzione (vedere leggere una fattura). Restituisce ogni evento della fattura, quindi una consegna mancata non fa perdere nulla.
Non ancora disponibili
- Intestazioni personalizzate.
- TLS reciproco.
- Un registro delle consegne.
- Un indirizzo di origine fisso.
Indicarci quale di questi serve al progetto pilota.