Webhooks

Verificar la firma

El algoritmo HMAC-SHA256 real, con ejemplo en Node y en Python.

Cada entrega llega con un header X-KycAr-Signature. Sin validarlo, tu endpoint acepta como verificación aprobada cualquier POST que llegue de internet a esa URL.

El header

X-KycAr-Signature: t=1787234567,v1=6a1f3d2c9b8e7a5f4d3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b0a9f8e7d6
CampoTipoRequeridoDescripción
tintegersiempreMomento del envío, en segundos unix.
v1hexsiempreHMAC-SHA256 en hexadecimal minúscula, 64 caracteres. Firmado con el secreto vigente.
v2hexsólo tras rotarEl mismo HMAC con el secreto anterior, durante las 24 h de doble firma después de un roll-secret.

El algoritmo

Tres pasos, sin ambigüedad:

  1. Material a firmar: la concatenación de t, un punto, y el cuerpo crudo de la request, exactamente como llegó:

    <t> + "." + <raw_body>
  2. Clave: el secreto whsec_… completo, con el prefijo, tal como te lo mostró la API al crear o rotar el endpoint.

  3. Firma: HMAC-SHA256(clave, material), en hexadecimal minúscula.

La firma es válida si alguna de las firmas del header (v1 o v2) coincide con la que calculaste, y si |ahora − t| está dentro de tu tolerancia.

Tolerancia de reloj

t está dentro del material firmado: un atacante no puede reusar una entrega vieja cambiando el timestamp, porque la firma dejaría de validar.

La tolerancia recomendada es 300 segundos. Rechazá lo que caiga fuera de esa ventana aunque la firma sea correcta: eso es lo que corta un ataque de repetición.

Node.js

Sin dependencias, sólo node:crypto.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

/**
 * @param {string} secret     tu whsec_... completo (con prefijo)
 * @param {string} header     valor de X-KycAr-Signature
 * @param {string} rawBody    el cuerpo crudo, sin parsear ni re-serializar
 */
export function verifyKycarSignature(secret, header, rawBody) {
  let t;
  const signatures = [];

  for (const piece of header.split(",")) {
    const eq = piece.indexOf("=");
    if (eq <= 0) return false;
    const key = piece.slice(0, eq).trim();
    const value = piece.slice(eq + 1).trim();
    if (key === "t") t = value;
    if (key === "v1" || key === "v2") signatures.push(value);
  }

  if (t === undefined || signatures.length === 0 || !/^\d{1,12}$/.test(t)) return false;

  const timestamp = Number(t);
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  let match = false;
  for (const candidate of signatures) {
    if (candidate.length !== expected.length || !/^[0-9a-f]+$/.test(candidate)) continue;
    if (timingSafeEqual(Buffer.from(candidate, "hex"), Buffer.from(expected, "hex"))) {
      match = true; // sin early return: mismo tiempo con una o dos firmas
    }
  }
  return match;
}

Con Express

express.raw deja el cuerpo como Buffer: es lo que necesitás para firmar.

import express from "express";

const app = express();

app.post(
  "/hooks/kycar",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("X-KycAr-Signature") ?? "";
    const rawBody = req.body.toString("utf8");

    if (!verifyKycarSignature(process.env.KYCAR_WEBHOOK_SECRET, header, rawBody)) {
      return res.status(400).send("firma inválida");
    }

    const event = JSON.parse(rawBody);

    // Deduplicá por el id del evento: la entrega es at-least-once.
    if (yaProcesado(event.id)) return res.status(200).send("ok");

    // Encolá y respondé rápido: tenés 10 segundos.
    encolar(event);
    res.status(200).send("ok");
  },
);

Con Next.js (route handler)

export async function POST(request) {
  const rawBody = await request.text(); // crudo, antes de cualquier parseo
  const header = request.headers.get("x-kycar-signature") ?? "";

  if (!verifyKycarSignature(process.env.KYCAR_WEBHOOK_SECRET, header, rawBody)) {
    return new Response("firma inválida", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  await encolar(event);
  return new Response("ok", { status: 200 });
}

Python

Sólo librería estándar.

import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300
_TS_RE = re.compile(r"^\d{1,12}$")
_HEX_RE = re.compile(r"^[0-9a-f]+$")


def verify_kycar_signature(secret: str, header: str, raw_body: bytes) -> bool:
    """secret: tu whsec_... completo. raw_body: el cuerpo crudo, sin parsear."""
    timestamp_raw = None
    signatures = []

    for piece in header.split(","):
        key, sep, value = piece.partition("=")
        if not sep:
            return False
        key = key.strip()
        value = value.strip()
        if key == "t":
            timestamp_raw = value
        elif key in ("v1", "v2"):
            signatures.append(value)

    if timestamp_raw is None or not signatures or not _TS_RE.match(timestamp_raw):
        return False

    timestamp = int(timestamp_raw)
    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    signed = f"{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    match = False
    for candidate in signatures:
        if len(candidate) != len(expected) or not _HEX_RE.match(candidate):
            continue
        if hmac.compare_digest(candidate, expected):
            match = True  # sin break: mismo tiempo con una o dos firmas
    return match

Con FastAPI

from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()


@app.post("/hooks/kycar")
async def kycar_webhook(request: Request, x_kycar_signature: str = Header(default="")):
    raw_body = await request.body()  # bytes crudos

    if not verify_kycar_signature(SECRET, x_kycar_signature, raw_body):
        raise HTTPException(status_code=400, detail="firma inválida")

    event = json.loads(raw_body)
    if ya_procesado(event["id"]):
        return {"ok": True}

    await encolar(event)
    return {"ok": True}

Con Django

from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt


@csrf_exempt
def kycar_webhook(request):
    header = request.headers.get("X-KycAr-Signature", "")
    if not verify_kycar_signature(SECRET, header, request.body):
        return HttpResponseBadRequest("firma inválida")

    event = json.loads(request.body)
    encolar(event)
    return HttpResponse("ok")

Rotación con doble firma

Después de POST /v1/webhook-endpoints/{id}/roll-secret, durante 24 horas el header trae dos firmas:

X-KycAr-Signature: t=1787234567,v1=<HMAC con el secreto nuevo>,v2=<HMAC con el anterior>

Las funciones de arriba ya lo manejan: prueban todas las firmas del header contra tu secreto y aceptan si alguna coincide. Eso te permite rotar así, sin ventana de caída:

  1. Rotás y guardás el secreto nuevo. Tu servicio sigue con el viejo: valida por v2.
  2. Desplegás el secreto nuevo. Ahora valida por v1.
  3. Antes de previous_secret_expires_at, borrás el viejo.

Si preferís aceptar los dos secretos al mismo tiempo durante la transición, llamá a la función una vez por secreto y aceptá si alguna devuelve true.

Errores frecuentes

CampoTipoSíntomaDescripción
El body se re-serializaFirma nunca válidatodas fallanTu framework parseó el JSON y firmaste el resultado de JSON.stringify. Guardá el crudo antes de parsear.
Se firma sin el prefijoFirma nunca válidatodas fallanLa clave del HMAC es el secreto completo, incluido whsec_. No lo recortes.
Se firma sólo el cuerpoFirma nunca válidatodas fallanEl material es <t>.<raw_body>, con el timestamp del header adelante y un punto en el medio.
Reloj desincronizadoRechazos intermitentesválidas pero fuera de ventanaTu servidor está corrido más de 5 minutos. Sincronizá por NTP antes de ampliar la tolerancia.
Un proxy modifica el cuerpoFirma nunca válidatodas fallanAlgún intermediario recomprime o reescribe el JSON. Verificá lo más cerca posible del borde.