Zum Inhalt springen

This page is available in English. Switch to English

Arbeit, die jede Woche wiederkommt? Muss kein Mensch machen. Mehr zu KI und Automatisierung

Automatisierung

EspoCRM-Webhooks in n8n: Ereignisname, Signatur und eine Phase, die es nicht gab

Teil 3 von 6 der Serie Die Follow-up-Maschine

Ein gebürsteter Metallblock schwebt über einer Metallplatte mit passender Vertiefung, Licht fällt durch den Spalt.

Das ist Teil 3 der Serie zur Follow-up-Maschine. Teil 2 beschreibt die Architektur. Dieser Teil behandelt „Followup 1: Register“, den Workflow, der einen Deal zu beobachten beginnt, sobald er in EspoCRM in die Phase Angebot oder Verhandlung wechselt.

Mit 16 Nodes ist er der kleinste der fünf Workflows. Gemessen an seiner Größe hat er die meisten Fehler produziert.

Der Aufbau

Webhook
  → Inject secret (Set)
  → Verify signature (Code)
  → Valid? ──nein──→ Respond 401
       └─ja──→ Respond 200
                 → Split batch
                 → Filter stage
                 → Fetch deal (HTTP) ──Fehler──→ leise beenden
                 → Load cadence (Postgres)
                 → Inject deal fields (Set)
                 → Compute due dates (Code)
                 → Upsert watch + steps (Postgres)
                 → Log run (Postgres)

Zwei Entscheidungen prägen alles nach der Signaturprüfung. Der Workflow antwortet EspoCRM, bevor er irgendetwas tut. Und er verkraftet es, wenn das Ereignis ganz verloren geht, weil der stündliche Abgleich aus Teil 4 den Deal ohnehin registriert.

Fehler 1: ein akzeptierter Ereignisname, bei dem nie ein Webhook ausgelöst wird

Die erste Version abonnierte Opportunity.update.stage. EspoCRM akzeptierte das, speicherte es und zeigte es in der Webhook-Liste an. Dann kam nichts. Zwei Phasenwechsel im CRM, null Zustellungen, während der n8n-Endpunkt Testanfragen tadellos beantwortete.

Die Syntax für ein feldbezogenes Ereignis in EspoCRM lautet {Entity}.fieldUpdate.{field}:

Opportunity.fieldUpdate.stage

Ein schlichtes Opportunity.update funktioniert auch, weil der Workflow ohnehin nach Phase filtert. Was nicht funktioniert, ist ein plausibel klingender Name dazwischen, und nichts weist darauf hin.

Fehler 2: das Webhook-Secret selbst wählen

Laut meiner Einrichtungsnotiz sollte man ein Secret wählen und in EspoCRM eintragen. Tatsächlich läuft es umgekehrt. EspoCRM erzeugt für jeden Webhook einen eigenen geheimen Signaturschlüssel und signiert jede Zustellung damit. Dieses Webhook-Secret kopiert man aus der Detailansicht des Webhooks nach n8n. Ein selbst ausgedachter Wert passt nie, und jede Anfrage wird mit HTTP 401 abgewiesen.

Fehler 3: das Signaturformat

Die erste Prüfung berechnete einen Base64-HMAC über den Body und verglich ihn mit dem Header. Die erste echte Zustellung ist daran gescheitert. Das tatsächliche Format sieht anders aus:

  • Der Header-Wert ist Base64-kodiert.
  • Dekodiert steht darin die Webhook-ID, ein Doppelpunkt und dann der HMAC-SHA256 des unveränderten Bodys.
  • Neuere EspoCRM-Versionen senden einen Header Signature mit dem HMAC als Hex-Text. Der ältere Header X-Signature enthält die rohen Bytes.
Was steckt im Signatur-Header?D4 / FOLLOW-UP MACHINEWas steckt im Signatur-Header?Zuerst die Hülle dekodieren. Den Digest mit den exakten Body-Bytes prüfen. Watchful Loop: Register Header-Wert: Base64-kodiertSignature / X-SignatureBase64 dekodierenWebhook-ID:0x3aHMAC-SHA256Digest des unveränderten Bodyswird bei dieser Prüfung ignorierthier kein Sicherheitsmerkmalerster Doppelpunkt; nie Teil der Webhook-IDSignatureneuerer HeaderDigest als Hex-TextX-Signatureälterer HeaderDigest als rohe BytesUnveränderte Body-BytesWebhook: Raw Body ANSet: Binärdaten durchreichen ANDie Prüfung akzeptiert beide Digest-Formate. Base64-Dekodierung allein prüft keine Signatur.
Nach dem Base64-Dekodieren folgen Webhook-ID, erster Doppelpunkt und Digest. Der Digest wird anhand der exakten Body-Bytes geprüft; Hex-Text und rohe Bytes werden akzeptiert.

Die Prüfung, die im Echtbetrieb läuft, akzeptiert beides:

const crypto = require('crypto');
const item = $input.first();
const secret = item.json.webhook_secret;
const raw = Buffer.from(item.binary.data.data, 'base64'); // unveränderter Body aus dem Webhook-Node

const invalid = () => [{ json: { signature_valid: false } }];
const header = item.json.headers['signature'] ?? item.json.headers['x-signature'] ?? '';
const decoded = Buffer.from(String(header), 'base64');
const sep = decoded.indexOf(0x3a); // erster ':' (Webhook-IDs enthalten keinen)
if (sep < 0) return invalid();
const provided = decoded.subarray(sep + 1);

const hmac = crypto.createHmac('sha256', secret).update(raw);
const rawDigest = hmac.digest();
const hexDigest = Buffer.from(rawDigest.toString('hex'));
const matches = (a, b) => a.length === b.length && crypto.timingSafeEqual(a, b);

if (!matches(provided, hexDigest) && !matches(provided, rawDigest)) return invalid();
return $input.all();

Die Webhook-ID vor dem Doppelpunkt wird abgetrennt und ignoriert. Sicherheitsrelevant ist sie nicht, denn der HMAC allein bindet Body und Webhook-Secret aneinander. Der Vergleich nutzt timingSafeEqual und prüft vorher die Länge, weil timingSafeEqual bei unterschiedlich langen Buffern einen Fehler wirft.

Drei Einstellungen braucht dieser Node, um überhaupt zu funktionieren:

  1. Raw Body AN im Webhook-Node. Der HMAC gilt für die exakten Bytes, die EspoCRM gesendet hat, nicht für das JSON, das n8n daraus macht.
  2. Binärdaten durchreichen AN im Set-Node davor. Ein Set-Node verwirft Binärdaten standardmäßig, und der unveränderte Body liegt als Binärdaten vor.
  3. NODE_FUNCTION_ALLOW_BUILTIN=crypto am Task Runner. Sonst scheitert require('crypto') im Code-Node.

n8n 2.x und $env. Das Webhook-Secret selbst kommt über diesen Set-Node aus $env, weil Code-Nodes in n8n 2.x in einem Task Runner laufen, der $env nicht lesen kann (siehe Teil 2).

Eine ungültige Signatur führt zu einem Respond-to-Webhook-Node mit Status 401. Eine Warnung löst sie nicht aus.

Alles, was öffentlich im Internet steht, wird gescannt, und wer bei Scannern alarmiert, gewöhnt sich an, Alarme zu ignorieren.

Erst antworten, dann arbeiten

Bei gültiger Signatur antwortet der Workflow mit HTTP 200, bevor er irgendetwas anderes tut. EspoCRM wiederholt Zustellungen, die zu langsam beantwortet werden. Ein Workflow, der erst in die Datenbank schreibt und dann antwortet, erzeugt seine doppelten Zustellungen also selbst.

Das hat einen Preis. Ist Postgres gerade ausgefallen, geht das Ereignis verloren, weil EspoCRM die Bestätigung bereits erhalten hat und nicht erneut sendet. Das ist bewusst in Kauf genommen: Der Abgleich aus Teil 4 findet jeden relevanten Deal ohne Beobachtung innerhalb einer Stunde.

Die Webhook-Nutzdaten enthalten nicht den vollständigen Deal

Ein Feld-Webhook enthält die Datensatz-ID und die geänderten Attribute. Meistens also id und stage, aber weder Namen noch Firma. Deshalb lädt der Workflow nach dem Phasenfilter den Deal:

GET {ESPO_BASE_URL}/api/v1/Opportunity/{id}?select=name,stage,accountName

Der Node ist auf drei Versuche eingestellt, also einen Aufruf und zwei Wiederholungen. Scheitern alle drei, wird die Verarbeitung dieses Eintrags ohne Warnung beendet. Der Abgleich holt den Deal später nach.

Ein Detail brauchte einen Umbau der Node-Kette. Die erste Version führte den geladenen Deal mit dem in Postgres hinterlegten Nachfassrhythmus über einen Merge-Node zusammen, nach Position. Für einen Deal passt das. Scheitert in einer Zustellung mit mehreren Deals eine einzige Abfrage, verrutschen die Positionen, und Deal N bekommt den Namen von Deal N+1. Die Lösung: kein Merge nach Position, sondern jeden Eintrag in einem Set-Node über Referenzen auf benannte Nodes aufbauen:

name        = {{ $('Fetch deal').item.json.name }}
cadence     = {{ $('Filter stage').item.json.cadence }}

So bleiben zusammengehörige Einträge zusammen, auch wenn einzelne scheitern.

Fehler 4: eine Phase, die es nicht gibt

Der Phasenfilter ließ Deals in Proposal/Price Quote oder Negotiation durch. Proposal/Price Quote ist der Phasenname in SugarCRM und SuiteCRM. Eine Standardinstallation von EspoCRM nennt die Phase Proposal.

Jeder Deal, der in die Angebotsphase wechselte, also genau der Fall, für den die Maschine gebaut ist, fiel damit durch den Filter. Dieselbe Liste speiste die Abfrage für den Abgleich, also übersah auch der Abgleich diese Deals. Nichts schlug fehl. Jeder Lauf war grün.

Fünf Tage lang ist das beim Testen nicht aufgefallen, weil der Testdeal in der Phase Verhandlung stand, und die war richtig geschrieben. Die Korrektur akzeptiert beide Schreibweisen:

// Standard in EspoCRM ist 'Proposal'. 'Proposal/Price Quote' ist die Schreibweise von SugarCRM/SuiteCRM.
export const STAGES_IN_SCOPE = ['Proposal', 'Proposal/Price Quote', 'Negotiation'] as const;

Die Lehre gilt nicht nur für EspoCRM.

Ein falsch konfigurierter Filter verwirft relevante Einträge, ohne einen Fehler zu erzeugen, denn Weglassen ist seine Aufgabe.

Testen Sie jeden Wert, den ein Filter durchlassen soll, nicht nur einen davon.

Der Postgres-Node und seine Parameterkette

Die letzten beiden Nodes schreiben nach Postgres, und der Postgres-Node von n8n hat bei den Abfrageparametern eine Falle. Die Parameter sind ein einziger Text aus kommagetrennten Ausdrücken, und diesen Text zerlegt der Node selbst.

Im Register-Workflow führte das zu zwei Fehlern:

Literale außerhalb von Ausdrücken werden verworfen. Eine Parameterliste wie ='wf1', {{ $json.startedAt }}, ... verliert das 'wf1', weil nur {{ }}-Abschnitte zu Parametern werden. Alle folgenden Parameter rutschen eine Stelle nach links. Die Fehlermeldung lautete invalid input syntax for timestamptz: 3: Der Zeitstempel-Parameter hatte die Anzahl der Einträge bekommen. Die Lösung ist, auch Literale in einen Ausdruck mit doppelten geschweiften Klammern zu setzen, also {{ 'wf1' }}.

Aus null wird der Text 'null'. Ein JavaScript-null in einem Ausdruck wird zum Text null, und dieser Text landet in der Spalte. Register hat ihn in die Beobachtung geschrieben, sichtbar wurde er später in Evaluate: Die erste Aufgabenbeschreibung im Dry-Run lautete „TEST Followup machine (null)“. Die Lösung ist, für fehlende Werte einen leeren Text zu senden und ihn in SQL mit nullif(..., '') wieder zu einem echten NULL zu machen.

Die Parameterkette des Postgres-Nodes. Literale in {{ }} setzen, für fehlende Werte '' statt null senden, Freitext als ein JSON-Objekt übergeben.

Eine dritte Falle liegt an derselben Stelle: Kommas innerhalb der Werte. Sie hat zuerst den KI-Schritt erwischt und steht deshalb in Teil 5. Der Insert für Beobachtungen übergibt seine Werte inzwischen als ein einziges JSON-Objekt und entpackt sie in SQL. Das umgeht alle drei Fallen:

with p as (select $1::jsonb as j)
insert into watches (subject_type, subject_id, subject_url, subject_name, account_name, context, cadence)
select j ->> 'subject_type', j ->> 'subject_id', j ->> 'subject_url', j ->> 'subject_name',
       nullif(j ->> 'account_name', ''), nullif(j ->> 'context', ''), j ->> 'cadence'
from p
on conflict (subject_type, subject_id) where status = 'open' do nothing
returning id

Was man aus diesem Teil mitnehmen kann

  • Feldereignisse heißen in EspoCRM Entity.fieldUpdate.field. Ein falscher Name wird angenommen und bleibt stumm.
  • EspoCRM erzeugt das Webhook-Secret. Kopieren, nicht wählen.
  • Den unveränderten Body prüfen, beide Signatur-Header akzeptieren, zeitkonstant vergleichen.
  • Erst antworten, dann verarbeiten, und den Verlust eines Ereignisses verkraftbar machen.
  • Nie nach Position zusammenführen, wenn einzelne Einträge scheitern können.
  • Jeden Wert testen, den ein Filter durchlassen soll.

Weiter: Teil 4, Evaluate.