Al timbrar un CFDI, la respuesta síncrona ya incluye el UUID. Pero no todas las operaciones fiscales terminan dentro de una request: una cancelación que necesita aceptación del receptor puede tardar hasta 72 horas. Los webhooks cubren ese intervalo sin polling ni jobs periódicos.
Esta guía explica cómo integrar webhooks de timbrado CFDI con un sistema de nómina: desde registrar la suscripción hasta verificar la firma, deduplicar entregas y manejar reintentos.
Respuesta síncrona y notificación asíncrona
POST /cfdi/timbrar sigue siendo síncrono. Si el timbrado termina durante la request, recibes el folio fiscal en esa respuesta y no debes esperar un webhook para continuar. El webhook funciona como notificación y como canal para resultados posteriores, no como sustituto de la API.
En nómina, por ejemplo, puedes guardar el UUID de cada recibo al timbrarlo y usar cfdi.timbrado para conciliar tu operación. Si después solicitas una cancelación que queda pendiente, cfdi.cancelacion_en_proceso te avisa de inmediato y el evento final llega cuando el SAT confirma el desenlace.
Representación textual del diagrama (Mermaid)
flowchart TD
A[Sistema de nómina] -->|POST /cfdi/timbrar| B[Ipsofactura]
B -->|UUID síncrono| A
B -->|cfdi.timbrado| A
A -->|POST /cfdi/cancelar| B
B -->|cancelación inmediata| C[cfdi.cancelado]
B -->|requiere aceptación| D[cfdi.cancelacion_en_proceso]
D -->|aceptada| C
D -->|rechazada| E[cfdi.cancelacion_rechazada]Configura la suscripción
Registra una URL HTTPS pública y los eventos que quieres recibir. Puedes omitir company_id para escuchar a todas las empresas de tu cuenta o incluirlo para filtrar a un emisor.
curl -X POST https://api.ipsofactura.com/webhooks \
-H "x-api: TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.ejemplo.com/webhooks/ipsofactura",
"description": "Notificaciones de nómina",
"events": [
"cfdi.timbrado",
"cfdi.cancelado",
"cfdi.cancelacion_en_proceso",
"cfdi.cancelacion_rechazada"
]
}'secret_key una sola vez. Guárdalo en un gestor de secretos; lo necesitarás para validar cada firma y ninguna consulta posterior vuelve a mostrarlo.Catálogo de los 6 eventos
| Evento | Cuándo se emite |
|---|---|
cfdi.timbrado | Cuando un CFDI se timbra correctamente, incluso si se confirma después de un timeout. |
pago.timbrado | Cuando se timbra un complemento de pago contra una factura PPD. |
cfdi.cancelado | Cuando la cancelación queda firme ante el SAT. |
cfdi.cancelacion_en_proceso | Cuando el SAT espera la aceptación o el rechazo del receptor. |
cfdi.cancelacion_rechazada | Cuando el receptor rechaza una cancelación pendiente. |
csd.por_vencer | Cuando el CSD de una empresa se acerca a su vencimiento. |
Los seis códigos ya son válidos al crear una suscripción. Los primeros cinco se generan actualmente; csd.por_vencer comenzará a entregarse sin que tengas que modificarla.
Los avisos incluyen identificadores y datos de conciliación, pero no el XML ni el PDF. Usa el id recibido para consultar esos documentos por la API autenticada.
El sobre de cada evento
{
"event_id": "8c1f0b6e-5a24-4d31-9b77-0e2a6c4d1f93",
"event": "cfdi.timbrado",
"version": "v1",
"created_at": "2026-08-26T18:42:07.512Z",
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"uuid": "6128396F-C09B-4EC6-8699-43DA5A244971",
"rfc_emisor": "EKU9003173C9",
"rfc_receptor": "XAXX010101000",
"total": 1160.00,
"estatus": "timbrado"
}
}event_id identifica la entrega y se conserva en todos sus reintentos. created_at indica cuándo nació el evento; no es la hora del intento de entrega. Los campos sin valor se omiten en lugar de enviarse como null.
Verifica la firma antes de procesar
Cada request incluye X-Ipso-Signature con la forma t=<epoch>,v1=<hmac>. La firma es un HMAC-SHA256 de <t>.<cuerpo_crudo>. Usa los bytes originales: parsear y volver a serializar el JSON cambia el cuerpo y rompe la validación.
Node.js
import crypto from 'crypto';
import express from 'express';
const app = express();
const secret = process.env.IPSO_WEBHOOK_SECRET;
const tolerancia = 5 * 60;
function verificar(rawBody, signature) {
const partes = Object.fromEntries(
signature.split(',').map(parte => parte.split('=', 2))
);
const edad = Math.abs(Math.floor(Date.now() / 1000) - Number(partes.t));
if (!partes.t || !partes.v1 || edad > tolerancia) return false;
const esperada = crypto
.createHmac('sha256', secret)
.update(partes.t + '.' + rawBody.toString('utf8'))
.digest('hex');
const a = Buffer.from(esperada);
const b = Buffer.from(partes.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/ipsofactura', express.raw({ type: 'application/json' }), (req, res) => {
if (!verificar(req.body, req.get('X-Ipso-Signature') || '')) {
return res.status(400).send('firma inválida');
}
const evento = JSON.parse(req.body.toString('utf8'));
res.status(200).send('ok');
encolar(evento);
});Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
secret = os.environ["IPSO_WEBHOOK_SECRET"]
def verificar(raw_body: bytes, signature: str) -> bool:
try:
partes = dict(parte.split("=", 1) for parte in signature.split(","))
timestamp = partes["t"]
if abs(int(time.time()) - int(timestamp)) > 5 * 60:
return False
mensaje = timestamp.encode() + b"." + raw_body
esperada = hmac.new(secret.encode(), mensaje, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, partes["v1"])
except (KeyError, ValueError):
return False
@app.post("/webhooks/ipsofactura")
def recibir():
raw_body = request.get_data()
if not verificar(raw_body, request.headers.get("X-Ipso-Signature", "")):
return "firma inválida", 400
evento = json.loads(raw_body)
encolar(evento)
return "ok", 200Reintentos: 6 intentos en aproximadamente 10.5 horas
Una respuesta 2xx confirma la entrega. Cualquier 3xx, 4xx, 5xx, timeout o error de red programa el siguiente intento.
| Intento | Momento |
|---|---|
| 1 | Inmediato |
| 2 | +1 minuto |
| 3 | +5 minutos |
| 4 | +30 minutos |
| 5 | +2 horas |
| 6 | +8 horas |
Si el sexto intento falla, la entrega queda en estado dead. El ciclo completo suma poco más de 10.5 horas. Tu endpoint tiene 10 segundos para responder; confirma primero con 2xx y procesa el trabajo en una cola.
Deduplica por event_id
La entrega es at-least-once: un evento puede llegar más de una vez si procesaste el cuerpo pero la respuesta se perdió o excedió el timeout. Haz idempotente tu handler con el mismo event_id que llega en el cuerpo y en X-Ipso-Event-Id.
const agregado = await redis.set(
`ipso:webhook:${evento.event_id}`,
'1',
{ NX: true, EX: 60 * 60 * 24 * 7 }
);
if (!agregado) return; // Ya procesado
await procesar(evento);Un TTL de 7 días cubre con margen las 10.5 horas de reintentos. No dependas del orden de llegada: consulta el estado actual por API cuando una transición requiera confirmación.
Checklist de integración
- Registra una URL HTTPS pública y selecciona solo los eventos que consumes.
- Guarda
secret_keycuando creas la suscripción; se muestra una sola vez. - Conserva el cuerpo crudo y valida HMAC-SHA256 antes de parsear el JSON.
- Rechaza firmas con timestamps fuera de la tolerancia de 5 minutos.
- Deduplica cada entrega por
event_idy vuelve idempotente el procesamiento. - Responde
2xxen menos de 10 segundos y procesa en segundo plano. - Monitorea entregas fallidas y reconstruye el estado por API si una notificación se pierde.
Con estas siete comprobaciones, tu integración recibe notificaciones CFDI en tiempo real sin convertir el webhook en una nueva fuente de inconsistencias. La API conserva la verdad fiscal; el webhook te dice cuándo vale la pena consultarla o avanzar tu flujo.