After each call, Safina sends the call summary to your webhook URL. Your CRM, your n8n workflow or your Zap then creates a lead or a task from it. But a webhook URL is just an address. Anyone who knows it can send a fake “call ended” event to it.
A signature solves this. Safina signs each request with a secret that only you and Safina know. Your receiver checks the signature and knows two things: Safina sent the request, and nobody changed the content on the way. A timestamp in the signature also stops someone from sending an old request again.
New to webhooks? Read What Is a Webhook? first. The setup in the app is in Connect Safina AI to Your CRM via Webhook.
What you need
| Requirement | Details |
|---|---|
| Safina plan | Pro or higher |
| A webhook in the Safina app | With a signing secret (see below) |
| A receiver | Your own server, n8n, Zapier or Make |
You create and sign webhooks in the Safina app. You can also create them with the Safina REST API (POST /webhooks). The REST API and API keys are in beta: early access is available on request at info@safina.ai, and API keys need the Pro plan or higher.
Step 1: Turn on signing
Signing is optional. Safina signs the requests of a webhook only when it has a signing secret. You get one in three ways:
- New webhook in the app: In the Safina app, open the “Safina AI” tab, then “CRM Synchronization”, and add a webhook. Turn on Sign deliveries before you save.
- Existing webhook in the app: Tap Create secret on the webhook. Webhooks from before this feature stay unsigned until you do this.
- REST API (beta): Send
"create_signing_secret": truewithPOST /webhooks. The answer holdssigning_secret.
The secret starts with whsec_. Safina shows it only once. Copy it and store it in your receiver, like a password.
A webhook without a secret keeps working and sends unsigned requests, as before. You can also use the authentication of the webhook (auth_type): Safina then sends a bearer token or an API key header with each request. That proves that the sender knows the token. A signature also proves that the content was not changed and that the request is new.
Step 2: Understand the header
Each signed request has this header:
X-Safina-Signature: t=1794569912,v1=5f2c…e91a
tis the time of the delivery attempt, in Unix seconds.v1is the HMAC-SHA256 of the text<t>.<raw body>, with your signing secret as the key, in hex.
Your receiver checks three things:
- Time: Reject the request if
tis more than 300 seconds (5 minutes) before or after your clock. - Signature: Calculate the HMAC over the raw request body exactly as it arrived, before you parse the JSON. Parsing and writing the JSON again can change spaces or the order of keys, and then the signature no longer matches.
- Comparison: Compare your value with each
v1value in constant time. One match is sufficient.
Why can there be more than one v1? When you tap Create new secret, the old secret stays valid for 24 hours. During that time, the header has one v1 value for each valid secret. So you can update your receiver without losing events.
Each delivery attempt has a new timestamp and a new signature. A retry sends the same event_id, so skip an event_id that you already processed.
Step 3: Check the signature in your code
These examples are the same as in the API reference. We tested both against real signatures from Safina’s signing code.
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)
);
});
};
With Express, read the body as raw bytes on this route, for example with express.raw({ type: "application/json" }). Then req.body is a Buffer. Parse the JSON only after the check:
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, request.get_data() gives you the raw bytes. In FastAPI, use await request.body().
Check the signature in automation tools
n8n
n8n can check the signature with a Code node. Its JavaScript has the Node.js crypto module: on n8n Cloud it is available, on a self-hosted n8n you allow it with the environment variable NODE_FUNCTION_ALLOW_BUILTIN=crypto (n8n docs).
- In the Webhook node, choose POST and turn on the option Raw Body. n8n then keeps the body as binary data. Look up the name of the binary field in the node output (usually
data). - Add a Code node after it, in the mode “Run Once for All Items”, with this code:
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")) }];
If the signature is wrong, the workflow stops. If it is correct, the next nodes get the parsed event. The Crypto node of n8n can also calculate an HMAC (action Hmac, type SHA256, encoding HEX), but you must first build the text <t>.<raw body> and then compare the values in an IF node. The Code node does all of it in one step.
Zapier
In Zapier, use the trigger Webhooks by Zapier → Catch Raw Hook. Unlike Catch Hook, it gives you the unparsed body and the request headers (Zapier help). Webhooks by Zapier needs a Professional plan or higher.
- Add a Code by Zapier step (Run JavaScript). Under Input Data, map
rawBodyto the raw body of the trigger andsignatureto theX-Safina-Signatureheader. - Paste this code. The Node.js standard library, and with it
crypto, is available in Code by Zapier (Zapier help):
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 };
- Add a Filter step: only continue if
validis true.
The secret is in the code of the step, so everyone who can edit the Zap can see it. Catch Raw Hook does not split the JSON into fields. To map single fields in later steps, parse the body in the same code step and return the fields that you need.
Make
Make can do the check with built-in functions. The function sha256(text; [encoding]; [key]) returns an HMAC when you give it a key (Make help). In the Custom webhook module, turn on JSON pass-through (you get the original JSON as text) and, under Advanced settings, Get request headers (Make help). Then add a filter after the webhook with two conditions:
tfrom the header is at most 300 seconds away fromformatDate(now; "X")(Unix seconds).- The
X-Safina-Signatureheader containsv1=followed bysha256(<t>.<raw body>; hex; <your secret>).
This works, but the filter does not compare in constant time, and you have to take t out of the header with split and get. For most Make scenarios, the authentication of the webhook is the simpler option: Make can protect a custom webhook with an API key, which it expects in the x-make-apikey header (Make help). In Safina, choose the authentication “API key”, the location “Header” and the header name x-make-apikey, and enter the same key.
Common errors
- The signature never matches: Your framework parsed the JSON before your check. Use the raw body.
- The signature matches, but the time check fails: Your server clock is wrong. Synchronize it with NTP.
- It stopped working after a new secret: The old secret is valid for 24 hours only. Store the new secret in your receiver.
- The same event arrives twice: That is a retry after a timeout or an error. Skip an
event_idthat you already processed.
Summary
Turn on Sign deliveries for each webhook, store the secret once, and check X-Safina-Signature before you process an event: time, raw body, constant-time comparison. Then a fake request does not reach your CRM. Questions? Write to us at info@safina.ai.