Qué son y cuándo usarlos
Un webhook es una suscripción: registras una URL tuya y nosotros te hacemos unPOST
firmado cada vez que ocurre uno de los eventos a los que te suscribiste (un CFDI timbrado, una
cancelación, un complemento de pago).
Úsalos cuando el resultado te llega después de la request que lo originó, o cuando no
quieres estar consultando la API en un ciclo:
- Una cancelación con aceptación del receptor queda
en_procesoy el SAT resuelve horas o días después. El webhook te avisa del desenlace sin que tengas que hacer polling. - Quieres conciliar timbrados y pagos contra tu sistema interno en cuanto ocurren.
- Emites por varias empresas y necesitas un solo canal de notificación por cuenta.
El webhook es un aviso, no el documento. El payload lleva identificadores (
id, uuid,
RFCs, total) y nunca el XML, el PDF ni el sello. Para obtener el documento haz la llamada
autenticada correspondiente (GET /cfdi/{id}/xml, /pdf) con el id que viene en el aviso.Alta de una suscripción
cURL
Respuesta
201:
Reglas del alta
- Máximo 10 webhooks activos por cuenta. Al llegar al tope, el alta responde
400VALIDATION_ERROR; borra o desactiva uno que ya no uses. - Un código de evento desconocido responde
400con el catálogo completo de códigos válidos en elmessage. - Si envías
company_idde una empresa que no es tuya, la respuesta esEMPRESA_NOT_FOUND(nunca revelamos si el id existe en otra cuenta). - El prefijo del secreto indica el ambiente:
whsec_live_en producción,whsec_test_en sandbox.
Requisitos de la URL
La URL se valida al crearla y otra vez en cada entrega (para cerrar un DNS rebinding). Rechazamos:
Si el hostname es válido al alta pero resuelve a una dirección no ruteable al momento de
entregar, el intento se marca como fallido con el motivo correspondiente.
Catálogo de eventos
Los campos base, presentes en todos los eventos de CFDI:
Un campo sin valor se omite del objeto
data en lugar de enviarse como null. No
asumas que una llave siempre viene: lee con default.Los campos base de arriba aplican a los cinco eventos de CFDI.
csd.por_vencer no habla de
un comprobante sino de un certificado, así que su data tiene su propia forma: la encuentras en
Aviso de vencimiento del CSD.Forma del payload
Toda entrega es unPOST con Content-Type: application/json y este sobre:
Además, cada request lleva estos headers:
Ejemplos por evento
Eventos de cancelación y timbrado tardío
Ciclo de vida de una cancelación
Cancelar un CFDI (POST /cfdi/cancelar) no siempre resuelve al instante — un motivo que
requiere la aceptación del receptor queda pendiente hasta 72 horas. Ipsofactura reconcilia
ese estado automáticamente (barrido cada 5 minutos) y publica el desenlace:
1
cfdi.cancelacion_en_proceso
Se emite en el momento de
POST /cfdi/cancelar cuando la cancelación no queda firme
de inmediato: el CFDI pasa a estatus: en_proceso en espera de que el receptor acepte o
rechace desde el portal del SAT.2
cfdi.cancelado o cfdi.cancelacion_rechazada
Cuando el reconciliador confirma el veredicto del SAT, emite exactamente uno de los dos:
cfdi.cancelado si el receptor aceptó (o el motivo no requería su aceptación),
cfdi.cancelacion_rechazada si la rechazó.data — los campos base más:
Si el SAT no resuelve dentro de la ventana (72 h + margen), el CFDI conserva
estatus: en_proceso y no se emite un veredicto inventado — ni cfdi.cancelado ni
cfdi.cancelacion_rechazada. Consulta GET /cfdi/{id}/estatus si esto te ocurre.Timbrado tardío: cfdi.timbrado y pago.timbrado
Un timbrado que respondió 503 STAMPING_OUTCOME_UNKNOWN (ver
Recuperación tras timeout)
no queda sin resolver: Ipsofactura vuelve a preguntarle al PAC en segundo plano y, en cuanto
confirma que el CFDI existe, emite el evento del comprobante — con el mismo payload que un
timbrado a tiempo, sin ningún campo que lo marque como tardío. Tu integración no necesita
distinguir los dos casos: si te suscribiste al evento, lo recibes de cualquier forma.
Un complemento de pago confirmado tarde queda registrado completo —su árbol de pagos y sus
documentos relacionados incluidos—, así que la cadena de parcialidades de la factura PPD sigue
siendo correcta. Ver
Complemento de pago.
Esta es también la señal más rápida de que un
503 con retry_after sí se resolvió, más
rápida que hacer polling a GET /cfdi.
Aviso de vencimiento del CSD
Un CSD tiene cuatro años de vigencia y el día que se vence se detiene todo el timbrado de esa empresa: el PAC rechaza el comprobante y no hay forma de emitir hasta que registres el certificado nuevo.csd.por_vencer existe para que eso nunca te tome por sorpresa.
Cuándo se emite
Un barrido diario revisa el CSD activo de cada una de tus empresas y emite el aviso cuando le quedan 30, 15 o 7 días de vigencia.- Una vez por umbral, y nada más. Cada certificado emite como máximo tres avisos en toda su vida: uno a los 30 días, uno a los 15 y uno a los 7. El aviso de los 7 días no se repite al día siguiente, ni el de los 15 vuelve cuando se cruzan los 7.
- Si registras un CSD que ya está dentro de un umbral, recibes los umbrales que ya cruzó en el primer barrido. Un certificado dado de alta con 12 días de vigencia restante emite el aviso de 30 y el de 15 juntos (nunca tuvo un “día 30”), y más adelante el de 7. Siguen siendo tres avisos como máximo.
dias_restantesyumbralno siempre coinciden.umbrales el aviso que se está emitiendo (30, 15 o 7);dias_restanteses la vigencia real ese día. En el caso anterior el aviso deumbral: 30llega condias_restantes: 12. Para decidir la urgencia leedias_restantes.- Un CSD ya vencido no genera el aviso. Deja de ser “por vencer” y pasa a ser un problema distinto: el timbrado falla y lo verás en la respuesta de la API, no aquí.
- Respeta el filtro
company_idde la suscripción, igual que el resto de los eventos: si registraste el webhook concompany_id, sólo recibes los avisos de los CSD de esa empresa. - Un CSD que dejó de ser el certificado activo de la empresa (porque subiste uno nuevo) ya no genera avisos.
Campos de data
csd.por_vencer
El aviso nunca lleva material del certificado — ni el
.cer, ni la llave, ni la contraseña.
Para renovar, sube el CSD nuevo con POST /empresas/{id}/certificados como la primera vez.Verificación de la firma
Verifica siempre la firma antes de procesar. Tu endpoint es público; sin verificar, cualquiera puede inventar un aviso de timbrado. El header tiene esta forma:t— el instante del intento, en segundos desde epoch.v1—HMAC-SHA256, en hexadecimal minúscula, calculado sobre la cadena"<t>.<cuerpo_crudo>"usando tusecret_keycomo llave.
Tolerancia de t
El timestamp va dentro de la cadena firmada, no solo al lado. Por eso un atacante que
capture una entrega no puede reenviarla con un t nuevo: tendría que recalcular el HMAC, y no
tiene el secreto. Lo único que necesitas hacer es rechazar timestamps viejos.
Recomendamos una tolerancia de 5 minutos. Ten en cuenta que:
tes del intento, no del evento. Un reintento a las 8 horas llega con untfresco y una firma nueva sobre el mismo cuerpo, así que pasa la tolerancia sin problema.- El
created_atdel cuerpo sí puede ser mucho más viejo quet. No lo uses para la tolerancia. - Mantén el reloj de tu servidor sincronizado por NTP; si va desfasado más que la tolerancia, rechazarás entregas legítimas.
Reintentos y entrega
Qué contamos como éxito
Una entrega es exitosa si tu endpoint responde con un código 2xx (200–299). Cualquier
otra cosa —3xx, 4xx, 5xx, timeout, error de TLS, host que no resuelve— cuenta como fallo
y programa un reintento.
Backoff
Hacemos 6 intentos en total. Las esperas entre intentos son fijas:
Si el sexto intento falla, la entrega queda en estado
dead: no se reintenta más de forma
automática (puedes reenviarla a mano). El lapso total desde el primer
intento hasta el último es de poco más de 10 horas y media.
Timeouts
Si tu handler tarda más de 10 segundos en responder, la entrega se cuenta como fallida aunque
la hayas procesado. Por eso: responde 2xx primero, procesa después.
Cuerpo de la respuesta
Guardamos hasta 2000 caracteres de tu respuesta en el historial de intentos, para que puedas depurar. Lo que exceda se trunca (response_truncated: true). No devuelvas datos
sensibles en el cuerpo de la respuesta a un webhook.
Estados de una entrega
Auto-deshabilitado y cómo reactivar
Cuando una entrega llega adead porque agotó sus intentos, la suscripción pasa a failing
pero sigue recibiendo. Si acumula 5 entregas muertas consecutivas sin ninguna entrega exitosa
en medio, desactivamos la suscripción: su status pasa a disabled y deja de recibir
entregas. Es deliberado — un endpoint muerto no debe acumular horas de reintentos
indefinidamente — y la reactivación es un acto explícito tuyo, nunca una recuperación silenciosa.
Una entrega exitosa reinicia el conteo.
El campo status de la suscripción (distinto del interruptor active) refleja la salud de
entrega:
Para reactivarla, arregla tu endpoint y haz:
cURL
status a active y limpia last_error. Cambiar la url tiene el mismo efecto:
el error anterior pertenecía al destino anterior.
At-least-once y deduplicación
La entrega es at-least-once: garantizamos que un evento se entrega al menos una vez, no que se entregue exactamente una vez. Puedes recibir el mismo evento más de una vez —por ejemplo, si procesaste la entrega pero tu200 se perdió en el camino, o si tu respuesta tardó más de los
10 segundos de timeout.
Deduplica por event_id. Es el mismo valor en todos los reintentos de una entrega y viene
también en el header X-Ipso-Event-Id:
El cuerpo se congela al momento de publicarse el evento: un reintento 8 horas después envía
exactamente los mismos bytes que el primer intento (la firma cambia porque
t cambia).
Si el CFDI cambió de estado entre tanto, el payload no lo refleja — consulta la API si
necesitas el estado actual.Historial de entregas
Listar entregas
cURL
Ver una entrega con su historial de intentos
cURL
webhook_id, el payload que enviamos (como cadena JSON:
son los bytes exactos sobre los que se calculó la firma) y el detalle de cada intento:
status_code viene en null cuando el intento nunca obtuvo respuesta (timeout, DNS, TLS); en
ese caso response_body lleva el motivo del fallo.
Reenviar una entrega
cURL
202: el reenvío se encola y se entrega igual que una entrega normal (mismo
event_id, mismos bytes, firma nueva).
Solo puedes reenviar entregas en estado delivered o dead. Reenviar una pending o failed
responde 400 — esas ya tienen un reintento programado y duplicar el envío no ayuda. También
responde 400 si la suscripción está apagada: reactívala primero.
Enviar un evento de prueba
cURL
202 y entrega a tu endpoint un evento webhook.test firmado igual que cualquier
otro, con datos de ejemplo. Es la forma de comprobar de punta a punta que tu URL responde y que
tu verificación de firma funciona, sin tener que timbrar un CFDI.
webhook.test se entrega a la suscripción que indiques aunque no lo tengas en events: no
es un código del catálogo al que te suscribas, es una prueba dirigida. Ignóralo (o úsalo como
health check) en tu handler, y no lo trates como un evento de negocio.Gestionar suscripciones
cURL
PATCH, un campo ausente conserva su valor actual; events se reemplaza completo (no
se suma al conjunto existente). Un webhook_id que no sea de tu cuenta responde 404.
Sandbox
Los webhooks funcionan igual en el sandbox: mismo flujo de alta, mismo sobre, misma firma, mismos reintentos.
Las suscripciones de sandbox son independientes de las de producción, igual que las
API keys: una suscripción de sandbox no recibe eventos de producción ni viceversa.
El requisito de
https con host público también aplica en sandbox: no puedes apuntar a
localhost. Para desarrollo local usa un túnel (ngrok, Cloudflare Tunnel) que te dé una URL
https pública, y usa POST /webhooks/{webhook_id}/test para validar el circuito.Buenas prácticas
1
Verifica la firma antes de leer el cuerpo
Sin verificar, tu endpoint acepta avisos de cualquiera. Compara en tiempo constante y
aplica la tolerancia de 5 minutos sobre
t.2
Responde 2xx en menos de 10 segundos
Encola el evento y responde de inmediato. Procesar dentro del handler te expone al timeout
de lectura y convierte trabajo ya hecho en un reintento.
3
Haz tu procesamiento idempotente
Deduplica por
event_id y haz que reprocesar el mismo evento sea inofensivo. La entrega es
at-least-once por diseño.4
No confíes en el orden
Los eventos llegan por su propio camino y con reintentos: un
cfdi.cancelado puede llegar
antes que el cfdi.timbrado del mismo CFDI. Resuelve el estado con el estatus del
payload o consultando GET /cfdi/{id}.5
Trata el aviso como puntero, no como documento
Usa el
id para pedir el XML o el PDF por API cuando los necesites. El payload nunca los
lleva.6
Vigila el status de tus suscripciones
Un
status en failing es la advertencia previa al disabled. Revisa last_error y el
historial de entregas antes de que se agoten los reintentos.