---
title: Outbound Webhooks
description: Ricevi notifiche in tempo reale quando un lead viene creato, aggiornato o eliminato in Audin — eventi firmati HMAC, consegnati con retry durabile verso i tuoi endpoint HTTPS.
---

Gli **outbound webhook** sono il canale con cui **Audin notifica il tuo
sistema** quando qualcosa cambia nel CRM. Tu configuri uno o più endpoint
HTTPS, scegli i **topic** a cui iscriverti (es. `lead.created`,
`lead.updated`, `lead.deleted`) e Audin ti recapita un `POST` firmato ogni
volta che l'evento si verifica.

È il complemento speculare dell'[ingestion webhook](/docs/api-rest/ingestion-webhook):
l'ingestion serve a **spingere** i cambiamenti dal tuo sistema verso Audin;
gli outbound webhook servono a **ricevere** da Audin i cambiamenti avvenuti
nel CRM (anche quelli originati da una chiamata, da un workflow o da un
operatore dentro Audin).

<Callout type="info">
  I webhook sono **notifiche**, non una fonte di verità. La consegna e i
  retry sono durabili, ma in casi limite (es. un riavvio nel preciso istante
  tra la scrittura e l'accodamento) una singola notifica può non partire. Per
  uno stato sempre coerente, **riconcilia periodicamente** via API REST
  (`GET /leads`) oltre ad ascoltare i webhook.
</Callout>

## Configurare un endpoint

Ogni endpoint ha: un **URL HTTPS** di destinazione, un flag `enabled`, un set
di **topic** sottoscritti, eventuali **header personalizzati** (inviati con
ogni richiesta) e un **signing secret** generato da Audin.

### Dal dashboard

In **Impostazioni → Webhook** (`https://app.audin.ai/settings/webhooks`):

1. Crea un endpoint con l'URL HTTPS pubblico del tuo ricevitore.
2. Seleziona i topic a cui iscriverti.
3. Copia il **signing secret** mostrato **una sola volta** alla creazione
   (serve per verificare le firme — vedi sotto).
4. (Opzionale) Usa **Test connection** per inviare subito un evento di prova
   (`topic` `webhook.test`, `id` con prefisso `evt_test-`): non viene salvato
   nello storico ed è pensato per validare che il tuo endpoint risponda.

Nella stessa pagina trovi lo **storico delle consegne** (esito, status code,
tentativo, durata) per diagnosticare un endpoint che fallisce.

### Via API

Gli stessi endpoint sono gestibili via API REST con la tua **Account API Key**
(header `X-API-Key`, la stessa di tutta l'[API partner](/docs/api-rest/autenticazione)).

```bash
# Elenco dei topic disponibili
curl https://api.audin.ai/webhook-topics \
  -H "X-API-Key: $API_KEY"

# Crea un endpoint iscritto ai topic dei lead
curl -X POST https://api.audin.ai/webhook-endpoints \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/audin",
    "enabled": true,
    "description": "Sync CRM esterno",
    "customHeaders": [{ "name": "X-My-Token", "value": "abc123" }],
    "topics": ["lead.created", "lead.updated", "lead.deleted"]
  }'
```

La risposta alla creazione include `plainSecret` — il signing secret in
chiaro, **mostrato una sola volta**. Conservalo lato server.

| Operazione | Endpoint |
|---|---|
| Topic disponibili | `GET /webhook-topics` |
| Lista / crea endpoint | `GET` · `POST /webhook-endpoints` |
| Dettaglio / modifica / elimina | `GET` · `PUT` · `DELETE /webhook-endpoints/{id}` |
| Aggiorna le sottoscrizioni | `PUT /webhook-endpoints/{id}/subscriptions` (rimpiazza l'intero set) |
| Ruota il secret | `POST /webhook-endpoints/{id}/rotate-secret` |
| Invia un evento di prova | `POST /webhook-endpoints/{id}/test` |
| Storico consegne | `GET /webhook-endpoints/{id}/deliveries` |

## Topic disponibili

I topic seguono la forma `<entità>.<azione>`. Nella versione corrente:

| Topic | Quando |
|---|---|
| `lead.created` | Un nuovo lead viene creato (da qualsiasi canale: API, import, chiamata, workflow). |
| `lead.updated` | Un campo visibile del lead cambia (incluso il cambio di stato/stage). |
| `lead.deleted` | Un lead viene eliminato. |
| `comment.created` | Un nuovo commento/nota viene aggiunto (su un lead, un deal o un'attività). Include anche i commenti generati automaticamente da Audin (es. riepiloghi di chiamata, note di workflow). |
| `comment.updated` | Il contenuto di un commento cambia. |
| `comment.deleted` | Un commento viene eliminato. |

<Callout type="info">
  Il catalogo cresce un'entità alla volta. Interroga `GET /webhook-topics` per
  l'elenco aggiornato senza dover consultare la documentazione.
</Callout>

## Formato dell'evento (v1)

Ogni consegna è un `POST` con un corpo JSON in questa forma:

| Campo | Tipo | Note |
|---|---|---|
| `id` | string | Identificativo univoco dell'evento (`evt_<uuid>`). **Chiave di idempotency** lato tuo (vedi sotto). |
| `topic` | string | Il topic dell'evento, es. `lead.updated`. |
| `account_id` | string | Account a cui si riferisce l'evento. |
| `created_at` | string (ISO 8601) | Istante di generazione dell'evento. |
| `version` | string | Versione del formato (`"1"`). |
| `data.object` | object | Snapshot dell'entità interessata. |
| `data.previous_attributes` | object | **Solo su `*.updated`**: i campi cambiati con il **valore precedente**. |

### Esempio — `lead.updated`

Su un aggiornamento, `previous_attributes` contiene **solo i campi cambiati**,
con il valore che avevano *prima*; `object` è lo snapshot *dopo* la modifica:

```json
{
  "id": "evt_9b1c2d3e-4f5a-6789-abcd-ef0123456789",
  "topic": "lead.updated",
  "account_id": "51358416-…",
  "created_at": "2026-06-18T10:30:00.000Z",
  "version": "1",
  "data": {
    "object": {
      "id": "f1e2d3c4-…",
      "fullName": "Mario Rossi",
      "firstName": "Mario",
      "lastName": "Rossi",
      "jobTitle": "Direttore acquisti",
      "email": "mario@acme.example.com",
      "phoneNumber": "+393334445566",
      "city": "Milano",
      "leadStatus": "QUALIFIED",
      "stageId": "stage-2-uuid"
    },
    "previous_attributes": {
      "leadStatus": "NEW",
      "stageId": "stage-1-uuid"
    }
  }
}
```

### Esempio — `lead.created`

Sugli eventi `*.created` e `*.deleted` **non** è presente
`previous_attributes`:

```json
{
  "id": "evt_1a2b3c4d-5e6f-7890-abcd-ef1234567890",
  "topic": "lead.created",
  "account_id": "51358416-…",
  "created_at": "2026-06-18T09:00:00.000Z",
  "version": "1",
  "data": {
    "object": {
      "id": "a9b8c7d6-…",
      "fullName": "Giulia Bianchi",
      "firstName": "Giulia",
      "lastName": "Bianchi",
      "jobTitle": "Responsabile marketing",
      "email": "giulia@acme.example.com",
      "phoneNumber": "+393331112233",
      "city": "Torino",
      "leadStatus": "NEW"
    }
  }
}
```

### Lead sincronizzati da un'integrazione (es. HubSpot)

Per i lead che arrivano da un'integrazione come **HubSpot**, lo snapshot
`object` include anche i **campi nativi** mappati 1:1 su Audin —
`firstName`, `lastName`, `jobTitle`, `mobilePhone`, `companyName`,
`website`, `lifecycleStage`, `address`, `city`, `state`, `zip`, `country`
— valorizzati quando la sincronizzazione li popola (altrimenti `null`).

Le **proprietà personalizzate** definite nel tuo account HubSpot e le altre
proprietà native non mappate non vanno perse: sono annidate sotto
`customParams.hubspot` dentro lo stesso `object`:

```json
{
  "object": {
    "id": "a9b8c7d6-…",
    "fullName": "Giulia Bianchi",
    "firstName": "Giulia",
    "lastName": "Bianchi",
    "lifecycleStage": "lead",
    "city": "Torino",
    "customParams": {
      "hubspot": {
        "native": { "hs_object_id": "12345", "hs_analytics_source": "ORGANIC_SEARCH" },
        "custom": { "settore_interesse": "Ortodonzia", "codice_cliente": "MD-0099" }
      }
    }
  }
}
```

<Callout type="info">
  **Nessun campo è nascosto.** Tutti i campi nativi e l'intero blocco
  `customParams.hubspot` (`native` + `custom`) sono inclusi di default nello
  snapshot degli eventi `lead.created` / `lead.updated`. Scrivi il tuo
  ricevitore in modo da ignorare le chiavi che non ti interessano.
</Callout>

### Esempio — `comment.created`

I commenti (note su lead, deal o attività) seguono lo stesso envelope. In
`object` trovi il commento; `metadata` e i riferimenti interni di
workflow **non** sono inclusi nello snapshot:

```json
{
  "id": "evt_2b3c4d5e-6f70-8901-bcde-f23456789012",
  "topic": "comment.created",
  "account_id": "51358416-…",
  "created_at": "2026-06-18T11:15:00.000Z",
  "version": "1",
  "data": {
    "object": {
      "id": "c4d5e6f7-…",
      "content": "Richiamare il cliente lunedì mattina.",
      "type": "NOTE",
      "authorType": "USER",
      "userId": "a9b8c7d6-…",
      "dealId": null,
      "createdAt": "2026-06-18T11:15:00.000Z"
    }
  }
}
```

<Callout type="info">
  Anche i commenti **generati da Audin** (riepiloghi di chiamata, note
  prodotte da un workflow) emettono `comment.created`: in quei casi
  `authorType` non è `USER`. Se ti interessano solo le note umane, filtra
  sul valore di `authorType` nel tuo handler.
</Callout>

<Callout type="warn">
  Il **wire format `v1` è un contratto**. Un cambiamento incompatibile
  comporterebbe un nuovo `version`. Scrivi il tuo ricevitore in modo da
  ignorare campi sconosciuti dentro `object` (additivi) senza romperti.
</Callout>

## Header della richiesta

Ogni `POST` include questi header (oltre agli eventuali header personalizzati
che hai configurato):

| Header | Significato |
|---|---|
| `X-Audin-Signature` | Firma HMAC-SHA256 nel formato `v1=<hex>`. |
| `X-Audin-Signature-Timestamp` | Timestamp Unix (secondi) usato per calcolare la firma. |
| `X-Audin-Webhook-Id` | Identificativo dell'endpoint di destinazione. |
| `X-Audin-Delivery-Id` | Identificativo del singolo tentativo di consegna. |
| `X-Audin-Topic` | Il topic dell'evento. |
| `User-Agent` | `Audin-Webhook/1.0`. |

## Verificare la firma

**Verifica sempre la firma** prima di fidarti del payload. La firma è calcolata
come:

```
firma = HMAC_SHA256( signing_secret , "<timestamp>.<corpo_grezzo>" )
```

dove `<timestamp>` è il valore dell'header `X-Audin-Signature-Timestamp` e
`<corpo_grezzo>` è il **body della richiesta esattamente come ricevuto** (la
stringa raw, non un JSON ri-serializzato). Il risultato è esadecimale e
nell'header arriva con il prefisso `v1=`.

Confronta in **tempo costante** e **rifiuta** le richieste con timestamp troppo
vecchio (protezione anti-replay).

```js
const crypto = require("crypto");

// Usa il body GREZZO della richiesta (Buffer/stringa), NON un JSON già parsato.
function verifyAudinWebhook(rawBody, headers, signingSecret, toleranceSec = 300) {
  const signature = headers["x-audin-signature"];          // "v1=<hex>"
  const timestamp = headers["x-audin-signature-timestamp"]; // "1718705400"

  if (!signature || !timestamp) return false;

  // 1. Anti-replay: scarta timestamp troppo vecchi (o nel futuro).
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > toleranceSec) return false;

  // 2. Ricalcola la firma sul payload firmato "<timestamp>.<rawBody>".
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected =
    "v1=" +
    crypto.createHmac("sha256", signingSecret).update(signedPayload).digest("hex");

  // 3. Confronto in tempo costante (le lunghezze devono coincidere).
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

<Callout type="warn">
  Molti framework espongono solo il body già parsato (`req.body` come oggetto).
  Per la verifica ti serve il **body grezzo**: in Express, ad esempio,
  `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`.
  Ri-serializzare il JSON parsato può alterare spazi/ordine delle chiavi e far
  fallire la verifica.
</Callout>

## Consegna, retry e risposta attesa

Il tuo endpoint deve rispondere con uno status **`2xx`** il più velocemente
possibile (entro **10 secondi**). Accoda il lavoro e rispondi subito: non
elaborare in modo sincrono dentro la richiesta del webhook.

- **Non-`2xx` o timeout** → Audin ritenta secondo questa pianificazione:
  **1m → 5m → 30m → 2h → 12h → 24h** (fino a **6 tentativi** su una finestra di
  circa **40 ore**). Esauriti i tentativi, la consegna è marcata come fallita.
- **`410 Gone`** → Audin **smette di ritentare** quella consegna. Usalo se
  l'endpoint non deve più ricevere quel tipo di evento.
- Ogni tentativo è tracciato nello storico consegne (status code, durata,
  eventuale errore).

## Idempotency

La stessa consegna può arrivare **più di una volta** (es. il tuo `2xx` non
raggiunge Audin e scatta un retry). Inoltre, un singolo aggiornamento che
cambia sia lo stato sia altri campi visibili può generare **due** eventi
`lead.updated` con `previous_attributes` disgiunti.

**Deduplica sempre** sull'`id` dell'evento (`evt_…`): tienilo come chiave e
ignora gli `id` già processati. Tratta ogni handler come idempotente.

## Rotazione del secret

Puoi rigenerare il signing secret di un endpoint dal dashboard ("Rigenera") o
via `POST /webhook-endpoints/{id}/rotate-secret`. Alla rotazione:

- Audin inizia a firmare con il **nuovo** secret. C'è una **finestra di grazia
  di ~24h** sul secret precedente, pensata per darti il tempo di aggiornare la
  verifica.
- In un deployment distribuito, può servire fino a **~60 secondi** perché il
  nuovo secret venga usato ovunque: durante quell'intervallo alcune consegne
  potrebbero ancora arrivare firmate col secret precedente.

**Linea guida pratica:** durante una rotazione, fai **accettare al tuo
verificatore sia il vecchio sia il nuovo secret** per un breve periodo (la
finestra di 24h copre ampiamente il rollout), poi rimuovi il vecchio.

```js
// Durante la rotazione, accetta entrambi i secret.
function verifyWithRotation(rawBody, headers, secrets /* [nuovo, vecchio] */) {
  return secrets.some((s) => verifyAudinWebhook(rawBody, headers, s));
}
```

## Prossimo passo

<Cards>
  <Card href="/docs/api-rest/ingestion-webhook" title="Ingestion webhook" description="Il canale speculare: spingi lead, deal e commenti dal tuo sistema verso Audin." />
  <Card href="/docs/api-rest/crm" title="CRM anagrafiche" description="Leggi e scrivi lead, aziende e contatti con gli id Audin per riconciliare lo stato." />
</Cards>
