Prezentare generală Pentru cei pasionați de tehnică
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.
Deocamdată, acest articol există doar în germană.
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).
- Neues Ziel: Name, Adresse (nur
https://, Port 443, keine internen Adressen) und die Ereignisse anhaken. - Das Geheimnis (
whsec_…) wird genau einmal angezeigt. Im Zielsystem eintragen. Danach lässt sich nur ein neues erzeugen. - 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": { }
}
idgehört zum Ereignis. Bekommen mehrere Ziele dasselbe Ereignis, tragen alle dieselbeid, aber je eigenewebhook-id.versionändert sich nur, wenn sich die Felder eines Ereignisses unverträglich ändern. Neue Felder kommen ohne neue Version dazu.timestampist 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-idund denselben Text, nurwebhook-timestampund 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.