How to Check That a Webhook Really Comes from Safina

Safina can sign every webhook it sends. Learn how to turn on signing, check the X-Safina-Signature header in Node.js or Python, and verify calls in n8n, Zapier and Make.

How to Check That a Webhook Really Comes from Safina Guides
Karsten Kreh Karsten Kreh
Updated

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

RequirementDetails
Safina planPro or higher
A webhook in the Safina appWith a signing secret (see below)
A receiverYour 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": true with POST /webhooks. The answer holds signing_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
  • t is the time of the delivery attempt, in Unix seconds.
  • v1 is the HMAC-SHA256 of the text <t>.<raw body>, with your signing secret as the key, in hex.

Your receiver checks three things:

  1. Time: Reject the request if t is more than 300 seconds (5 minutes) before or after your clock.
  2. 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.
  3. Comparison: Compare your value with each v1 value 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).

  1. 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).
  2. 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.

  1. Add a Code by Zapier step (Run JavaScript). Under Input Data, map rawBody to the raw body of the trigger and signature to the X-Safina-Signature header.
  2. 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 };
  1. Add a Filter step: only continue if valid is 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:

  • t from the header is at most 300 seconds away from formatDate(now; "X") (Unix seconds).
  • The X-Safina-Signature header contains v1= followed by sha256(<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_id that 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.

9:41

Safina handled 51 calls this week

46

Trustworthy

4

Suspicious

1

Dangerous

Last 7 days
Filter
EM
Emma Martin 67s 15:30

Wants to discuss the offer for the new campaign and has questions about the timeline.

LS
Laura Smith 54s 14:45

Asking about the order status and when the delivery arrives.

TH
Tim Miller 34s 13:10

Schedule a meeting for the project discussion next week.

Unknown 44s 11:30

Prize promise – probably spam.

SK
Sarah King 10s 09:15

Complaint about the last order, asks for a callback.

MM
Mike Mitchell 95s Dec 13

Wants to discuss a potential collaboration.

AR
Amy Roberts 85s Dec 13

Is your colleague and wants to discuss the project.

JK
Jack Kennedy 42s Dec 12

Asking about available appointments next week.

LB
Lisa Brown 68s Dec 12

Has questions about the invoice and asks for clarification.

Calls
Safina
Contacts
Profile
9:41
Call from Emma Martin
Dec 12
11:30
67s
+12125551234

Wants to discuss the offer for the new campaign and has questions about the timeline.

Key points

  • Call back Emma Martin
  • Clarify timeline & pricing questions
Call back
Edit contact

AI Insights

Caller mood Very good

The caller was cooperative and provided the needed information.

Urgency Low

The caller can wait for a response.

Audio & Transcript

0:16

Hello, this is Safina AI, Peter's digital assistant. How can I help you?

Hi Safina, this is Emma Martin. I wanted to discuss the offer and the timeline.

Thanks, Emma. Are you mainly deciding between the Standard and Pro package for the launch?

Exactly. We need the Pro package and would like to start next month if onboarding is possible in week one.

Say goodbye to your old-fashioned voicemail.

Try Safina for free and start managing your calls intelligently.

Safina Support Bot
Safina Support Bot

Hi! 👋 I'm the Safina support assistant. Ask me anything about setup, call forwarding, plans or your account. Note: your messages are processed in line with our privacy policy.

Your messages are processed per our privacy policy