Nach jedem Anruf schickt Safina die Zusammenfassung an Deine Webhook-URL. Dein CRM, Dein n8n-Workflow oder Dein Zap macht daraus einen Lead oder eine Aufgabe. Eine Webhook-URL ist aber nur eine Adresse. Wer sie kennt, kann ein gefälschtes „Anruf beendet“-Ereignis an sie schicken.
Eine Signatur löst das Problem. Safina signiert jeden Request mit einem Secret, das nur Du und Safina kennen. Dein Empfänger prüft die Signatur und weiß dann zweierlei: Safina hat den Request gesendet, und niemand hat den Inhalt unterwegs verändert. Ein Zeitstempel in der Signatur verhindert außerdem, dass jemand einen alten Request noch einmal schickt.
Neu bei Webhooks? Lies zuerst Was ist ein Webhook?. Die Einrichtung in der App steht im Leitfaden Safina AI per Webhook mit CRM verknüpfen.
Das brauchst Du
| Voraussetzung | Details |
|---|---|
| Safina-Plan | Pro oder höher |
| Ein Webhook in der Safina-App | Mit Signatur-Secret (siehe unten) |
| Ein Empfänger | Dein eigener Server, n8n, Zapier oder Make |
Webhooks legst Du in der Safina-App an und signierst sie dort. Du kannst sie auch über die Safina REST-API anlegen (POST /webhooks). Die REST-API und API-Schlüssel sind in der Beta: Frühen Zugang bekommst Du auf Anfrage an info@safina.ai, und API-Schlüssel brauchen den Pro-Plan oder höher.
Schritt 1: Signatur einschalten
Die Signatur ist optional. Safina signiert die Requests eines Webhooks nur, wenn er ein Signatur-Secret hat. Ein Secret bekommst Du auf drei Wegen:
- Neuer Webhook in der App: Öffne in der Safina-App den Tab „Safina AI“, dann „CRM synchronisieren“, und füge einen Webhook hinzu. Schalte vor dem Speichern Zustellungen signieren ein.
- Bestehender Webhook in der App: Tippe beim Webhook auf Secret erzeugen. Webhooks von vor dieser Funktion bleiben unsigniert, bis Du das tust.
- REST-API (Beta): Sende
"create_signing_secret": truemitPOST /webhooks. Die Antwort enthältsigning_secret.
Das Secret beginnt mit whsec_. Safina zeigt es nur einmal. Kopiere es und hinterlege es in Deinem Empfänger, wie ein Passwort.
Ein Webhook ohne Secret funktioniert weiter und sendet wie bisher unsignierte Requests. Du kannst auch die Authentifizierung des Webhooks nutzen (auth_type): Safina sendet dann mit jedem Request ein Bearer-Token oder einen API-Schlüssel im Header. Das beweist, dass der Absender das Token kennt. Eine Signatur beweist zusätzlich, dass der Inhalt unverändert und der Request neu ist.
Schritt 2: Den Header verstehen
Jeder signierte Request hat diesen Header:
X-Safina-Signature: t=1794569912,v1=5f2c…e91a
tist die Zeit des Zustellversuchs in Unix-Sekunden.v1ist der HMAC-SHA256 des Texts<t>.<Raw-Body>, mit Deinem Signatur-Secret als Schlüssel, in Hex.
Dein Empfänger prüft drei Dinge:
- Zeit: Lehne den Request ab, wenn
tmehr als 300 Sekunden (5 Minuten) vor oder nach Deiner Uhr liegt. - Signatur: Berechne den HMAC über den Raw-Body des Requests, genau so, wie er angekommen ist, bevor Du das JSON parst. Wenn Du das JSON parst und neu schreibst, können sich Leerzeichen oder die Reihenfolge der Schlüssel ändern, und die Signatur passt nicht mehr.
- Vergleich: Vergleiche Deinen Wert mit jedem
v1-Wert in konstanter Zeit. Ein Treffer reicht.
Warum kann es mehr als ein v1 geben? Wenn Du auf Neues Secret erzeugen tippst, bleibt das alte Secret 24 Stunden gültig. In dieser Zeit hat der Header einen v1-Wert für jedes gültige Secret. So kannst Du Deinen Empfänger umstellen, ohne Ereignisse zu verlieren.
Jeder Zustellversuch hat einen neuen Zeitstempel und eine neue Signatur. Eine Wiederholung sendet dieselbe event_id. Überspringe also eine event_id, die Du schon verarbeitet hast.
Schritt 3: Die Signatur im Code prüfen
Diese Beispiele sind dieselben wie in der API-Referenz (auf Englisch). Wir haben beide mit echten Signaturen aus dem Signatur-Code von Safina getestet.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a Buffer or a string)
const verifySafinaSignature = (header, rawBody, secret) => {
const parts = header.split(",").map((part) => part.trim().split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = Buffer.from(
createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex")
);
return parts.some(([key, value]) => {
const candidate = Buffer.from(value ?? "");
return (
key === "v1" &&
candidate.length === expected.length &&
timingSafeEqual(candidate, expected)
);
});
};
Mit Express liest Du den Body auf dieser Route als Rohdaten, zum Beispiel mit express.raw({ type: "application/json" }). Dann ist req.body ein Buffer. Parse das JSON erst nach der Prüfung:
app.post("/safina-webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Safina-Signature") ?? "";
if (!verifySafinaSignature(header, req.body, process.env.SAFINA_SIGNING_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// … process the event
res.status(200).end();
});
Python
import hashlib
import hmac
import time
# raw_body: the request body exactly as received, as bytes
def verify_safina_signature(header: str, raw_body: bytes, secret: str) -> bool:
parts = [part.strip().split("=", 1) for part in header.split(",")]
values = [(part[0], part[1]) for part in parts if len(part) == 2]
timestamp = next((value for key, value in values if key == "t"), "")
if not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return any(
key == "v1" and hmac.compare_digest(value.encode(), expected.encode())
for key, value in values
)
In Flask bekommst Du die Rohdaten mit request.get_data(), in FastAPI mit await request.body().
Die Signatur in Automatisierungstools prüfen
n8n
n8n prüft die Signatur mit einem Code-Node. Sein JavaScript hat das Node.js-Modul crypto: In n8n Cloud ist es verfügbar, in einem selbst gehosteten n8n erlaubst Du es mit der Umgebungsvariable NODE_FUNCTION_ALLOW_BUILTIN=crypto (n8n-Doku).
- Wähle im Webhook-Node POST und schalte die Option Raw Body ein. n8n behält den Body dann als Binärdaten. Den Namen des Binärfelds siehst Du in der Ausgabe des Nodes (meist
data). - Füge danach einen Code-Node im Modus „Run Once for All Items“ mit diesem Code hinzu:
const crypto = require("crypto");
const secret = "whsec_…"; // your signing secret
const header = $input.first().json.headers["x-safina-signature"] ?? "";
const rawBody = await this.helpers.getBinaryDataBuffer(0, "data");
const parts = header.split(",").map((part) => part.trim().split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")
);
const valid =
Number.isInteger(timestamp) &&
Math.abs(Date.now() / 1000 - timestamp) <= 300 &&
parts.some(
([key, value]) =>
key === "v1" &&
value?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(value), expected)
);
if (!valid) {
throw new Error("The Safina signature is not valid");
}
return [{ json: JSON.parse(rawBody.toString("utf8")) }];
Ist die Signatur falsch, stoppt der Workflow. Ist sie richtig, bekommen die nächsten Nodes das geparste Ereignis. Der Crypto-Node von n8n kann auch einen HMAC berechnen (Aktion Hmac, Typ SHA256, Encoding HEX). Dafür musst Du aber zuerst den Text <t>.<Raw-Body> bauen und die Werte danach in einem IF-Node vergleichen. Der Code-Node erledigt alles in einem Schritt.
Zapier
Nutze in Zapier den Trigger Webhooks by Zapier → Catch Raw Hook. Anders als Catch Hook liefert er den ungeparsten Body und die Request-Header (Zapier-Hilfe). Webhooks by Zapier braucht den Professional-Plan oder höher.
- Füge einen Schritt Code by Zapier (Run JavaScript) hinzu. Ordne unter Input Data
rawBodydem Raw-Body des Triggers undsignaturedem HeaderX-Safina-Signaturezu. - Füge diesen Code ein. Die Standardbibliothek von Node.js und damit
cryptoist in Code by Zapier verfügbar (Zapier-Hilfe):
const crypto = require("crypto");
const secret = "whsec_…"; // your signing secret
const parts = inputData.signature.split(",").map((part) => part.trim().split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(inputData.rawBody).digest("hex")
);
const valid =
Number.isInteger(timestamp) &&
Math.abs(Date.now() / 1000 - timestamp) <= 300 &&
parts.some(
([key, value]) =>
key === "v1" &&
value?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(value), expected)
);
output = { valid };
- Füge einen Filter-Schritt hinzu: Nur weitermachen, wenn
validtrue ist.
Das Secret steht im Code des Schritts. Jeder, der den Zap bearbeiten kann, sieht es also. Catch Raw Hook zerlegt das JSON nicht in Felder. Wenn Du in späteren Schritten einzelne Felder zuordnen willst, parse den Body im selben Code-Schritt und gib die Felder zurück, die Du brauchst.
Make
Make kann die Prüfung mit eingebauten Funktionen machen. Die Funktion sha256(text; [encoding]; [key]) liefert einen HMAC, wenn Du ihr einen Schlüssel gibst (Make-Hilfe). Schalte im Modul Custom webhook JSON pass-through ein (Du bekommst das Original-JSON als Text) und unter den erweiterten Einstellungen Get request headers (Make-Hilfe). Füge dann nach dem Webhook einen Filter mit zwei Bedingungen hinzu:
taus dem Header liegt höchstens 300 Sekunden vonformatDate(now; "X")(Unix-Sekunden) entfernt.- Der Header
X-Safina-Signatureenthältv1=gefolgt vonsha256(<t>.<Raw-Body>; hex; <Dein Secret>).
Das funktioniert, aber der Filter vergleicht nicht in konstanter Zeit, und Du musst t mit split und get aus dem Header holen. Für die meisten Make-Szenarien ist die Authentifizierung des Webhooks einfacher: Make kann einen Custom Webhook mit einem API-Schlüssel schützen, den es im Header x-make-apikey erwartet (Make-Hilfe). Wähle in Safina die Authentifizierung „API-Schlüssel“, als Ort „Header“ und als Header-Name x-make-apikey, und trage denselben Schlüssel ein.
Häufige Fehler
- Die Signatur passt nie: Dein Framework hat das JSON vor Deiner Prüfung geparst. Nimm den Raw-Body.
- Die Signatur passt, aber die Zeitprüfung schlägt fehl: Die Uhr Deines Servers geht falsch. Synchronisiere sie per NTP.
- Nach einem neuen Secret geht nichts mehr: Das alte Secret gilt nur 24 Stunden. Hinterlege das neue Secret in Deinem Empfänger.
- Dasselbe Ereignis kommt zweimal: Das ist eine Wiederholung nach einem Timeout oder einem Fehler. Überspringe eine
event_id, die Du schon verarbeitet hast.
Fazit
Schalte für jeden Webhook Zustellungen signieren ein, hinterlege das Secret einmal und prüfe X-Safina-Signature, bevor Du ein Ereignis verarbeitest: Zeit, Raw-Body, Vergleich in konstanter Zeit. Dann erreicht kein gefälschter Request Dein CRM. Fragen? Schreib uns an info@safina.ai.