Después de cada llamada, Safina envía el resumen a tu URL de webhook. Tu CRM, tu flujo de n8n o tu Zap crea con él un lead o una tarea. Pero una URL de webhook es solo una dirección. Quien la conozca puede enviarle un evento falso de «llamada terminada».
Una firma resuelve el problema. Safina firma cada solicitud con un secreto que solo conocen tú y Safina. Tu receptor comprueba la firma y sabe dos cosas: Safina envió la solicitud y nadie cambió el contenido por el camino. Además, una marca de tiempo en la firma impide que alguien vuelva a enviar una solicitud antigua.
¿Eres nuevo con los webhooks? Lee primero ¿Qué es un webhook?. La configuración en la app está en la guía Conectar Safina AI con tu CRM mediante Webhook.
Lo que necesitas
| Requisito | Detalles |
|---|---|
| Plan de Safina | Pro o superior |
| Un webhook en la app de Safina | Con secreto de firma (ver abajo) |
| Un receptor | Tu propio servidor, n8n, Zapier o Make |
Creas y firmas los webhooks en la app de Safina. También puedes crearlos con la API REST de Safina (POST /webhooks). La API REST y las claves de API están en beta: el acceso anticipado está disponible bajo petición en info@safina.ai, y las claves de API necesitan el plan Pro o superior.
Paso 1: Activa la firma
La firma es opcional. Safina firma las solicitudes de un webhook solo cuando tiene un secreto de firma. Lo obtienes de tres formas:
- Webhook nuevo en la app: En la app de Safina, abre la pestaña Safina AI, luego «Sincronización CRM», y añade un webhook. Activa Firmar los envíos antes de guardar.
- Webhook existente en la app: Toca Crear secreto en el webhook. Los webhooks anteriores a esta función siguen sin firmar hasta que lo hagas.
- API REST (beta): Envía
"create_signing_secret": trueconPOST /webhooks. La respuesta contienesigning_secret.
El secreto empieza por whsec_. Safina lo muestra una sola vez. Cópialo y guárdalo en tu receptor, como una contraseña.
Un webhook sin secreto sigue funcionando y envía solicitudes sin firmar, como antes. También puedes usar la autenticación del webhook (auth_type): Safina envía entonces un token bearer o una clave de API en una cabecera con cada solicitud. Eso demuestra que el remitente conoce el token. Una firma demuestra además que el contenido no cambió y que la solicitud es nueva.
Paso 2: Entiende la cabecera
Cada solicitud firmada lleva esta cabecera:
X-Safina-Signature: t=1794569912,v1=5f2c…e91a
tes la hora del intento de entrega, en segundos Unix.v1es el HMAC-SHA256 del texto<t>.<body sin procesar>, con tu secreto de firma como clave, en hexadecimal.
Tu receptor comprueba tres cosas:
- Hora: Rechaza la solicitud si
testá más de 300 segundos (5 minutos) antes o después de tu reloj. - Firma: Calcula el HMAC sobre el body sin procesar, exactamente como llegó, antes de analizar el JSON. Si analizas el JSON y lo vuelves a escribir, pueden cambiar los espacios o el orden de las claves, y la firma ya no coincide.
- Comparación: Compara tu valor con cada valor
v1en tiempo constante. Basta con una coincidencia.
¿Por qué puede haber más de un v1? Cuando tocas Crear nuevo secreto, el secreto anterior sigue siendo válido durante 24 horas. En ese tiempo, la cabecera tiene un valor v1 por cada secreto válido. Así puedes actualizar tu receptor sin perder eventos.
Cada intento de entrega tiene una marca de tiempo y una firma nuevas. Un reintento envía el mismo event_id, así que omite un event_id que ya hayas procesado.
Paso 3: Comprueba la firma en tu código
Estos ejemplos son los mismos que en la referencia de la API (en inglés). Probamos los dos con firmas reales del código de firma de Safina.
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)
);
});
};
Con Express, lee el body de esta ruta como bytes sin procesar, por ejemplo con express.raw({ type: "application/json" }). Entonces req.body es un Buffer. Analiza el JSON solo después de la comprobación:
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
)
En Flask, request.get_data() te da los bytes sin procesar. En FastAPI, usa await request.body().
Comprueba la firma en herramientas de automatización
n8n
n8n comprueba la firma con un nodo Code. Su JavaScript tiene el módulo crypto de Node.js: en n8n Cloud está disponible, y en un n8n autoalojado lo permites con la variable de entorno NODE_FUNCTION_ALLOW_BUILTIN=crypto (documentación de n8n).
- En el nodo Webhook, elige POST y activa la opción Raw Body. n8n guarda entonces el body como datos binarios. Busca el nombre del campo binario en la salida del nodo (normalmente
data). - Añade después un nodo Code en el modo «Run Once for All Items» con este código:
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")) }];
Si la firma es incorrecta, el flujo se detiene. Si es correcta, los nodos siguientes reciben el evento analizado. El nodo Crypto de n8n también puede calcular un HMAC (acción Hmac, tipo SHA256, codificación HEX), pero primero tienes que construir el texto <t>.<body sin procesar> y después comparar los valores en un nodo IF. El nodo Code lo hace todo en un paso.
Zapier
En Zapier, usa el trigger Webhooks by Zapier → Catch Raw Hook. A diferencia de Catch Hook, te da el body sin analizar y las cabeceras de la solicitud (ayuda de Zapier). Webhooks by Zapier necesita el plan Professional o superior.
- Añade un paso Code by Zapier (Run JavaScript). En Input Data, asigna
rawBodyal body sin procesar del trigger ysignaturea la cabeceraX-Safina-Signature. - Pega este código. La biblioteca estándar de Node.js, y con ella
crypto, está disponible en Code by Zapier (ayuda de Zapier):
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 };
- Añade un paso Filter: continúa solo si
valides true.
El secreto está en el código del paso, así que todos los que pueden editar el Zap lo ven. Catch Raw Hook no divide el JSON en campos. Para asignar campos sueltos en pasos posteriores, analiza el body en el mismo paso de código y devuelve los campos que necesitas.
Make
Make puede hacer la comprobación con funciones integradas. La función sha256(text; [encoding]; [key]) devuelve un HMAC cuando le das una clave (ayuda de Make). En el módulo Custom webhook, activa JSON pass-through (recibes el JSON original como texto) y, en la configuración avanzada, Get request headers (ayuda de Make). Después añade un filtro tras el webhook con dos condiciones:
tde la cabecera está como máximo a 300 segundos deformatDate(now; "X")(segundos Unix).- La cabecera
X-Safina-Signaturecontienev1=seguido desha256(<t>.<body sin procesar>; hex; <tu secreto>).
Funciona, pero el filtro no compara en tiempo constante, y tienes que sacar t de la cabecera con split y get. Para la mayoría de los escenarios de Make, la autenticación del webhook es la opción más sencilla: Make puede proteger un custom webhook con una clave de API, que espera en la cabecera x-make-apikey (ayuda de Make). En Safina, elige la autenticación «Clave de API», la ubicación «Encabezado» y el nombre del encabezado x-make-apikey, e introduce la misma clave.
Errores frecuentes
- La firma nunca coincide: Tu framework analizó el JSON antes de tu comprobación. Usa el body sin procesar.
- La firma coincide, pero falla la comprobación de la hora: El reloj de tu servidor va mal. Sincronízalo con NTP.
- Dejó de funcionar tras un secreto nuevo: El secreto anterior solo es válido 24 horas. Guarda el secreto nuevo en tu receptor.
- El mismo evento llega dos veces: Es un reintento tras un timeout o un error. Omite un
event_idque ya hayas procesado.
Resumen
Activa Firmar los envíos en cada webhook, guarda el secreto una vez y comprueba X-Safina-Signature antes de procesar un evento: hora, body sin procesar, comparación en tiempo constante. Así ninguna solicitud falsa llega a tu CRM. ¿Preguntas? Escríbenos a info@safina.ai.