Tema
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
| Evento | Cuándo |
|---|---|
documento.sellado | Hacienda aceptó el documento. Es el que casi siempre importa. |
documento.rechazado | Hacienda lo rechazó. codigoMsg y descripcionMsg dicen por qué. |
documento.invalidado | Hacienda selló un evento de invalidación sobre un documento que estaba sellado. |
documento.retornado | Hacienda 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
- Parte la cabecera
X-Firmapor comas:t=es la marca de tiempo,v1=la firma en hexadecimal. - Comprueba que
testá dentro de los 5 minutos. Si no, descarta. - Calcula
HMAC-SHA256(secreto, "{t}." + cuerpoCrudo). - 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
localhostno 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.