Verifica della Sicurezza dei Webhook (HMAC Signature)
Per garantire che ogni webhook ricevuto provenga realmente dal nostro sistema e non sia stato alterato o falsificato, utilizziamo il meccanismo standard di firma HMAC-SHA256, lo stesso adottato da Stripe, Shopify, GitHub, PayPal e molti altri servizi affidabili.
Principi di sicurezza garantiti
- Autenticità — solo chi possiede la chiave segreta può generare una firma valida
- Integrità — il contenuto del payload non è stato modificato durante il transito
- Protezione da replay — grazie al timestamp, le richieste vecchie vengono rifiutate
Componenti coinvolti
| Header HTTP | Descrizione | Obbligatorio | Esempio valore |
|---|---|---|---|
Webhook-Timestamp | Timestamp Unix (secondi) di generazione del webhook | Sì | 1733678400 |
Webhook-Signature | Firma nel formato t=<timestamp>,v1=<hmac_sha256_hex> | Sì | t=1733678400,v1=4f8a9b2c...d7e1f3 |
Come calcoliamo la firma (lato nostro)
-
Concateniamo: timestamp + ”.” + raw_body (il
raw_bodyè il JSON esattamente come inviato, senza parsing o modifiche di formattazione) -
Calcoliamo l’HMAC-SHA256 utilizzando la chiave segreta condivisa con voi
-
Convertiamo il risultato in stringa esadecimale minuscola
-
Costruiamo l’header:
Webhook-Signature: t=<timestamp>,v1=<firma_calcolata>Come verificare la firma (lato vostro – passi obbligatori)
-
Controllare la presenza degli header
SeWebhook-TimestampoWebhook-Signaturemancano → rispondere con 401 Unauthorized -
Verificare la freschezza del timestamp (anti-replay)
|current_time - timestamp| ≤ 300 secondi (tolleranza massima 5 minuti)Se il timestamp è troppo vecchio → 401
-
Estrarre la firma v1 Il valore di Webhook-Signature può contenere più firme separate da virgola (per supportare rotazione chiavi in futuro). Prendete solo la parte che inizia con v1=
Esempio:
t=1733678400,v1=abcdef123456,v0=oldone → usare solo v1=abcdef123456- Ricalcolare la firma attesa
payload_to_sign = timestamp + "." + request.getContent() // RAW body!
expected = HMAC-SHA256(payload_to_sign, YOUR_WEBHOOK_SECRET).hexdigest()-
Confrontare in modo sicuro Utilizzare una funzione di comparazione timing-safe (es.
hash_equals()in PHP,crypto.timingSafeEqual()in Node.js)Se non corrispondono esattamente → 401 Unauthorized
Esempio implementazione PHP
<?php
function verifyWebhookSignature($request, $webhookSecret) {
// 1. Ottenere gli header
$timestamp = $request->header('Webhook-Timestamp');
$signature = $request->header('Webhook-Signature');
if (!$timestamp || !$signature) {
return false; // 401 Unauthorized
}
// 2. Verificare freschezza del timestamp (max 5 minuti)
$currentTime = time();
if (abs($currentTime - $timestamp) > 300) {
return false; // Timestamp troppo vecchio
}
// 3. Estrarre la firma v1
preg_match('/v1=([a-f0-9]+)/', $signature, $matches);
if (!isset($matches[1])) {
return false;
}
$receivedSignature = $matches[1];
// 4. Ricalcolare la firma attesa
$rawBody = $request->getContent(); // RAW body, non parsed!
$payloadToSign = $timestamp . '.' . $rawBody;
$expectedSignature = hash_hmac('sha256', $payloadToSign, $webhookSecret);
// 5. Confrontare in modo timing-safe
return hash_equals($expectedSignature, $receivedSignature);
}
// Utilizzo
if (!verifyWebhookSignature($request, env('WEBHOOK_SECRET'))) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Elaborare il webhook
$payload = json_decode($request->getContent(), true);
// ...Esempio implementazione Node.js
const crypto = require('crypto');
function verifyWebhookSignature(req, webhookSecret) {
// 1. Ottenere gli header
const timestamp = req.headers['webhook-timestamp'];
const signature = req.headers['webhook-signature'];
if (!timestamp || !signature) {
return false; // 401 Unauthorized
}
// 2. Verificare freschezza del timestamp (max 5 minuti)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false; // Timestamp troppo vecchio
}
// 3. Estrarre la firma v1
const v1Match = signature.match(/v1=([a-f0-9]+)/);
if (!v1Match) {
return false;
}
const receivedSignature = v1Match[1];
// 4. Ricalcolare la firma attesa
const rawBody = req.rawBody; // Deve essere il body RAW, non parsed!
const payloadToSign = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(payloadToSign)
.digest('hex');
// 5. Confrontare in modo timing-safe
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
// Utilizzo con Express
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
req.rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(req, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Elaborare il webhook
const payload = JSON.parse(req.rawBody);
// ...
res.json({ received: true });
});Note importanti
-
Usare sempre il body RAW: Non parsare il JSON prima di verificare la firma, altrimenti la formattazione potrebbe cambiare e la verifica fallirà
-
Confronto timing-safe: Utilizzare sempre funzioni di comparazione sicure per prevenire timing attacks
-
Tolleranza temporale: La finestra di 5 minuti è un buon compromesso tra sicurezza e tolleranza per problemi di sincronizzazione oraria
-
Rotazione chiavi: Il formato con versione (
v1=) permette di supportare più algoritmi di firma in futuro senza rompere la retrocompatibilità -
Logging: Registrare i tentativi falliti di verifica per individuare possibili attacchi
Contenuto degli invii
Il contenuto di ogni evento, con esempi, è descritto in
Webhook . Esempio di tracking.update:
{
"event": "tracking.update",
"timestamp": "2026-10-01T08:15:02+02:00",
"ldv": "3UW0WTQ069239",
"vector_name": "PosteDeliveryBusiness",
"order_id": "ORD-1234",
"client_store_id": null,
"TrackingDettaglio": [
{ "Data": "01/10/2026 08:12", "Stato": "In transito", "Luogo": "Roma" }
],
"statusCode": 3,
"contractCode": "poste-express",
"domain": "demo.spedisci.online"
}Eventi disponibili
| Evento | Descrizione |
|---|---|
tracking.update | Lo stato della spedizione cambia |
shipment.created | Una spedizione viene creata |
stock.opened | Il corriere apre una giacenza sulla spedizione |
stock.closed | La giacenza si chiude |
Gli eventi si scelgono alla registrazione dell’endpoint, in Impostazioni → Webhooks oppure dalle API .
Risposta richiesta
Il vostro endpoint deve rispondere con:
- 2xx se il webhook è stato ricevuto
- 401 Unauthorized se la firma non è valida
In caso di errore di rete o di risposta 5xx l’invio viene ritentato 3 volte (dopo 30 secondi, 2 minuti e 5 minuti). Una risposta 4xx non viene ritentata. Un endpoint che non risponde 2xx per 72 ore viene disattivato automaticamente, con un avviso via e-mail.