Pregled Za tehnički zainteresovane

Webhooks: Zapier, Make und n8n anbinden

Ereignisse aus dem CRM an andere Programme senden, Signatur prüfen und Kontakte von außen anlegen oder aktualisieren.

Ovaj članak zasad postoji samo na njemačkom.

Mit Webhooks meldet das CRM Ereignisse (neuer Kontakt, Angebot angenommen, Rechnung bezahlt, Einsatz erledigt) an Zapier, Make, n8n oder dein eigenes System. Umgekehrt können diese Programme Anfragen anlegen und Kontakte aktualisieren. Jede Nachricht ist nach dem Standard Standard Webhooks signiert, damit das Zielsystem prüfen kann, dass sie von uns kommt.

Den Vertrag zur Auftragsverarbeitung (AVV) schließt dein Betrieb selbst mit dem Dienst, den du anbindest. Zugangscodes, Schlüssel, Arbeitszeiten und Lohn werden nie gesendet.

Einrichten

Im CRM unter Workflows > Webhooks & Lead-Erfassung, Karte Ereignisse an andere Programme senden. Die Karte sieht nur, wer das Recht Schnittstellen und Webhooks hat (standardmäßig der Rang Inhaber).

  1. Neues Ziel: Name, Adresse (nur https://, Port 443, keine internen Adressen) und die Ereignisse anhaken.
  2. Das Geheimnis (whsec_…) wird genau einmal angezeigt. Im Zielsystem eintragen. Danach lässt sich nur ein neues erzeugen.
  3. Testereignis schickt sofort ein webhook.test.
Dienst Was du dort anlegst
Zapier Auslöser „Webhooks by Zapier“, Ereignis „Catch Hook“. Die angezeigte Adresse als Ziel eintragen.
Make Modul „Webhooks“, „Custom webhook“. Adresse kopieren.
n8n Knoten „Webhook“, Methode POST, die Production URL eintragen. Prüfen mit einem Code-Knoten (Beispiel unten).

Was wir senden

Eine POST-Anfrage mit JSON. Kopfzeilen:

Kopfzeile Inhalt
content-type application/json
user-agent Agency-Flow-Webhooks/1
webhook-id msg_…, gleich bei jeder Wiederholung derselben Nachricht
webhook-timestamp Unix-Zeit in Sekunden, Zeit dieses Versuchs
webhook-signature v1,<base64>

Umschlag (gleich für alle Ereignisse):

{
  "id": "evt_3f1c2a9b8e7d4c6a9f0b1c2d3e4f5a6b",
  "type": "contact.created",
  "version": 1,
  "timestamp": "2026-10-04T18:22:31.512Z",
  "account_id": "6f1a7c2e-0b8d-4d4e-9a31-5c2b8e9f0a11",
  "data": { }
}
  • id gehört zum Ereignis. Bekommen mehrere Ziele dasselbe Ereignis, tragen alle dieselbe id, aber je eigene webhook-id.
  • version ändert sich nur, wenn sich die Felder eines Ereignisses unverträglich ändern. Neue Felder kommen ohne neue Version dazu.
  • timestamp ist die Zeit des Ereignisses, nicht des Versands.
  • Die Reihenfolge der Schlüssel im JSON ist nicht festgelegt.

Ereignisse und Felder

Unter data stehen nur die hier genannten Felder (Erlaubt-Liste in der Datenbank, public.webhook_erlaubte_felder). Was fehlt, ist null.

Kontakte und Anfragen

contact.created: ein Kontakt wurde angelegt (von Hand, Import, Formular, Anruf). contact.updated: Name, Position, E-Mail, Telefon, Firma, Stichworte, Status oder Newsletter-Abmeldung haben sich geändert. Andere Änderungen (zum Beispiel der Lead-Score) lösen nichts aus. Gelöschte und anonymisierte Kontakte lösen nichts aus.

Feld Inhalt
id Nummer des Kontakts
first_name, last_name, title Name, Position
email, emails erste Adresse, alle Adressen
phone, phones erste Nummer, alle Nummern
company { "id", "name" } oder null
tags Namen der Stichworte
status Status im CRM
email_opt_out true, wenn keine Werbe-Mails erwünscht
changed nur bei contact.updated: geänderte Felder, z. B. ["first_name","phones"]
{
  "type": "contact.updated",
  "version": 1,
  "data": {
    "id": 4711,
    "first_name": "Anna",
    "last_name": "Meier",
    "title": "Hausverwaltung",
    "email": "anna@weber.de",
    "emails": ["anna@weber.de"],
    "phone": "+49 170 1234567",
    "phones": ["+49 170 1234567"],
    "company": { "id": 12, "name": "Praxis Dr. Weber" },
    "tags": ["Stammkunde"],
    "status": "warm",
    "email_opt_out": false,
    "changed": ["first_name"]
  }
}

inquiry.received: eine Anfrage kam über die Lead-Erfassung (Formular, Zapier, lead-radar, Anruf-Termin). Termine aus Buchungsseiten kommen als booking.created.

Feld Inhalt
id Nummer der Anfrage
contact_id Kontakt, an dem sie hängt
source Quelle des Endpunkts, z. B. website
first_name, last_name, email, phone wie eingegeben
message Nachricht, höchstens 5000 Zeichen
utm_source, utm_medium, utm_campaign, landing_url Herkunft
created_at Zeitpunkt

Nicht enthalten: IP-Adresse, Browser, Besucher- und Sitzungskennung.

Pipeline

deal.stage_changed: eine Karte kam in eine Pipeline oder wechselte die Stufe. Umsortieren innerhalb einer Stufe löst nichts aus.

Feld Inhalt
id Nummer der Karte
contact { "id", "first_name", "last_name" }
pipeline { "id", "name" }
stage, stage_label neue Stufe (Schlüssel, Name)
previous_stage, previous_stage_label alte Stufe, null bei neuer Karte
title, value Titel und Wert der Karte
won, lost, lost_reason gewonnen, verloren, Grund

Angebote und Rechnungen

quote.sent, quote.accepted, quote.rejected, invoice.created, invoice.paid. Belege, die beim Abgleich mit Lexware oder Qonto importiert wurden, lösen beim Anlegen nichts aus; wird ein solcher Beleg später bezahlt, kommt invoice.paid.

Feld Inhalt
id, number interne Nummer, Belegnummer
contact_id, company_id, deal_id Bezüge
customer { "name", "email", "address", "zip", "city", "country", "vat_id" }
currency, net, vat_rate, vat, gross Beträge
issue_date Belegdatum
valid_until nur Angebote
due_date nur Rechnungen
status Status im CRM
sent_at, accepted_at, rejected_at je nach Angebots-Ereignis
paid_at, amount_paid nur invoice.paid

Nicht enthalten: interne Notizen, Hinweistexte, PDF-Links.

{
  "type": "invoice.paid",
  "version": 1,
  "data": {
    "id": "8a0c…",
    "number": "RE-2026-0142",
    "contact_id": 4711,
    "company_id": 12,
    "deal_id": null,
    "customer": { "name": "Praxis Dr. Weber", "email": "buchhaltung@weber.de",
                  "address": "Rosenweg 12", "zip": "50667", "city": "Köln",
                  "country": "DE", "vat_id": null },
    "currency": "EUR", "net": 400, "vat_rate": 19, "vat": 76, "gross": 476,
    "issue_date": "2026-09-30", "due_date": "2026-10-14",
    "status": "paid", "paid_at": "2026-10-04T08:12:00Z", "amount_paid": 476
  }
}

Termine

booking.created, booking.cancelled.

Feld Inhalt
id, start_at, end_at, status Termin
booking_page { "id", "title" }
contact_id Kontakt, falls zugeordnet
guest { "name", "email", "phone", "address" }
notes, answers Nachricht und Antworten des Formulars (nur booking.created)
created_at nur booking.created
cancelled_at, cancel_reason nur booking.cancelled

Nicht enthalten: der Verwaltungslink des Gasts, Kalender-Kennungen.

Einsätze und Reklamationen

job.completed: ein Einsatz wurde auf erledigt gesetzt.

Feld Inhalt
id, title, start_at, end_at Einsatz
object { "id", "name", "street", "zip", "city" }
contact_id Auftraggeber des Objekts

Nicht enthalten: Mitarbeiter, Arbeitszeiten, Notizen, Zugangscodes, Schlüsselinfo, interne Objektnotiz.

complaint.created: eine Reklamation oder ein Problem am Objekt wurde gemeldet.

Feld Inhalt
id, title, description, priority, status, created_at Meldung
object { "id", "name" }

Nicht enthalten: wer gemeldet hat, Fotos.

Test

webhook.test mit data = { "message", "subscription": { "id", "name" } }. Kommt nur über den Knopf Testereignis.

Signatur prüfen

Signiert wird webhook-id + "." + webhook-timestamp + "." + body mit HMAC-SHA256. Schlüssel ist das Geheimnis ohne whsec_, base64-dekodiert. Die Kopfzeile kann mehrere Signaturen tragen (v1,… v1,…), eine passende reicht.

Wichtig: den Text so prüfen, wie er ankam. Wer erst JSON liest und neu schreibt, bekommt eine andere Zeichenfolge, und die Signatur passt nicht.

Den Zeitstempel ablehnen, wenn er mehr als 5 Minuten von der eigenen Uhr abweicht. Die webhook-id merken und eine zweite Nachricht mit derselben webhook-id nicht noch einmal verarbeiten, sondern mit 200 antworten.

Node.js (ohne Bibliothek)

import crypto from "node:crypto";
import express from "express";

const GEHEIMNIS = process.env.AGENCY_FLOW_WEBHOOK_SECRET; // whsec_…
const app = express();

// express.raw: den Text unverändert behalten
app.post("/agency-flow", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id");
  const ts = req.header("webhook-timestamp");
  const sigKopf = req.header("webhook-signature") ?? "";
  const body = req.body.toString("utf8");

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const schluessel = Buffer.from(GEHEIMNIS.replace(/^whsec_/, ""), "base64");
  const erwartet = crypto.createHmac("sha256", schluessel)
    .update(`${id}.${ts}.${body}`).digest("base64");
  const ok = sigKopf.split(" ").some((teil) => {
    const [version, sig] = teil.split(",");
    return version === "v1" && sig && sig.length === erwartet.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(erwartet));
  });
  if (!ok) return res.sendStatus(401);

  const ereignis = JSON.parse(body);
  // … verarbeiten, webhook-id merken …
  res.sendStatus(204);
});

Mit der Bibliothek des Standards: npm install standardwebhooks, dann new Webhook(GEHEIMNIS).verify(body, req.headers).

Python (ohne Bibliothek)

import base64, hashlib, hmac, time
from flask import Flask, request, abort

GEHEIMNIS = "whsec_..."
app = Flask(__name__)

@app.post("/agency-flow")
def empfangen():
    msg_id = request.headers["webhook-id"]
    ts = request.headers["webhook-timestamp"]
    sig_kopf = request.headers.get("webhook-signature", "")
    body = request.get_data(as_text=True)  # unverändert

    if abs(time.time() - int(ts)) > 300:
        abort(400)
    schluessel = base64.b64decode(GEHEIMNIS.removeprefix("whsec_"))
    erwartet = base64.b64encode(
        hmac.new(schluessel, f"{msg_id}.{ts}.{body}".encode(), hashlib.sha256).digest()
    ).decode()
    if not any(
        teil.startswith("v1,") and hmac.compare_digest(teil[3:], erwartet)
        for teil in sig_kopf.split(" ")
    ):
        abort(401)
    # … verarbeiten, msg_id merken …
    return "", 204

Mit der Bibliothek: pip install standardwebhooks, dann Webhook(GEHEIMNIS).verify(body, dict(request.headers)).

n8n (Code-Knoten)

Im Webhook-Knoten unter Optionen „Raw Body“ einschalten, dann im Code-Knoten:

const crypto = require("crypto");
const h = $json.headers;
const body = Buffer.from($binary.data.data, "base64").toString("utf8");
const key = Buffer.from($env.AF_SECRET.replace(/^whsec_/, ""), "base64");
const soll = crypto.createHmac("sha256", key)
  .update(`${h["webhook-id"]}.${h["webhook-timestamp"]}.${body}`).digest("base64");
if (!h["webhook-signature"].split(" ").includes(`v1,${soll}`)) throw new Error("Signatur falsch");
return [{ json: JSON.parse(body) }];

Zustellung, Wiederholung, Pause

  • Ereignisse gehen innerhalb einer Minute raus (Warteschlange, Cron jede Minute, nur wenn etwas wartet).
  • Erfolg ist jede Antwort 200 bis 299. Alles andere ist ein Fehlschlag: 3xx (Weiterleitungen werden nicht verfolgt), 4xx, 5xx, Zeitlimit von 8 Sekunden, keine Verbindung.
  • Wiederholung nach 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden und 12 Stunden. Nach dem 6. Versuch gilt die Nachricht als gescheitert. Jede Wiederholung trägt dieselbe webhook-id und denselben Text, nur webhook-timestamp und Signatur sind neu.
  • retry-after (Sekunden oder Datum) bei 429 oder 503 verlängert die Wartezeit, höchstens auf 12 Stunden.
  • 410 Gone heißt: das Ziel will nichts mehr. Das Ziel wird sofort pausiert.
  • Nach 10 Fehlversuchen in Folge pausiert das Ziel automatisch. Inhaber und Admins bekommen eine Glocke. Wieder einschalten setzt den Zähler auf 0; wartende Nachrichten gehen dann raus.
  • Erneut senden im Protokoll schickt eine Nachricht noch einmal, mit derselben webhook-id.
  • Antworte schnell (unter 8 Sekunden) und verarbeite lange Arbeiten im Hintergrund.

Das Protokoll im CRM zeigt je Nachricht Status, Antwortcode, Dauer, die ersten 1000 Zeichen der Antwort und den gesendeten Inhalt. Einträge bleiben 30 Tage.

Webhooks an Agency-Flow senden

Endpunkte unter Webhooks & Lead-Erfassung, Karte der Lead-Erfassung. Adresse: POST https://kqypndusxumtaggmidib.supabase.co/functions/v1/lead-webhook?token=<token> (oder Kopfzeile x-lead-token). Höchstens 100 KB, 60 Aufrufe je Minute und Absender.

Signatur: neue Endpunkte prüfen standardmäßig die Signatur nach demselben Standard (eigenes Geheimnis je Endpunkt, Toleranz 5 Minuten). Eine webhook-id, die der Endpunkt schon verarbeitet hat, wird nicht noch einmal verarbeitet; die Antwort ist 200 mit "duplicate": true. Für ein Formular direkt auf einer Website die Signatur ausschalten, denn ein Browser kann kein Geheimnis schützen. Vorhandene Endpunkte bleiben, wie sie waren.

Abgelehnte Aufrufe stehen unter Aufrufe mit Grund (Signatur fehlt, stimmt nicht, Zeitstempel zu alt) und ohne Inhalt.

Kontakt oder Anfrage anlegen (ohne action)

Feld Pflicht Inhalt
email oder phone eins davon Schlüssel; gibt es den Kontakt schon (E-Mail ohne Groß- und Kleinschreibung, Nummer als +49…), wird keiner neu angelegt
first_name, last_name oder name nein Name
company nein Firmenname, wird gesucht oder angelegt
message nein Nachricht der Anfrage
utm_source, utm_medium, utm_campaign, landing_url nein Herkunft

Weitere Felder lassen sich im CRM über Field-Mapping zuordnen. Antwort: 200 { "ok": true, "intake_id", "contact_id" }.

Kontakt aktualisieren ("action": "contact.update")

Nur für Endpunkte mit Signatur. Legt nie einen Kontakt an.

{
  "action": "contact.update",
  "email": "anna@weber.de",
  "last_name": "Schulz",
  "add_phone": "+49 221 998877",
  "note": "Neue Durchwahl laut Telefonat"
}
Feld Inhalt
email Schlüssel; zuerst gesucht
phone Schlüssel, wenn keine E-Mail passt
first_name, last_name, title ersetzen den Wert
add_email, add_phone werden ergänzt, wenn noch nicht vorhanden
note wird als Notiz am Kontakt angelegt

Alle anderen Felder werden ignoriert. Antworten: 200 { "ok": true, "contact_id", "changed": [...] }, 404 { "error": "kontakt_nicht_gefunden" }, 403, wenn der Endpunkt keine Signatur verlangt.

Grenzen und Datenschutz

Grenze Wert
Ziele je Konto 10
Nachrichten je Ziel und Stunde 1000, darüber verworfen (im Protokoll sichtbar)
Größe einer Nachricht 64 KB
Testereignisse je Ziel und Stunde 20
Zeitlimit 8 Sekunden
Aufbewahrung des Protokolls 30 Tage
  • Recht: einrichten, sehen und testen nur mit Schnittstellen und Webhooks (Rang Inhaber; Admins, Organisation und Agentur nach der üblichen Regel).
  • Ziele: nur https://, Port 443, keine Zugangsdaten in der Adresse. Vor jedem Versand wird der Name aufgelöst; zeigt er auf eine private, lokale, Link-Local- oder sonst nicht öffentliche Adresse (IPv4 und IPv6), wird nicht gesendet. Restrisiko: ändert sich DNS genau zwischen Prüfung und Verbindung (DNS rebinding), bleibt eine Lücke von Millisekunden. Die Zustellung läuft in einer Edge Function außerhalb des Datenbanknetzes.
  • Geheimnisse liegen verschlüsselt im Supabase Vault, nie im Klartext in Tabellen oder Logs. Angezeigt wird ein Geheimnis nur beim Anlegen oder Erneuern.
  • Nie gesendet: Passwörter, Tokens, API-Schlüssel, Zugangscodes und Schlüsselinfos von Objekten, Arbeitszeiten, Lohn, Personalakten, Abwesenheiten und Krankmeldungen.
  • AVV: Mit einem Ziel gehen Kundendaten an den Dienst, den der Betrieb wählt, bei Zapier und Make in die USA. Den Vertrag zur Auftragsverarbeitung schließt der Betrieb selbst mit dem Dienst. n8n lässt sich in der EU selbst betreiben.