Verifique un webhook firmado
Esta guía explica cómo autenticar un evento recibido por webhook: lectura de la cabecera, cálculo de la firma, comparación en tiempo constante, control de tolerancia y deduplicación. Ejemplo ficticio en Node.js.
Actualizado el
El principio
Cada evento llega con una cabecera Telqio-Signature con la forma t=<marca de tiempo unix>,v1=<firma hexadecimal>. La firma es un HMAC-SHA256 del texto «marca de tiempo, punto, cuerpo sin procesar», calculado con el secreto del punto de conexión.
Hacen falta tres controles: la firma coincide, la marca de tiempo está dentro de una tolerancia de 300 segundos, y el identificador del evento id no se ha procesado ya.
Antes de empezar
- Node.js con sus módulos nativos. No hay nada más que instalar.
- El secreto del punto de conexión, leído desde una variable de entorno. No lo escriba nunca en el código ni lo exponga en el navegador.
- Acceso al cuerpo sin procesar de la solicitud, antes de que lo toque cualquier analizador JSON. Un cuerpo reformateado cambia la firma.
La función de verificación
La comparación usa timingSafeEqual para que un atacante no pueda adivinar la firma byte a byte midiendo el tiempo de respuesta. Las longitudes se comprueban antes, porque timingSafeEqual lanza un error si difieren.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifySignature(rawBody, header, secret, nowMs = Date.now()) {
if (typeof header !== 'string') return false
const parts = {}
for (const item of header.split(',')) {
const index = item.indexOf('=')
if (index > 0) parts[item.slice(0, index).trim()] = item.slice(index + 1).trim()
}
const timestamp = Number(parts.t)
if (!Number.isInteger(timestamp) || !parts.v1) return false
const ageSeconds = Math.abs(Math.floor(nowMs / 1000) - timestamp)
if (ageSeconds > TOLERANCE_SECONDS) return false
const signedPayload = Buffer.concat([Buffer.from(timestamp + '.'), rawBody])
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest()
const received = Buffer.from(parts.v1, 'hex')
return expected.length === received.length && crypto.timingSafeEqual(expected, received)
}El punto de conexión
El servidor siguiente lee el cuerpo sin procesar, verifica la firma, descarta los duplicados y responde rápido. El Set en memoria es solo para el ejemplo: en la práctica, guarde los identificadores procesados en un almacenamiento compartido y duradero.
import http from 'node:http'
import { verifySignature } from './verify.mjs'
const SECRET = process.env.TELQIO_WEBHOOK_SECRET
const seen = new Set()
http
.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404).end()
return
}
const chunks = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
if (!verifySignature(rawBody, req.headers['telqio-signature'], SECRET)) {
res.writeHead(400).end('invalid signature')
return
}
const event = JSON.parse(rawBody.toString('utf8'))
if (seen.has(event.id)) {
res.writeHead(200).end('duplicate')
return
}
seen.add(event.id)
console.log('evento recibido:', event.type, event.id)
res.writeHead(200).end('ok')
})
})
.listen(3000)Probar en local
- Exporte un secreto de prueba: export TELQIO_WEBHOOK_SECRET=secret_de_test_exemple, y ejecute node server.mjs.
- En otra terminal, firme un cuerpo de prueba con openssl y envíelo con curl.
- Ejecute de nuevo el mismo comando: la respuesta pasa de ok a duplicate. Cambie un carácter del cuerpo sin recalcular la firma: la respuesta será invalid signature.
BODY='{"id":"evt_EXEMPLE_001","type":"call.completed","created":1794096000}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "secret_de_test_exemple" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "Telqio-Signature: t=$T,v1=$SIG" \
-d "$BODY"Eventos posibles
- call.ringing, call.connected, call.completed, call.failed
- message.sent, message.delivered, message.undelivered
- number.activated
Errores frecuentes
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| La firma se rechaza siempre | Un analizador JSON leyó y volvió a serializar el cuerpo. | Verifique sobre los bytes recibidos, antes de cualquier análisis. |
| Rechazo aunque la firma es correcta | Reloj del servidor desfasado: la diferencia supera los 300 segundos. | Sincronice el reloj (NTP). |
| Excepción en timingSafeEqual | Longitudes distintas, falta el control previo. | Compare primero las longitudes, como en el ejemplo. |
| Eventos procesados dos veces | La entrega se repite y el id no se deduplica. | Guarde los id procesados y responda 200 a los duplicados. |
| Secreto expuesto | Secreto en el repositorio, en un registro o en el navegador. | Sustitúyalo y léalo solo desde el servidor. |
Lo que esta guía no hace
- No demuestra que su aplicación sea segura: este código es un punto de partida que debe revisar y probar.
- No fija la política de reintento de entregas, que será acotada y visible en la consola.
- No garantiza el orden de los eventos: un message.delivered puede llegar antes que otros. Apóyese en los identificadores y las marcas de tiempo.
- No existe ningún webhook real mientras no haya un proveedor configurado.
Preguntas frecuentes
¿Por qué usar el cuerpo sin procesar?
La firma cubre los bytes exactos enviados. Un espacio o un orden de claves modificado por un analizador JSON basta para producir una firma distinta.
¿Qué responder a un evento ya procesado?
Un código 200. El emisor considera entonces la entrega como correcta y deja de repetirla, sin efectos secundarios de su lado.