Skip to main content

Qué son y cuándo usarlos

Un webhook es una suscripción: registras una URL tuya y nosotros te hacemos un POST 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_proceso y 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.
Los webhooks no sustituyen la respuesta de la API. POST /cfdi/timbrar te devuelve el UUID de forma síncrona; la API sigue siendo la fuente de verdad. Si una entrega se pierde, tu integración debe poder reconstruir el estado consultando la API.

Alta de una suscripción

cURL
Respuesta 201:
secret_key se muestra una sola vez, en esta respuesta. Guárdalo en tu gestor de secretos: lo necesitas para verificar la firma de cada entrega y ninguna consulta posterior lo devuelve — solo verás secret_key_prefix (los primeros 13 caracteres). Si lo pierdes, borra la suscripción y crea una nueva.

Reglas del alta

  • Máximo 10 webhooks activos por cuenta. Al llegar al tope, el alta responde 400 VALIDATION_ERROR; borra o desactiva uno que ya no uses.
  • Un código de evento desconocido responde 400 con el catálogo completo de códigos válidos en el message.
  • Si envías company_id de una empresa que no es tuya, la respuesta es EMPRESA_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 un POST 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ó.
Los tres eventos comparten el mismo cuerpo 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_restantes y umbral no siempre coinciden. umbral es el aviso que se está emitiendo (30, 15 o 7); dias_restantes es la vigencia real ese día. En el caso anterior el aviso de umbral: 30 llega con dias_restantes: 12. Para decidir la urgencia lee dias_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_id de la suscripción, igual que el resto de los eventos: si registraste el webhook con company_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.
Tres avisos por certificado es poco margen si tu endpoint estuvo caído esos tres días. El aviso es una comodidad, no un calendario: GET /empresas/{id}/certificados te da fecha_fin cuando quieras y sigue siendo la fuente de verdad.

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.
  • v1HMAC-SHA256, en hexadecimal minúscula, calculado sobre la cadena "<t>.<cuerpo_crudo>" usando tu secret_key como llave.
Firma el cuerpo crudo (los bytes exactos que recibiste). Si haces JSON.parse y vuelves a serializar, el orden de las llaves o el espaciado cambian y la firma no coincidirá nunca. En Express usa express.raw(), no express.json().

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:
  • t es del intento, no del evento. Un reintento a las 8 horas llega con un t fresco y una firma nueva sobre el mismo cuerpo, así que pasa la tolerancia sin problema.
  • El created_at del cuerpo sí puede ser mucho más viejo que t. 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 (200299). 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 a dead 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
Esto devuelve status a active y limpia last_error. Cambiar la url tiene el mismo efecto: el error anterior pertenecía al destino anterior.
Las entregas que quedaron dead mientras la suscripción estaba apagada no se reenvían solas al reactivarla. Reenvíalas con POST .../resend o reconstruye el estado consultando la API.

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 tu 200 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:
Un TTL de una semana cubre con margen las ~10.5 horas del ciclo de reintentos y los reenvíos manuales.
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
Devuelve lo mismo que el listado más 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
Responde 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
Responde 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
En el 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.