ipsofacturaHablar con Ventas →
← Blog
WebhooksAPIIntegración

Cómo integrar webhooks de timbrado CFDI: guía completa para desarrolladores

|10 min de lectura

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.

Flujo de webhooks para timbrado y cancelación de CFDI de nóminaEl sistema de nómina solicita el timbrado y recibe el UUID en la respuesta. Ipsofactura envía eventos para el timbrado, la cancelación inmediata o el resultado de una cancelación que requiere aceptación.Sistema de nóminaPOST /cfdi/timbrarIpsofacturaRespuesta síncrona con UUIDcfdi.timbradoNómina lista para dispersiónSolicitud de cancelaciónPOST /cfdi/cancelarcfdi.canceladoCancelación inmediatacancelacion_en_procesoEspera al receptor, hasta 72 hcfdi.canceladoocancelacion_rechazadaresultado firmeresultado asíncrono
Verde: evento definitivo. Naranja: estado pendiente que tendrá un desenlace posterior.
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"
    ]
  }'
Guarda el secreto
La respuesta de creación muestra 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

EventoCuándo se emite
cfdi.timbradoCuando un CFDI se timbra correctamente, incluso si se confirma después de un timeout.
pago.timbradoCuando se timbra un complemento de pago contra una factura PPD.
cfdi.canceladoCuando la cancelación queda firme ante el SAT.
cfdi.cancelacion_en_procesoCuando el SAT espera la aceptación o el rechazo del receptor.
cfdi.cancelacion_rechazadaCuando el receptor rechaza una cancelación pendiente.
csd.por_vencerCuando 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", 200
Evita ataques de replay
Rechaza timestamps con más de 5 minutos de diferencia y compara el HMAC en tiempo constante. Mantén sincronizado el reloj de tu servidor.

Reintentos: 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.

IntentoMomento
1Inmediato
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

  1. Registra una URL HTTPS pública y selecciona solo los eventos que consumes.
  2. Guarda secret_key cuando creas la suscripción; se muestra una sola vez.
  3. Conserva el cuerpo crudo y valida HMAC-SHA256 antes de parsear el JSON.
  4. Rechaza firmas con timestamps fuera de la tolerancia de 5 minutos.
  5. Deduplica cada entrega por event_id y vuelve idempotente el procesamiento.
  6. Responde 2xx en menos de 10 segundos y procesa en segundo plano.
  7. 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.

¿Listo para automatizar?

Empieza a timbrar en menos de 2 horas.

Primeros 100 timbres sin costo. Sandbox listo. Sin contrato anual.

Hablar con Ventas →

Más artículos

8 min · API
Cómo integrar una API de facturación SAT en tu sistema
10 min · Errores
Errores de timbrado SAT: guía para desarrolladores