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.

Versión de demostración. El formato descrito es una propuesta de diseño y puede cambiar. No se emite ningún webhook real mientras no haya un proveedor configurado.

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.

Ejemplo ficticio en Node.js. El secreto y los eventos son inventados. Adáptelo antes de cualquier uso real.
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.

Ejemplo ficticio en Node.js, para guardar como server.mjs junto a la función anterior.
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

  1. Exporte un secreto de prueba: export TELQIO_WEBHOOK_SECRET=secret_de_test_exemple, y ejecute node server.mjs.
  2. En otra terminal, firme un cuerpo de prueba con openssl y envíelo con curl.
  3. 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.
Ejemplo ficticio: evento y secreto inventados, enviados únicamente a su propia máquina.
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íntomaCausa probableQué hacer
La firma se rechaza siempreUn analizador JSON leyó y volvió a serializar el cuerpo.Verifique sobre los bytes recibidos, antes de cualquier análisis.
Rechazo aunque la firma es correctaReloj del servidor desfasado: la diferencia supera los 300 segundos.Sincronice el reloj (NTP).
Excepción en timingSafeEqualLongitudes distintas, falta el control previo.Compare primero las longitudes, como en el ejemplo.
Eventos procesados dos vecesLa entrega se repite y el id no se deduplica.Guarde los id procesados y responda 200 a los duplicados.
Secreto expuestoSecreto 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.