Webhooks
Los webhooks avisan a tu sistema cuando cambia el estado de un documento, sin que tengas que consultar. Son indispensables en
modo async y cuando la API responde 202.
Registrar un endpoint
Sección titulada «Registrar un endpoint»curl https://api.sudte.com/v1/webhook-endpoints \ -H "Authorization: Bearer $SUDTE_API_KEY" \ -H "Idempotency-Key: webhook-erp-1" \ -H "Content-Type: application/json" \ -d '{ "url": "https://erp.example.com/sudte/webhook", "events": ["document.processed", "document.rejected"] }'La respuesta incluye el secreto de firma (whsec_…) una sola vez: guárdalo. La URL debe ser https y pública. Los
endpoints son por ambiente: los registrados con una key sk_test_ reciben solo eventos de pruebas. También puedes crearlos
desde el panel, en Desarrolladores → Webhooks, y enviar un evento de prueba con
POST /v1/webhook-endpoints/{id}/test.
Eventos
Sección titulada «Eventos»| Evento | Cuándo |
|---|---|
document.processed |
El MH selló el documento. |
document.rejected |
El MH rechazó el documento. Incluye su código y observaciones. |
document.contingency |
El MH no respondió tras los reintentos; el documento pasó a contingencia. |
document.voided |
Se selló el evento de invalidación del documento. |
document.delivered |
Se envió el correo con el JSON y el PDF al receptor. |
fiscal.report.ready · fiscal.report.failed |
Un reporte fiscal terminó de generarse o falló. |
fiscal.period.closed · fiscal.period.drift_detected |
Se cerró un periodo fiscal, o cambiaron datos de un periodo cerrado. |
Formato
Sección titulada «Formato»Cada entrega es un POST con cuerpo JSON:
{ "id": "evt_01JA2P9T3K5M7N9Q1R3S5T7V9W", "object": "event", "type": "document.processed", "environment": "01", "created_at": "2026-10-12T16:15:04Z", "data": { "id": "doc_01JA2M7R5N3P8Q4S6T0V2W4X6Y", "object": "document", "type": "03", "status": "processed", "environment": "01", "emitter_id": "emi_01J9Z8K2QF4T6W3X5Y7A9B1C2D", "external_id": "factura-2026-00831", "generation_code": "5D0C2B1A-7E3F-4A9B-8C6D-1E2F3A4B5C6D", "control_number": "DTE-03-M001P001-000000000000057", "reception_stamp": "20264F1A8C3E9B2D7A6C5E4F3B2A1D9C8E7F6A5B", "processed_at": "2026-10-12T10:15:03-06:00", "total": "168.00", "mh_response": { "code": "001", "message": "RECIBIDO", "observations": [] } }}Cabeceras de cada entrega:
| Cabecera | Contenido |
|---|---|
Sudte-Signature |
t=<unix>,v1=<firma>. Ver verificación. |
Sudte-Event-Id |
Identificador del evento (igual a id). Úsalo para no procesar dos veces el mismo evento. |
Sudte-Delivery-Attempt |
Número de intento de entrega. |
User-Agent |
sudte-webhooks/1.0 |
Verificar la firma
Sección titulada «Verificar la firma»La firma es HMAC-SHA256(secreto, t + "." + cuerpo) en hexadecimal, donde cuerpo son los bytes exactos recibidos (no lo
vuelvas a serializar). Rechaza los eventos cuya marca de tiempo t tenga más de 5 minutos de diferencia con tu reloj.
import crypto from 'node:crypto';
export function verificarFirma(cuerpo, cabecera, secreto, tolerancia = 300) { const partes = Object.fromEntries(cabecera.split(',').map((p) => p.trim().split('='))); const t = Number(partes.t); if (!t || !partes.v1 || Math.abs(Date.now() / 1000 - t) > tolerancia) return false; const esperada = crypto.createHmac('sha256', secreto).update(`${t}.${cuerpo}`).digest('hex'); if (esperada.length !== partes.v1.length) return false; return crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(partes.v1));}
// Express: usa el cuerpo crudoapp.post('/sudte/webhook', express.raw({ type: 'application/json' }), (req, res) => { if (!verificarFirma(req.body.toString('utf8'), req.get('Sudte-Signature'), process.env.SUDTE_WEBHOOK_SECRET)) { return res.sendStatus(400); } const evento = JSON.parse(req.body); res.sendStatus(200); // responde rápido y procesa en segundo plano});import hashlib, hmac, time
def verificar_firma(cuerpo: bytes, cabecera: str, secreto: str, tolerancia: int = 300) -> bool: partes = dict(p.strip().split("=", 1) for p in cabecera.split(",")) t = int(partes.get("t", "0")) if not t or "v1" not in partes or abs(time.time() - t) > tolerancia: return False esperada = hmac.new(secreto.encode(), f"{t}.".encode() + cuerpo, hashlib.sha256).hexdigest() return hmac.compare_digest(esperada, partes["v1"])function verificarFirma(string $cuerpo, string $cabecera, string $secreto, int $tolerancia = 300): bool { $partes = []; foreach (explode(',', $cabecera) as $p) { [$k, $v] = array_map('trim', explode('=', $p, 2)); $partes[$k] = $v; } $t = (int) ($partes['t'] ?? 0); if (!$t || empty($partes['v1']) || abs(time() - $t) > $tolerancia) return false; $esperada = hash_hmac('sha256', $t . '.' . $cuerpo, $secreto); return hash_equals($esperada, $partes['v1']);}// $cuerpo = file_get_contents('php://input');func VerificarFirma(cuerpo []byte, cabecera, secreto string, ahora time.Time) bool { var t int64 var v1 string for _, p := range strings.Split(cabecera, ",") { k, v, _ := strings.Cut(strings.TrimSpace(p), "=") switch k { case "t": t, _ = strconv.ParseInt(v, 10, 64) case "v1": v1 = v } } if t == 0 || v1 == "" || ahora.Sub(time.Unix(t, 0)).Abs() > 5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secreto)) fmt.Fprintf(mac, "%d.", t) mac.Write(cuerpo) return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))}Reintentos y buenas prácticas
Sección titulada «Reintentos y buenas prácticas»- Responde con un código 2xx en menos de 15 segundos. Si respondes otro código o no respondes, sudte reintenta con espera exponencial durante 24 horas.
- Las entregas son al menos una vez: puede llegar el mismo evento más de una vez. Guarda el
Sudte-Event-Idy descarta los repetidos. - El orden no está garantizado. Si necesitas el estado más reciente, consulta
GET /v1/documents/{id}. - Procesa en segundo plano: guarda el evento, responde 200 y luego actualiza tu sistema.