Outbound Webhooks
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.
Versione Markdown di questa pagina
Scarica o copia il contenuto di questa pagina in formato .md — utile per fornirlo a un agente AI che integra questa specifica feature.
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: 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).
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.
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):
- Crea un endpoint con l'URL HTTPS pubblico del tuo ricevitore.
- Seleziona i topic a cui iscriverti.
- Copia il signing secret mostrato una sola volta alla creazione (serve per verificare le firme — vedi sotto).
- (Opzionale) Usa Test connection per inviare subito un evento di prova
(
topicwebhook.test,idcon prefissoevt_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).
# 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. |
deal.created | Un nuovo deal viene creato (da dashboard, API, ingestion o workflow). |
deal.updated | Un campo visibile del deal cambia: titolo, importo, stage/status (incluse chiusura vinta/persa e riapertura), proprietario, totali ricalcolati dai prodotti o dall'accettazione di un preventivo. |
deal.deleted | Un deal viene eliminato. |
activity.created | Una nuova attività (task, chiamata, email, meeting, nota) viene creata: manualmente, da un template di stage, da un workflow o dall'analisi di una chiamata. |
activity.updated | Un campo visibile dell'attività cambia: oggetto, scadenza, stato (incluso il completamento), assegnatario, priorità, posizione. |
activity.deleted | Un'attività viene eliminata. |
company.created | Una nuova azienda viene creata (da dashboard, API, modulo contatti del sito o workflow). |
company.updated | Un campo visibile dell'azienda cambia: anagrafica, dominio/sito, settore, descrizione (anche quella generata da Audin leggendo il sito), proprietario, campi HubSpot. |
company.deleted | Un'azienda viene eliminata. |
quote.created | Un nuovo preventivo viene creato (da template, import o MCP). Porta già documentNumber e currentRevisionId. |
quote.updated | Lo stato o i puntatori del preventivo cambiano: invio, apertura, accettazione (con acceptedRevisionId/acceptedOptionId), rifiuto, scadenza, nuova revisione, riapertura, revoca del link. |
quote.deleted | Un preventivo viene eliminato. |
contract.created | Un nuovo contratto viene creato o importato. |
contract.updated | Lo stato del contratto cambia: invio, apertura, firma (signedAt), rifiuto, scadenza, revoca del link. |
contract.deleted | Un contratto viene eliminato. |
call.created | Una chiamata viene registrata: inbound al bot o a un operatore, outbound da campagna/workflow/desk, upload manuale di un audio. |
call.updated | Un campo visibile della chiamata cambia: status (in-progress, completed, failed, no-answer…), duration/endTime, transferredAt/transferredTo (passaggio a un operatore), transcriptionStatus (enhanced_completed = trascrizione pronta), esito (outcome, outcomeNotes), lead collegato (userId). |
call.deleted | Una chiamata viene eliminata. |
conversation.created | Una nuova conversazione WhatsApp/SMS viene aperta (primo messaggio in arrivo, invio dal desk o via API). |
conversation.updated | Cambia la presa in carico o lo stato: isHumanMode/assignedTo/humanTakeoverAt (operatore), status (BOT_ACTIVE, HUMAN_TAKEOVER, CLOSED, OPEN), botConfigId, lead collegato. I contatori e la finestra 24h non generano eventi. |
conversation.deleted | Una conversazione viene eliminata. |
message.created | Un messaggio viene registrato: in arrivo dal cliente (direction: INBOUND), inviato dal bot, da un operatore o dal sistema (senderType). |
message.updated | Lo stato di consegna cambia (SENT → DELIVERED → READ, oppure FAILED con errorCode), o viene registrato l'operatore mittente. |
analysis.created | L'analisi AI di una chiamata o conversazione è pronta: riepilogo, sentiment, urgenza, esito consigliato, e le liste di action item, prossimi passi, pain point, temi, obiezioni e alert critici. Riferimento alla sorgente via callId o conversationSessionId. |
analysis.updated | Un'analisi preliminare viene sostituita da quella finale. |
analysis.deleted | Un'analisi viene rimossa (ad esempio prima di una ri-analisi). |
form_submission.created | Un form pubblico viene compilato: answers per ref di campo, leadId ancora nullo. |
form_submission.updated | La submission viene agganciata a un lead (leadId valorizzato, previous_attributes.leadId: null). |
line_item.created | Una riga prodotto viene aggiunta a un deal (dealId) o a un'offerta di preventivo (quoteOptionId). |
line_item.updated | Una riga cambia: quantità, prezzi, sconto, periodo di fatturazione, ordine. Il costo di acquisto non è mai incluso. |
line_item.deleted | Una riga viene rimossa. Le righe copiate in blocco (nuova revisione, offerta duplicata, travaso dell'offerta accettata sul deal) non generano eventi: leggi il deal o il preventivo. |
web_chat_conversation.created | Un visitatore apre una chat dal widget del sito (widgetId, visitorId; userId quando il lead viene riconosciuto). |
web_chat_conversation.updated | Cambia lo stato (active/closed), la presa in carico (isHumanMode) o il lead collegato. |
web_chat_conversation.deleted | Una chat del widget viene eliminata. |
web_chat_message.created | Un messaggio della chat del widget: dal visitatore (direction: inbound, senderType: user), dal bot, da un operatore o dal sistema. |
booking.created | Un appuntamento viene prenotato tramite un link di prenotazione (bot, desk o pagina pubblica). Porta startAt/endAt, attendees, htmlLink e leadIds. Gli eventi del calendario non nati da una prenotazione non generano eventi. |
booking.updated | Una prenotazione viene spostata o cancellata (status: CANCELLED), da Audin o dal cliente direttamente su Google (rilevato al prossimo sync). |
email.created | Un'email viene inviata dal composer sul canale Gmail (direction: OUTBOUND) o arriva una nuova email sulla casella collegata (INBOUND, dal sync incrementale). Lo storico importato al collegamento della casella non genera eventi. |
email.updated | Il destinatario apre l'email (openedAt, openCount) o clicca un link (clickedAt, clickCount); un'email viene nascosta o ripristinata (isHidden). Il corpo HTML non è incluso: usa GET /emails/{id}. |
Il catalogo cresce un'entità alla volta. Interroga GET /webhook-topics per
l'elenco aggiornato senza dover consultare la documentazione.
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:
{
"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:
{
"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:
{
"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" }
}
}
}
}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.
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:
{
"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"
}
}
}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.
Esempio — deal.updated
I deal seguono lo stesso envelope. In object trovi il deal così com'è in
Audin (senza le relazioni: pipeline, stage e proprietario sono referenziati
per id). Gli importi (amount, oneTimeTotal, recurringTotal,
totalValue) sono decimali serializzati come stringa, per non perdere
precisione:
{
"id": "evt_3c4d5e6f-7081-9012-cdef-345678901234",
"topic": "deal.updated",
"account_id": "51358416-…",
"created_at": "2026-09-15T09:30:00.000Z",
"version": "1",
"data": {
"object": {
"id": "d5e6f7a8-…",
"title": "Fornitura arredi 2026",
"amount": "12500.00",
"currency": "EUR",
"status": "WON",
"pipelineId": "p1a2b3c4-…",
"stageId": "s9f8e7d6-…",
"probability": 1,
"ownerId": "u1b2c3d4-…",
"primaryContactId": "a9b8c7d6-…",
"externalId": null,
"customFields": null,
"actualCloseDate": "2026-09-15T09:30:00.000Z",
"createdAt": "2026-09-01T08:00:00.000Z",
"updatedAt": "2026-09-15T09:30:00.000Z"
},
"previous_attributes": {
"status": "OPEN",
"stageId": "s1a2b3c4-…",
"probability": 0.6,
"actualCloseDate": null
}
}
}Esempio — activity.updated
Le attività seguono lo stesso envelope: in object c'è l'attività con i
riferimenti per id (dealId, contactId, companyId, assignedTo,
parentId per le sottoattività). Un completamento produce un activity.updated
con status e completedAt in previous_attributes:
{
"id": "evt_4d5e6f70-8192-0123-def0-456789012345",
"topic": "activity.updated",
"account_id": "51358416-…",
"created_at": "2026-09-15T10:00:00.000Z",
"version": "1",
"data": {
"object": {
"id": "e6f7a8b9-…",
"type": "TASK",
"subject": "Richiamare il cliente",
"status": "COMPLETED",
"priority": "HIGH",
"dueDate": "2026-09-16T09:00:00.000Z",
"completedAt": "2026-09-15T10:00:00.000Z",
"dealId": "d5e6f7a8-…",
"contactId": "a9b8c7d6-…",
"companyId": null,
"assignedTo": "u1b2c3d4-…",
"sourceType": "MANUAL",
"parentId": null,
"createdAt": "2026-09-14T08:00:00.000Z",
"updatedAt": "2026-09-15T10:00:00.000Z"
},
"previous_attributes": {
"status": "PENDING",
"completedAt": null
}
}
}Esempio — company.created
Le aziende seguono lo stesso envelope. annualRevenue è un decimale
serializzato come stringa; il blob interno metadata non è incluso:
{
"id": "evt_5e6f7081-92a3-1234-ef01-567890123456",
"topic": "company.created",
"account_id": "51358416-…",
"created_at": "2026-09-15T11:00:00.000Z",
"version": "1",
"data": {
"object": {
"id": "f7a8b9c0-…",
"name": "ACME S.p.A.",
"domain": "acme.it",
"website": "https://acme.it",
"sector": "Manifattura",
"city": "Bergamo",
"country": "Italy",
"ownerId": "u1b2c3d4-…",
"externalId": null,
"annualRevenue": "1500000.00",
"createdAt": "2026-09-15T11:00:00.000Z",
"updatedAt": "2026-09-15T11:00:00.000Z"
}
}
}Esempio — quote.updated (accettazione)
Preventivi e contratti seguono lo stesso envelope, ma con un'esclusione
importante: htmlContent e gli URL dei PDF non sono mai inclusi. Il
contenuto del documento e il PDF si leggono via API (GET /quotes/{id},
GET /contracts/{id}), dove l'URL viene rigenerato a ogni lettura. L'evento
porta stato, timestamp, numero documento e i riferimenti alle revisioni:
{
"id": "evt_6f708192-a3b4-2345-f012-678901234567",
"topic": "quote.updated",
"account_id": "51358416-…",
"created_at": "2026-09-15T12:00:00.000Z",
"version": "1",
"data": {
"object": {
"id": "a8b9c0d1-…",
"dealId": "d5e6f7a8-…",
"documentNumber": "PRV-2026-0042",
"title": "Fornitura arredi 2026",
"status": "ACCEPTED",
"currentRevisionId": "r2c3d4e5-…",
"acceptedRevisionId": "r2c3d4e5-…",
"acceptedOptionId": "o1a2b3c4-…",
"sentAt": "2026-09-10T09:00:00.000Z",
"viewedAt": "2026-09-11T15:20:00.000Z",
"respondedAt": "2026-09-15T12:00:00.000Z",
"expiresAt": "2026-10-10T09:00:00.000Z",
"ownerId": "u1b2c3d4-…",
"createdAt": "2026-09-10T08:30:00.000Z",
"updatedAt": "2026-09-15T12:00:00.000Z"
},
"previous_attributes": {
"status": "VIEWED",
"acceptedRevisionId": null,
"acceptedOptionId": null,
"respondedAt": null
}
}
}Un contratto firmato produce contract.updated con status: "SIGNED" e
signedAt valorizzato. Per scaricare il PDF firmato usa GET /contracts/{id}.
Esempio — call.updated (chiamata conclusa)
Le chiamate seguono lo stesso envelope. Lo stato interno del motore (workflow,
puntatori di step, chiavi dello storage audio) non è incluso; trascrizione e
analisi si leggono via GET /calls/{id}. Una chiamata può ricevere più
call.updated in sequenza (fine chiamata, trascrizione pronta, esito
impostato): usa previous_attributes per capire cosa è cambiato.
{
"id": "evt_70819234-b5c6-3456-0123-789012345678",
"topic": "call.updated",
"account_id": "51358416-…",
"created_at": "2026-09-15T14:05:12.000Z",
"version": "1",
"data": {
"object": {
"id": "CA0123456789abcdef0123456789abcdef",
"status": "completed",
"callType": "AI_BOT",
"phoneNumber": "+393331234567",
"from": "+393331234567",
"to": "+390212345678",
"startTime": "2026-09-15T14:02:00.000Z",
"endTime": "2026-09-15T14:05:12.000Z",
"duration": 192,
"userId": "a9b8c7d6-…",
"botConfigId": "b1c2d3e4-…",
"transcriptionStatus": "immediate_completed",
"outcome": null,
"transferredAt": null,
"createdAt": "2026-09-15T14:02:00.000Z",
"updatedAt": "2026-09-15T14:05:12.000Z"
},
"previous_attributes": {
"status": "in-progress",
"endTime": null,
"duration": null,
"transcriptionStatus": null
}
}
}Esempio — message.created (messaggio in arrivo)
Le conversazioni e i messaggi seguono lo stesso envelope. Un messaggio porta
conversationId, direction, senderType, content, contentType,
buttonPayload (quick reply scelta) e lo stato di consegna; l'URL dei media
non è incluso (si legge via API). I messaggi di sistema che tracciano le
chiamate ai tool del bot non generano eventi.
{
"id": "evt_8192a3b4-c5d6-4567-1234-890123456789",
"topic": "message.created",
"account_id": "51358416-…",
"created_at": "2026-09-15T15:10:00.000Z",
"version": "1",
"data": {
"object": {
"id": "9a0b1c2d-…",
"conversationId": "c1d2e3f4-…",
"direction": "INBOUND",
"senderType": "USER",
"senderId": null,
"content": "Vorrei spostare l'appuntamento a giovedì",
"contentType": "text",
"buttonPayload": null,
"twilioSid": "SM0123456789abcdef0123456789abcdef",
"status": "DELIVERED",
"createdAt": "2026-09-15T15:10:00.000Z",
"updatedAt": "2026-09-15T15:10:00.000Z"
}
}
}Esempio — analysis.created
L'analisi è autosufficiente: le liste figlie sono incluse nell'evento, non
serve una seconda lettura. Il lead e la chiamata si raggiungono via callId
(GET /calls/{id}).
{
"id": "evt_92a3b4c5-d6e7-5678-2345-901234567890",
"topic": "analysis.created",
"account_id": "51358416-…",
"created_at": "2026-09-15T14:07:30.000Z",
"version": "1",
"data": {
"object": {
"id": "b2c3d4e5-…",
"sourceType": "CALL",
"callId": "CA0123456789abcdef0123456789abcdef",
"conversationSessionId": null,
"status": "final",
"summary": "Il cliente chiede un preventivo per un impianto; disponibile giovedì.",
"callQuality": "good",
"urgency": "high",
"sentimentOverall": "positive",
"interestLevel": "high",
"conversionProbability": "medium",
"recommendedStatus": "QUALIFIED",
"npsEstimated": 8,
"actionItems": [
{ "id": "…", "description": "Inviare il preventivo entro venerdì", "urgency": "high" }
],
"nextSteps": [{ "id": "…", "description": "Richiamare giovedì alle 10" }],
"painPoints": [],
"keyTopics": [{ "id": "…", "topic": "impianto", "relevance": "high" }],
"criticalAlerts": [],
"analyzedAt": "2026-09-15T14:07:30.000Z"
}
}
}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.
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).
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);
}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.
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-
2xxo 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.
// Durante la rotazione, accetta entrambi i secret.
function verifyWithRotation(rawBody, headers, secrets /* [nuovo, vecchio] */) {
return secrets.some((s) => verifyAudinWebhook(rawBody, headers, s));
}Prossimo passo
Ingestion webhook
Spingi lead, deal e commenti dal tuo sistema verso Audin con un singolo endpoint tipizzato e idempotente — upsert per external_id, ereditando automazioni e assegnazioni.
Documenti
Prodotti, categorie, clausole, template documento, preventivi e contratti — genera e traccia i documenti commerciali con l'API REST partner.