Blu Pantheon
Automatisierung & RPARPA-Plattformen

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.

CampoValore
Credential NameBlu Pantheon API
NameAuthorization
ValueBearer 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.

docker-compose.yml — estratto
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_7f3a9c21

Accesso 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ì:

ParametroValore
HTTP MethodPOST
Pathblupantheon
AuthenticationNone (l'autenticità si verifica con HMAC)
RespondUsing Respond to Webhook node
Options → Raw Bodyattivo
Options → Binary Propertydata
Options → Allowed Origins (CORS)vuoto

L'URL di produzione risultante è:

URL da registrare in Console

https://automation.acme.it/webhook/blupantheon

In 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.

Code node — Verifica firma
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?.

ParametroValore
Conditions → Boolean{{ $json.valid }}
Operationis true

Ramo falso — risposta di rifiuto

Collega un nodo Respond to Webhook rinominato Rifiuta.

ParametroValore
Respond WithJSON
Response Code{{ $json.status }}
Response Body{"error": "{{ $json.reason }}"}

Ramo vero — risposta immediata

Collega un nodo Respond to Webhook rinominato Accetta.

ParametroValore
Respond WithJSON
Response Code202
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 }}:

OutputValore
handoversession.handover.requested
bookingbooking.confirmed
clientclient.created
fallback(Fallback Output → extra)

Parte 5 — Chiamata alle API

Aggiungi un nodo HTTP Request rinominato Aggiungi nota sessione.

ParametroValore
MethodPOST
URLhttps://api.blupantheon.com/identity/v1/sessions/{{ $json.event.data.sessionId }}/notes
AuthenticationGeneric Credential Type
Generic Auth TypeHeader Auth
CredentialBlu Pantheon API
Send Headersattivo
Send Bodyattivo
Body Content TypeJSON
Options → Timeout15000
Options → Retry On Failattivo, Max Tries 3, Wait Between Tries 2000

Header aggiuntivi:

NameValue
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 }} fallito

e 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.

blupantheon-webhook.json
{
  "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

SituazioneComportamento
Firma non validaIl nodo Rifiuta risponde 401. Blu Pantheon ritenta ma continuerà a fallire: verifica il segreto.
Timestamp fuori tolleranzaRisposta 400. Controlla l'orologio del server n8n.
API Blu Pantheon in errore 5xxRetry On Fail ripete fino a 3 volte, poi il workflow fallisce e scatta l'Error Trigger.
Sistema a valle lentoLa risposta 202 è già stata inviata: nessun timeout lato Blu Pantheon.
DuplicatoAggiungi 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.

Passi successivi

Auf dieser Seite