Skip to content

Webhooks

Para enterarte de que un documento se selló sin preguntarlo cada segundo.

Registrar el endpoint

bash
curl -s -X POST "$API/api/v1/dte/webhooks" \
  -H "Authorization: Bearer $CLAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://tu-sistema.com/hooks/dte",
    "eventos": ["documento.sellado", "documento.rechazado", "documento.invalidado", "documento.retornado"]
  }'
json
{
  "id": "4a1b...",
  "url": "https://tu-sistema.com/hooks/dte",
  "eventos": ["documento.sellado", "documento.rechazado", "documento.invalidado", "documento.retornado"],
  "secreto": "whsec_k4m2n8p6q1r7s3t9v5w2x8y4z6a1b3c5"
}

El secreto se muestra una sola vez. Guárdalo donde guardes tus credenciales. Si se pierde, hay que dar de baja el webhook y crear otro.

eventos vacío significa todos.

Los cuatro eventos

EventoCuándo
documento.selladoHacienda aceptó el documento. Es el que casi siempre importa.
documento.rechazadoHacienda lo rechazó. codigoMsg y descripcionMsg dicen por qué.
documento.invalidadoHacienda selló un evento de invalidación sobre un documento que estaba sellado.
documento.retornadoHacienda selló un Evento de Retorno. El DTE original sigue vigente; restar la venta es decisión tuya.

Suscríbete a documento.invalidado y a documento.retornado aunque no los dispares desde tu sistema: si se pidieron desde el panel del contribuyente, tu sistema no se enteraría de otra forma.

Lo que llega

POST a tu URL, con este cuerpo:

json
{
  "id": "evt_01hqzx8k3m9p2n4r6s8t",
  "tipo": "documento.sellado",
  "creadoEn": "2026-08-12T09:31:15-06:00",
  "datos": {
    "codigoGeneracion": "2F380579-2A15-42E8-9ADF-4EA97AE4DA3A",
    "numeroControl": "DTE-01-M001P001-000000000000042",
    "tipoDte": "01",
    "estado": "SELLADO",
    "selloRecibido": "20266B573AF9DE4543A7942C0CD0891B0EBA3KWJ",
    "codigoMsg": "001",
    "descripcionMsg": "RECIBIDO"
  }
}

datos trae lo mismo que devuelve consultar el documento, para que no tengas que ir a buscar nada al recibirlo.

Y estas cabeceras:

Content-Type: application/json
X-Firma: t=1786455075,v1=5257a2f9c8b4...

Verificar la firma

Es HMAC-SHA256 sobre {timestamp}.{cuerpo}, con la marca de tiempo dentro del hash. Firmar solo el cuerpo dejaría que alguien que capturó una entrega la reenviara indefinidamente.

La tolerancia es de 5 minutos: absorbe el desfase normal de relojes y acota la ventana de reenvío.

Los cuatro pasos

  1. Parte la cabecera X-Firma por comas: t= es la marca de tiempo, v1= la firma en hexadecimal.
  2. Comprueba que t está dentro de los 5 minutos. Si no, descarta.
  3. Calcula HMAC-SHA256(secreto, "{t}." + cuerpoCrudo).
  4. Compara en tiempo constante con v1.

El paso 4 no es paranoia de manual: una comparación normal de cadenas termina en cuanto encuentra una diferencia, y esa diferencia de microsegundos le dice a quien intenta forjar una firma cuántos bytes iniciales acertó.

Usa el cuerpo crudo, byte a byte, no el resultado de parsear y volver a serializar el JSON: cualquier reordenamiento de claves cambia el hash.

Node.js

javascript
import crypto from "node:crypto";

const TOLERANCIA_S = 300;

export function verificarFirma(secreto, cabecera, cuerpoCrudo) {
  const partes = Object.fromEntries(
    cabecera.split(",").map((p) => p.trim().split("=")),
  );
  const t = Number(partes.t);
  if (!t || !partes.v1) return false;

  if (Math.abs(Date.now() / 1000 - t) > TOLERANCIA_S) return false;

  const esperada = crypto
    .createHmac("sha256", secreto)
    .update(`${t}.`)
    .update(cuerpoCrudo)
    .digest("hex");

  const a = Buffer.from(esperada, "utf8");
  const b = Buffer.from(partes.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: el cuerpo crudo, no el parseado.
app.post(
  "/hooks/dte",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (!verificarFirma(process.env.WHSEC, req.get("X-Firma"), req.body)) {
      return res.sendStatus(400);
    }
    const evento = JSON.parse(req.body.toString("utf8"));
    encolarParaProcesar(evento);   // rápido: responde ya
    res.sendStatus(200);
  },
);

PHP

php
function verificarFirma(string $secreto, string $cabecera, string $cuerpo): bool {
    $partes = [];
    foreach (explode(',', $cabecera) as $p) {
        [$k, $v] = array_pad(explode('=', trim($p), 2), 2, null);
        $partes[$k] = $v;
    }
    if (empty($partes['t']) || empty($partes['v1'])) return false;
    if (abs(time() - (int)$partes['t']) > 300) return false;

    $esperada = hash_hmac('sha256', $partes['t'] . '.' . $cuerpo, $secreto);
    return hash_equals($esperada, $partes['v1']);
}

$cuerpo = file_get_contents('php://input');
if (!verificarFirma(getenv('WHSEC'), $_SERVER['HTTP_X_FIRMA'] ?? '', $cuerpo)) {
    http_response_code(400);
    exit;
}

Cómo debe responder tu endpoint

Cualquier 2xx cierra la entrega. Cualquier otra cosa se reintenta.

Y responde rápido: acepta el evento, ponlo en tu propia cola y contesta 200. Si procesas de forma síncrona y tardas, la entrega puede darse por fallida y recibirás el mismo evento otra vez.

Reintentos

Si tu endpoint no contesta 2xx, se reintenta con espera exponencial: 2 s, 4 s, 8 s, 16 s, 32 s, hasta 5 intentos. Después el trabajo queda marcado como fallido —no se pierde, queda registrado— pero ya no se vuelve a intentar solo.

Se reintenta incluso ante un 4xx, a propósito: tu endpoint puede estar desplegándose y devolviendo 404 durante treinta segundos, y perder el aviso de un sello te devuelve al sondeo que este mecanismo evita.

Duplicados: tu endpoint tiene que aguantarlos

Un reintento después de que tu servidor procesó el evento pero no alcanzó a responder entrega el mismo evento dos veces. Es inevitable en cualquier sistema de entrega.

Usa el campo id del evento para descartarlo:

sql
CREATE TABLE eventos_recibidos (
    id text PRIMARY KEY,
    recibido_en timestamptz NOT NULL DEFAULT now()
);
-- INSERT ... ON CONFLICT DO NOTHING; si no insertó, ya lo procesaste.

Alternativa igual de buena: haz que procesar el evento sea idempotente por sí mismo. «Marcar esta venta como facturada con este sello» se puede ejecutar dos veces sin daño.

Registra el webhook y sondea igual

Los webhooks son el mecanismo principal, no el único que debes tener.

Si tu endpoint estuvo caído durante los cinco reintentos, ese evento no llega nunca. Un trabajo por hora que busque documentos que llevan demasiado tiempo sin sello y consulte su estado cuesta muy poco y cubre exactamente ese hueco:

bash
curl -s "$API/api/v1/dte/documentos?estado=TRANSMITIENDO&desde=2026-08-01" \
  -H "Authorization: Bearer $CLAVE"

Requisitos de la URL

  • HTTPS en producción. El evento lleva datos fiscales del contribuyente.
  • Accesible desde internet. Un localhost no sirve; para desarrollo usa un túnel.
  • Que responda rápido, por lo dicho arriba.

Dar de baja

bash
curl -s -X DELETE "$API/api/v1/dte/webhooks/{id}" -H "Authorization: Bearer $CLAVE"

Listar los registrados: GET /api/v1/dte/webhooks. Devuelve la URL y los eventos, nunca el secreto.