EspoCRM-Webhooks in n8n: Ereignisname, Signatur und eine Phase, die es nicht gab
Teil 3 von 6 der Serie Die Follow-up-Maschine
- Überblick, Angebote nachfassen: eine Follow-up-Maschine, die sich meldet, wenn sie ausfällt
- Architektur, n8n-Workflow-Beispiel aus dem Echtbetrieb: eine Architektur, die Wiederholungen übersteht
- Registrieren, EspoCRM-Webhooks in n8n: Ereignisname, Signatur und eine Phase, die es nicht gab
- Auswerten, Ein stündlicher n8n-Workflow, der sich nicht selbst überholt: Sperren, Merge-Nodes und leere Läufe
- Anreichern, KI-Zusammenfassungen aus CRM-E-Mails mit n8n und Mistral: Prompt, Parser und das Komma
- Warnungen, n8n Error Workflows, die wirklich warnen: Deduplizierung, hängende Sperren und die Freitagsmail
Alle 6 Teile
- Überblick, Angebote nachfassen: eine Follow-up-Maschine, die sich meldet, wenn sie ausfällt
- Architektur, n8n-Workflow-Beispiel aus dem Echtbetrieb: eine Architektur, die Wiederholungen übersteht
- Registrieren, EspoCRM-Webhooks in n8n: Ereignisname, Signatur und eine Phase, die es nicht gab
- Auswerten, Ein stündlicher n8n-Workflow, der sich nicht selbst überholt: Sperren, Merge-Nodes und leere Läufe
- Anreichern, KI-Zusammenfassungen aus CRM-E-Mails mit n8n und Mistral: Prompt, Parser und das Komma
- Warnungen, n8n Error Workflows, die wirklich warnen: Deduplizierung, hängende Sperren und die Freitagsmail
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
Signaturemit dem HMAC als Hex-Text. Der ältere HeaderX-Signatureenthält die rohen Bytes.
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:
- 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.
- 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.
NODE_FUNCTION_ALLOW_BUILTIN=cryptoam Task Runner. Sonst scheitertrequire('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$envnicht 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''stattnullsenden, 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.