# Webhook WhatsApp: verifica della firma, retry e duplicati

> Come gestire i webhook WhatsApp di un agente AI: verifica la firma Standard Webhooks, rispondi entro 10 secondi, sopravvivi ai retry e deduplica gli eventi. TypeScript.

Source: https://whasp.ai/it/blog/webhook-whatsapp-firma-retry-duplicati · 2026-10-20

## In breve

- Un webhook di whasp è firmato con Standard Webhooks, consegnato almeno una volta e senza garanzia d'ordine, quindi un receiver deve verificare, deduplicare e tollerare il disordine.
- Verifica la firma sul body grezzo della richiesta, mai su un JSON serializzato di nuovo, e rifiuta i timestamp sfasati di oltre cinque minuti.
- Scrivi l'evento in un archivio durevole, poi rispondi 2xx entro 10 secondi ed esegui dopo il lavoro lento: whasp conserva il corpo di un evento solo finché non viene consegnato o abbandonato.
- Se il tuo endpoint continua a fallire, whasp riprova 12 volte nell'arco di circa otto ore e poi si arrende, e il contenuto di un message.received fallito non può essere inviato di nuovo.
- Deduplica sull'id dell'evento e invia ogni risposta con un Idempotency-Key costruito con l'id del messaggio in arrivo.

Un webhook di whasp è una HTTPS POST per ogni evento, firmata con Standard Webhooks, consegnata almeno una volta e in nessun ordine garantito. Un receiver affidabile fa quattro cose: verifica la firma sul body grezzo, scrive l'evento in un archivio durevole, risponde 2xx entro 10 secondi ed esegue dopo il lavoro lento, deduplicando sull'id dell'evento. Questa guida costruisce quel receiver in TypeScript.

## Cosa contiene una consegna webhook?

Una consegna di whasp è una sola `POST` con una busta JSON e tre header di firma. La busta contiene l'`id` dell'evento, il suo `type`, un orario `created_at` e i `data` dell'evento. Gli header sono `webhook-id` (l'id dell'evento, lo stesso a ogni retry), `webhook-timestamp` (secondi unix) e `webhook-signature` (`v1,` seguito da un HMAC-SHA256 in base64).

Esistono otto tipi di evento:

| Tipo | Quando scatta | Cosa fa un receiver |
| --- | --- | --- |
| `message.received` | Una persona ha scritto a un numero. Contiene `text`, `media` e altro. | Salvalo ed esegui l'agente. È l'unica copia del contenuto. |
| `message.sent` | Un messaggio in uscita ha lasciato il telefono. | Aggiorna il tuo record. |
| `message.failed` | Un messaggio in uscita è fallito; il motivo è in `message.error`. | Registralo nel log. Riprova solo se il motivo è temporaneo. |
| `reaction.received` | Qualcuno ha reagito a un messaggio; un `emoji` vuoto significa rimossa. | Di solito ignorala. |
| `number.ready` | Un numero che era in `provisioning` ora è attivo. | Inizia a instradare gli utenti verso di esso. |
| `number.status_changed` | `health` o `online` è cambiato. | Manda un alert su `critical`. |
| `number.replaced` | Un numero bannato è stato sostituito. Nell'evento ci sono entrambi i numeri. | [Sposta gli utenti sul nuovo numero WhatsApp](https://whasp.ai/it/blog/spostare-utenti-nuovo-numero-whatsapp-ban). |
| `webhook.test` | Hai chiamato `POST /v1/webhook/test`. | Rispondi 2xx. |

## Come si verifica la firma?

La verifica della firma dimostra che una richiesta arriva da whasp e che nessuno ne ha cambiato il body. La libreria [`standardwebhooks`](https://github.com/standard-webhooks/standard-webhooks) la fa in una sola chiamata, e il progetto pubblica librerie per JavaScript, Python, Go, Java, Ruby, Rust e .NET. Questo è l'algoritmo della [specifica Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) scritto con `node:crypto`, per gli stack senza libreria (in Hono, passa `c.req.raw.headers` e il body come testo):

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Headers, rawBody: string): boolean {
  const id = headers.get("webhook-id") ?? "";
  const timestamp = headers.get("webhook-timestamp") ?? "";
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false; // più vecchio o più nuovo di 5 minuti; intercetta anche NaN

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();

  // L'header può contenere più firme separate da spazi, per esempio "v1,abc= v1,def=".
  return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
    const [version, signature] = part.split(",");
    if (version !== "v1" || !signature) return false;
    const got = Buffer.from(signature, "base64");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

Cinque errori fanno fallire di frequente la verifica:

- **Il JSON già parsato.** La firma copre i byte esatti che whasp ha inviato. Leggi prima il body come testo, verifica e solo dopo fai il parse. Un oggetto serializzato di nuovo può differire per spazi o per l'ordine delle chiavi.
- **La chiave sbagliata.** Un segreto ha la forma `whsec_` più base64. La chiave sono i byte decodificati, non la stringa. Le librerie tolgono il prefisso e decodificano per te.
- **Lo sfasamento dell'orologio.** I timestamp distanti più di cinque minuti dal tuo orologio vengono rifiutati. Tieni il server sincronizzato con NTP.
- **Un confronto semplice con `===`.** Usa [`timingSafeEqual`](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b), che lancia un errore anche con lunghezze diverse, da qui il controllo sulla lunghezza.
- **Leggere solo la prima firma.** L'header può contenere più firme separate da spazi. Accetta la richiesta se una qualsiasi firma `v1` corrisponde.

Rispondi 401 quando la verifica fallisce. whasp lo tratta come qualsiasi fallimento e ritenta, quindi un segreto sbagliato non fa perdere subito gli eventi, ma li fa perdere quando i retry finiscono. `POST /v1/webhook/rotate-secret` emette un nuovo segreto. L'API non dice se durante una rotazione whasp firmi con entrambi i segreti, quindi aggiorna subito la configurazione; ciò che viene rifiutato nel frattempo viene ritentato 30 secondi dopo.

## Perché rispondere subito e lavorare dopo?

Un receiver deve prima salvare l'evento e rispondere, perché whasp aspetta 10 secondi un 2xx e il lavoro di un agente, una chiamata al modello o a un tool, spesso dura di più. Rispondere in ritardo crea un retry, e un retry crea un duplicato.

Si salva per primo perché whasp conserva il corpo di un evento solo finché non viene consegnato o abbandonato, e i server di whasp non conservano i messaggi. Quando il tuo endpoint restituisce 2xx, hai l'unica copia di un `message.received`. Il pattern è una inbox durevole: inserisci l'evento grezzo, rispondi 204 e lascia che un worker elabori le righe della tabella. Il receiver completo è qui sotto, con Hono e `node:sqlite`. Installa `hono`, `@hono/node-server` e `standardwebhooks` in un progetto con `"type": "module"`, imposta `WHASP_WEBHOOK_SECRET` ed eseguilo con `node --env-file=.env webhook.ts` su Node 22.18 o più recente.

```ts
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Webhook } from "standardwebhooks";

type WhaspEvent = { id: string; type: string; created_at: string; data: any };

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...
const MAX_ATTEMPTS = 6;

const db = new DatabaseSync("events.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS inbox (
    id TEXT PRIMARY KEY, type TEXT NOT NULL, chat TEXT, body TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',  -- pending | done | dead
    attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT,
    received_at INTEGER NOT NULL DEFAULT (unixepoch()));
`);

const app = new Hono();

app.post("/whasp/webhook", async (c) => {
  const body = await c.req.text(); // i byte grezzi che sono stati firmati
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  const chat = event.data.message?.chat ?? event.data.chat ?? null;
  // Durevole prima del 2xx. Un id ripetuto non inserisce nulla e riceve comunque un 2xx.
  db.prepare("INSERT OR IGNORE INTO inbox (id, type, chat, body) VALUES (?, ?, ?, ?)").run(
    event.id, event.type, chat, body,
  );
  void drain(); // parte subito; il timer qui sotto riprende solo i retry
  return c.body(null, 204);
});

// Il worker: un evento alla volta per chat, più chat in parallelo.
const busy = new Set<string>();

async function drain() {
  const due = db
    .prepare("SELECT id, chat, body, attempts FROM inbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid")
    .all(Date.now()) as { id: string; chat: string | null; body: string; attempts: number }[];
  for (const row of due) {
    const lane = row.chat ?? row.id;
    if (busy.has(lane)) continue;
    busy.add(lane);
    void work(row).finally(() => busy.delete(lane));
  }
}

async function work(row: { id: string; body: string; attempts: number }) {
  try {
    await handle(JSON.parse(row.body));
    db.prepare("UPDATE inbox SET status = 'done' WHERE id = ?").run(row.id);
  } catch (err) {
    const attempts = row.attempts + 1;
    const status = attempts >= MAX_ATTEMPTS ? "dead" : "pending";
    const next = Date.now() + Math.min(2 ** attempts * 5_000, 300_000); // 10 s, 20 s, 40 s...
    db.prepare("UPDATE inbox SET status = ?, attempts = ?, next_at = ?, error = ? WHERE id = ?").run(
      status, attempts, next, String(err), row.id,
    );
  }
}

async function handle(event: WhaspEvent) {
  switch (event.type) {
    case "message.received":
      console.log("inbound from", event.data.message.chat, event.data.message.text);
      return; // chiama qui il tuo agente, poi POST /v1/messages
    case "message.failed":
      console.warn("send failed", event.data.message.id, event.data.message.error);
      return;
    case "number.status_changed":
      if (event.data.number.health === "critical") console.error("number critical", event.data.number.number);
      return;
    case "number.replaced":
      console.warn("replaced", event.data.old_number, "->", event.data.new_number.number);
      return;
    default:
      return; // message.sent, reaction.received, number.ready, webhook.test
  }
}

setInterval(drain, 1000);
serve({ fetch: app.fetch, port: 3000 });
```

Tre parti reggono il peso. L'`INSERT OR IGNORE` sull'id dell'evento è la deduplica e la durabilità in un solo comando. Il ciclo `drain` esegue un evento alla volta per chat e più chat in parallelo, così una conversazione lenta non blocca le altre. E `work` registra ogni fallimento con un contatore di tentativi e un'attesa crescente, poi parcheggia la riga come `dead` dopo sei tentativi, così un evento avvelenato smette di girare a vuoto e resta nella tabella perché tu possa leggerlo.

Ripulisci ogni giorno le righe finite con `DELETE FROM inbox WHERE status = 'done' AND received_at < unixepoch() - 86400`. Tieni gli id più a lungo della finestra di retry di whasp, o un retry in ritardo sembra nuovo. Dopo un'interruzione del tuo provider di modelli, rimetti in coda le righe parcheggiate con `UPDATE inbox SET status = 'pending', attempts = 0 WHERE status = 'dead'`.

## Cosa succede quando il tuo endpoint fallisce?

Quando il tuo endpoint fallisce, whasp ritenta lo stesso evento con pause sempre più lunghe. È un fallimento qualsiasi risposta non 2xx, un errore di connessione o nessuna risposta entro 10 secondi. Conta anche un 401 dovuto a una firma sbagliata.

| Retry | Attesa dopo il fallimento precedente |
| --- | --- |
| 1 | 30 secondi |
| 2 | 2 minuti |
| 3 | 10 minuti |
| 4 | 30 minuti |
| Da 5 a 12 | 1 ora ciascuno |

I dodici retry coprono circa otto ore. Dopo l'ultimo la consegna è segnata come `failed`, e poiché whasp elimina il corpo di un evento una volta consegnato o abbandonato, il testo di un `message.received` fallito a ogni tentativo non può essere inviato di nuovo. Senza un URL webhook, gli eventi vengono solo elencati come `no_endpoint` e non vengono mai inviati.

Ne seguono due abitudini. Tieni l'endpoint indipendente dal tuo provider di modelli: con l'inbox, un'interruzione lì si vede come righe nella tua tabella, non come consegne fallite. E controlla `GET /v1/webhook/deliveries`, che elenca gli ultimi 7 giorni con `status`, `attempts`, `last_status_code` e `last_error` di ogni consegna.

## Come gestire duplicati e ordine?

Gli eventi duplicati sono normali, perché la consegna è almeno una volta. Lo stesso evento arriva due volte quando il tuo 2xx si perde in rete o arriva dopo 10 secondi. Deduplica sull'id dell'evento (lo stesso valore di `webhook-id`), continua a rispondere 2xx alle ripetizioni e il tuo gestore girerà una volta per evento.

Neanche l'ordine è garantito. Un messaggio che ha richiesto un retry può arrivare dopo quello che la persona ha mandato subito dopo. Il receiver qui sopra esegue già una chat alla volta. Ordina la cronologia che costruisci per il modello per `message.created_at` invece che per ordine di arrivo, e aspettati le raffiche: tre messaggi in dieci secondi producono tre eventi, quindi valuta di aspettare due o tre secondi per vedere se ne arrivano altri prima di rispondere una volta sola.

I duplicati arrivano anche a ciò che invii in risposta. Invia ogni risposta con un `Idempotency-Key` costruito con l'id del messaggio in arrivo, per esempio `reply-msg_...`, non con un valore casuale a ogni tentativo. whasp conserva una chiave per 7 giorni per account: la stessa chiave restituisce il primo messaggio e non invia mai due volte. Un invio rifiutato (4xx) non consuma la chiave, e un 409 `idempotency_conflict` significa che la prima richiesta con quella chiave è ancora in corso, quindi riprova fra un attimo. Lo stesso vale per i tool con effetti collaterali, come prenotazioni o rimborsi: un evento rigiocato rifà girare il tuo agente, quindi proteggili con l'id del messaggio in arrivo, come spiega [la guida per collegare WhatsApp a Claude](https://whasp.ai/it/blog/collegare-whatsapp-claude-api-anthropic).

## Come testare e fare il debug dei webhook?

Testi un webhook di whasp in quattro passaggi, e l'ultimo non richiede alcun account whasp.

1. Avvia il receiver ed esponilo. `cloudflared tunnel --url http://localhost:3000` dà un indirizzo HTTPS temporaneo e [non richiede un account Cloudflare](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/).
2. Registralo con `PUT /v1/webhook` e un body `{ "url": "https://.../whasp/webhook" }`. La risposta contiene il segreto.
3. Chiama `POST /v1/webhook/test`. Invia subito un `webhook.test` e risponde `{ "delivered", "status_code", "error" }`, così vedi cosa ha restituito il tuo server.
4. Invia a localhost un evento finto firmato. `Webhook.sign` produce il valore dell'header, quindi questo script non richiede né un account whasp né un tunnel:

```ts
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!);
const event = {
  id: `evt_local_${Date.now()}`,
  type: "message.received",
  created_at: new Date().toISOString(),
  data: { message: { id: "msg_local_1", chat: "+15555550111", text: "Ciao, questo è un test", media: null } },
};
const body = JSON.stringify(event);

const res = await fetch("http://localhost:3000/whasp/webhook", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "webhook-id": event.id,
    "webhook-timestamp": String(Math.floor(Date.now() / 1000)),
    "webhook-signature": wh.sign(event.id, new Date(), body),
  },
  body,
});
console.log(res.status); // 204
```

Eseguilo con `node --env-file=.env send-test-event.ts` mentre il receiver è attivo. Cambia `type` e `data` per provare allo stesso modo `message.failed` o `number.replaced`.

## Cosa costruire dopo?

Collega il receiver a un agente: [la guida per collegare WhatsApp a ChatGPT](https://whasp.ai/it/blog/collegare-whatsapp-chatgpt-api-openai) e [quella per collegare WhatsApp a Claude](https://whasp.ai/it/blog/collegare-whatsapp-claude-api-anthropic) partono entrambe da questo schema. Dai all'agente una [memoria della conversazione](https://whasp.ai/it/blog/memoria-conversazione-agente-ai-whatsapp), oppure costruisci lo stesso flusso senza codice con [n8n](https://whasp.ai/it/blog/n8n-whatsapp-agente-ai). Quando un numero non basta, [pianifica la capacità con più numeri](https://whasp.ai/it/blog/scalare-agente-ai-whatsapp-piu-numeri).

## Domande

### Come verifico la firma di un webhook WhatsApp di whasp?

Ricalcola un HMAC-SHA256 sulla stringa webhook-id, un punto, webhook-timestamp, un punto e il body grezzo, con chiave la parte decodificata da base64 del tuo segreto whsec_, e confrontalo in tempo costante con il valore v1 in webhook-signature. La libreria standardwebhooks per il tuo linguaggio lo fa in una sola chiamata.

### Quante volte whasp riprova un webhook fallito?

Fino a 12 volte, dopo 30 secondi, 2 minuti, 10 minuti, 30 minuti e poi ogni ora. Dopo l'ultimo fallimento la consegna è segnata come fallita e il corpo dell'evento viene eliminato.

### Lo stesso evento webhook può arrivare due volte?

Sì. La consegna è almeno una volta, quindi un 2xx perso o una risposta lenta produce un duplicato con lo stesso id evento. Salva l'id con un vincolo di unicità e rispondi 2xx alle ripetizioni.

### Cosa deve restituire un endpoint webhook?

Qualsiasi status 2xx entro 10 secondi conta come consegnato, e basta un 204 senza body. Un 4xx, un 5xx, un timeout o un errore di connessione sono un fallimento e vengono ritentati.

### Come testo un webhook di whasp in locale?

Esponi la porta con un tunnel, registra l'URL con PUT /v1/webhook e chiama POST /v1/webhook/test. Puoi anche firmare da solo un evento finto con la libreria standardwebhooks e inviarlo a localhost.
