n8n
Ricevere webhook Blu Pantheon in n8n, verificare la firma HMAC con un Code node e chiamare le API con credenziali Header Auth.
n8n è la scelta più diretta per integrare Blu Pantheon: gestisce nativamente il corpo grezzo delle richieste, consente codice JavaScript arbitrario e memorizza le credenziali in modo cifrato.
Prima di iniziare
- Un istanza n8n 1.60 o superiore, self-hosted o cloud, raggiungibile da Internet in HTTPS
- La chiave API bp_live_xxxxxxxxxxxxxxxx e il segreto whsec_xxxxxxxxxxxxxxxx
- Variabili d ambiente BP_WEBHOOK_SECRET e BP_PROJECT_ID impostate sull istanza
- Permessi per creare credenziali e workflow
Parte 1 — Credenziali
Crea la credenziale Header Auth
Apri Credentials → Add credential → Header Auth.
| Campo | Valore |
|---|---|
| Credential Name | Blu Pantheon API |
| Name | Authorization |
| Value | Bearer bp_live_xxxxxxxxxxxxxxxx |
Salva. Il valore viene cifrato con la chiave di cifratura dell'istanza e non è più leggibile dall'interfaccia.
Esponi il segreto webhook come variabile d'ambiente
Il Code node non può leggere le credenziali. Il segreto va esposto come variabile d'ambiente dell'istanza n8n.
services:
n8n:
image: n8nio/n8n:latest
environment:
- N8N_HOST=automation.acme.it
- N8N_PROTOCOL=https
- WEBHOOK_URL=https://automation.acme.it/
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_BLOCK_ENV_ACCESS_IN_NODE=false
- BP_WEBHOOK_SECRET=${BP_WEBHOOK_SECRET}
- BP_PROJECT_ID=bp_proj_7f3a9c21Accesso alle variabili nei nodi
N8N_BLOCK_ENV_ACCESS_IN_NODE deve valere false perché $env sia accessibile dal Code
node. Su n8n Cloud usa invece le Variables del progetto e leggile con $vars.
Parte 2 — Nodo Webhook
Aggiungi un nodo Webhook e configuralo così:
| Parametro | Valore |
|---|---|
| HTTP Method | POST |
| Path | blupantheon |
| Authentication | None (l'autenticità si verifica con HMAC) |
| Respond | Using Respond to Webhook node |
| Options → Raw Body | attivo |
| Options → Binary Property | data |
| Options → Allowed Origins (CORS) | vuoto |
L'URL di produzione risultante è:
URL da registrare in Console
https://automation.acme.it/webhook/blupantheonIn fase di sviluppo usa /webhook-test/blupantheon con il pulsante Listen for test event.
Raw Body obbligatorio
Senza l'opzione Raw Body n8n deserializza il JSON e il corpo originale va perso. La firma
non sarà mai verificabile. Con Raw Body attivo il contenuto arriva in
items[0].binary.data.data come Base64.
Parte 3 — Code node di verifica
Aggiungi un nodo Code rinominato Verifica firma, modalità Run Once for All Items.
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300;
const secrets = [
$env.BP_WEBHOOK_SECRET,
$env.BP_WEBHOOK_SECRET_NEXT,
].filter(Boolean);
if (secrets.length === 0) {
throw new Error('Nessun segreto webhook configurato nelle variabili d ambiente');
}
const item = $input.first();
const headers = item.json.headers ?? {};
// Con Raw Body attivo il corpo arriva in Base64 nella proprietà binaria.
const rawBody = Buffer.from(item.binary.data.data, 'base64').toString('utf8');
const signatureHeader = headers['x-bp-signature'] ?? '';
const deliveryId = headers['x-bp-delivery'] ?? '';
const eventType = headers['x-bp-event'] ?? '';
let timestamp = null;
const signatures = [];
for (const segment of signatureHeader.split(',')) {
const index = segment.indexOf('=');
if (index === -1) continue;
const key = segment.slice(0, index).trim();
const value = segment.slice(index + 1).trim();
if (key === 't') timestamp = Number(value);
if (key === 'v1') signatures.push(value);
}
function fail(reason, status) {
return [
{
json: {
valid: false,
reason,
status,
deliveryId,
eventType,
},
},
];
}
if (!Number.isFinite(timestamp)) {
return fail('timestamp-non-valido', 400);
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
return fail('timestamp-fuori-tolleranza', 400);
}
if (signatures.length === 0) {
return fail('firma-assente', 401);
}
const signedPayload = `${timestamp}.${rawBody}`;
let valid = false;
for (const secret of secrets) {
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
const expectedBuffer = Buffer.from(expected, 'utf8');
for (const candidate of signatures) {
const candidateBuffer = Buffer.from(candidate, 'utf8');
if (
candidateBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
) {
valid = true;
break;
}
}
if (valid) break;
}
if (!valid) {
return fail('firma-non-corrispondente', 401);
}
const event = JSON.parse(rawBody);
return [
{
json: {
valid: true,
reason: 'ok',
status: 202,
deliveryId,
eventType,
event,
},
},
];Parte 4 — Ramificazione e risposta
Nodo IF
Aggiungi un nodo IF rinominato Firma valida?.
| Parametro | Valore |
|---|---|
| Conditions → Boolean | {{ $json.valid }} |
| Operation | is true |
Ramo falso — risposta di rifiuto
Collega un nodo Respond to Webhook rinominato Rifiuta.
| Parametro | Valore |
|---|---|
| Respond With | JSON |
| Response Code | {{ $json.status }} |
| Response Body | {"error": "{{ $json.reason }}"} |
Ramo vero — risposta immediata
Collega un nodo Respond to Webhook rinominato Accetta.
| Parametro | Valore |
|---|---|
| Respond With | JSON |
| Response Code | 202 |
| Response Body | {"status": "accettato"} |
Prosegui la catena dopo questo nodo: n8n continua l'esecuzione anche dopo aver risposto.
Switch per tipo di evento
Aggiungi un nodo Switch rinominato Instrada evento con modalità Rules sul valore
{{ $json.eventType }}:
| Output | Valore |
|---|---|
handover | session.handover.requested |
booking | booking.confirmed |
client | client.created |
fallback | (Fallback Output → extra) |
Parte 5 — Chiamata alle API
Aggiungi un nodo HTTP Request rinominato Aggiungi nota sessione.
| Parametro | Valore |
|---|---|
| Method | POST |
| URL | https://api.blupantheon.com/identity/v1/sessions/{{ $json.event.data.sessionId }}/notes |
| Authentication | Generic Credential Type |
| Generic Auth Type | Header Auth |
| Credential | Blu Pantheon API |
| Send Headers | attivo |
| Send Body | attivo |
| Body Content Type | JSON |
| Options → Timeout | 15000 |
| Options → Retry On Fail | attivo, Max Tries 3, Wait Between Tries 2000 |
Header aggiuntivi:
| Name | Value |
|---|---|
X-BP-Project | {{ $env.BP_PROJECT_ID }} |
Idempotency-Key | {{ $json.deliveryId }} |
Corpo JSON:
{
"body": "Ticket aperto nel gestionale: {{ $json.numeroPratica }}",
"visibility": "internal",
"author": "n8n"
}Parte 6 — Error Trigger
Crea un secondo workflow dedicato agli errori.
Aggiungi il nodo Error Trigger
Nuovo workflow BluPantheon - Gestione errori, primo nodo Error Trigger. Non richiede
configurazione.
Notifica il team
Collega un nodo Send Email (o Slack, Microsoft Teams) con oggetto:
[n8n] Workflow {{ $json.workflow.name }} fallitoe corpo che includa {{ $json.execution.error.message }} e
{{ $json.execution.url }}.
Collega il workflow principale
Nel workflow principale apri Settings → Error Workflow e seleziona
BluPantheon - Gestione errori.
Workflow completo da importare
Copia il JSON seguente e usalo con Workflows → Import from Clipboard.
{
"name": "BluPantheon - Ricezione eventi",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "blupantheon",
"responseMode": "responseNode",
"options": {
"rawBody": true,
"binaryPropertyName": "data"
}
},
"id": "a1000000-0000-4000-8000-000000000001",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-200, 300],
"webhookId": "a1000000-0000-4000-8000-000000000001"
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "const crypto = require('crypto');\nconst TOLERANCE_SECONDS = 300;\nconst secrets = [$env.BP_WEBHOOK_SECRET, $env.BP_WEBHOOK_SECRET_NEXT].filter(Boolean);\nif (secrets.length === 0) { throw new Error('Nessun segreto webhook configurato'); }\nconst item = $input.first();\nconst headers = item.json.headers || {};\nconst rawBody = Buffer.from(item.binary.data.data, 'base64').toString('utf8');\nconst signatureHeader = headers['x-bp-signature'] || '';\nconst deliveryId = headers['x-bp-delivery'] || '';\nconst eventType = headers['x-bp-event'] || '';\nlet timestamp = null;\nconst signatures = [];\nfor (const segment of signatureHeader.split(',')) {\n const index = segment.indexOf('=');\n if (index === -1) continue;\n const key = segment.slice(0, index).trim();\n const value = segment.slice(index + 1).trim();\n if (key === 't') timestamp = Number(value);\n if (key === 'v1') signatures.push(value);\n}\nfunction fail(reason, status) { return [{ json: { valid: false, reason, status, deliveryId, eventType } }]; }\nif (!Number.isFinite(timestamp)) return fail('timestamp-non-valido', 400);\nconst now = Math.floor(Date.now() / 1000);\nif (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return fail('timestamp-fuori-tolleranza', 400);\nif (signatures.length === 0) return fail('firma-assente', 401);\nconst signedPayload = timestamp + '.' + rawBody;\nlet valid = false;\nfor (const secret of secrets) {\n const expected = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');\n const expectedBuffer = Buffer.from(expected, 'utf8');\n for (const candidate of signatures) {\n const candidateBuffer = Buffer.from(candidate, 'utf8');\n if (candidateBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(candidateBuffer, expectedBuffer)) { valid = true; break; }\n }\n if (valid) break;\n}\nif (!valid) return fail('firma-non-corrispondente', 401);\nconst event = JSON.parse(rawBody);\nreturn [{ json: { valid: true, reason: 'ok', status: 202, deliveryId, eventType, event } }];"
},
"id": "a1000000-0000-4000-8000-000000000002",
"name": "Verifica firma",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [20, 300]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "version": 2 },
"conditions": [
{
"id": "cond-1",
"leftValue": "={{ $json.valid }}",
"rightValue": true,
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
}
],
"combinator": "and"
},
"options": {}
},
"id": "a1000000-0000-4000-8000-000000000003",
"name": "Firma valida?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [240, 300]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ status: 'accettato' }) }}",
"options": { "responseCode": 202 }
},
"id": "a1000000-0000-4000-8000-000000000004",
"name": "Accetta",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [460, 200]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: $json.reason }) }}",
"options": { "responseCode": "={{ $json.status }}" }
},
"id": "a1000000-0000-4000-8000-000000000005",
"name": "Rifiuta",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [460, 420]
},
{
"parameters": {
"method": "POST",
"url": "=https://api.blupantheon.com/identity/v1/sessions/{{ $json.event.data.sessionId }}/notes",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "X-BP-Project", "value": "={{ $env.BP_PROJECT_ID }}" },
{ "name": "Idempotency-Key", "value": "={{ $json.deliveryId }}" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ body: 'Evento ricevuto: ' + $json.eventType, visibility: 'internal', author: 'n8n' }) }}",
"options": { "timeout": 15000 }
},
"id": "a1000000-0000-4000-8000-000000000006",
"name": "Aggiungi nota sessione",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [700, 200],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Verifica firma", "type": "main", "index": 0 }]] },
"Verifica firma": { "main": [[{ "node": "Firma valida?", "type": "main", "index": 0 }]] },
"Firma valida?": {
"main": [
[{ "node": "Accetta", "type": "main", "index": 0 }],
[{ "node": "Rifiuta", "type": "main", "index": 0 }]
]
},
"Accetta": {
"main": [[{ "node": "Aggiungi nota sessione", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"active": false
}Dopo l importazione
Apri il nodo Aggiungi nota sessione e associa la credenziale Blu Pantheon API: le
credenziali non vengono mai esportate nel JSON.
Gestione degli errori
| Situazione | Comportamento |
|---|---|
| Firma non valida | Il nodo Rifiuta risponde 401. Blu Pantheon ritenta ma continuerà a fallire: verifica il segreto. |
| Timestamp fuori tolleranza | Risposta 400. Controlla l'orologio del server n8n. |
API Blu Pantheon in errore 5xx | Retry On Fail ripete fino a 3 volte, poi il workflow fallisce e scatta l'Error Trigger. |
| Sistema a valle lento | La risposta 202 è già stata inviata: nessun timeout lato Blu Pantheon. |
| Duplicato | Aggiungi un nodo di lettura su Redis o database prima dell'elaborazione, con chiave deliveryId. |
Procedura di test
Test in ascolto
Clicca Listen for test event sul nodo Webhook. Dalla Console invia un evento di prova
webhook.test verso l'URL /webhook-test/blupantheon.
Ispeziona il Code node
Apri l'output di Verifica firma. Deve mostrare valid: true e l'oggetto event completo.
Attiva il workflow
Sposta l'interruttore Active. L'URL di produzione /webhook/blupantheon diventa operativo.
Aggiorna l'endpoint in Console.
Verifica end-to-end
Genera un handover reale. Controlla in Executions che la catena completi e in Console che la nota compaia nella sessione.