> ## Documentation Index
> Fetch the complete documentation index at: https://developers.ipsofactura.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Recibe notificaciones firmadas cuando un CFDI se timbra, se cancela, se registra un pago o un CSD está por vencer.

## 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.

<Note>
  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.
</Note>

<Warning>
  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.
</Warning>

## Alta de una suscripción

```bash cURL theme={null}
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 timbrado",
    "events": ["cfdi.timbrado", "cfdi.cancelado"]
  }'
```

| Campo         | Requerido | Descripción                                                                                                                                         |
| ------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`         | Sí        | Endpoint `https` al que entregaremos los eventos. Máximo 255 caracteres.                                                                            |
| `events`      | Sí        | Lista de códigos del [catálogo](#catálogo-de-eventos). Al menos uno.                                                                                |
| `description` | No        | Texto libre para identificar la suscripción. Máximo 255 caracteres.                                                                                 |
| `company_id`  | No        | Filtra las entregas a una sola empresa emisora (el `id` que devuelve `GET /empresas`). Si lo omites, recibes los eventos de **todas** tus empresas. |

Respuesta `201`:

```json theme={null}
{
  "id": "3f2a91c4-2b6d-4a8e-9f01-5c7d2e8b4a10",
  "secret_key": "whsec_live_4Kq2mZ8xR1vTb...",
  "secret_key_prefix": "whsec_live_4",
  "url": "https://api.ejemplo.com/webhooks/ipsofactura",
  "description": "Notificaciones de timbrado",
  "events": ["cfdi.cancelado", "cfdi.timbrado"],
  "active": true,
  "version": "v1"
}
```

<Warning>
  `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.
</Warning>

### 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](/sandbox).

### Requisitos de la URL

La URL se valida al crearla y **otra vez en cada entrega** (para cerrar un DNS rebinding).
Rechazamos:

| Regla                                                                                                    | Motivo                                                           |
| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Solo `https`                                                                                             | Las entregas llevan datos fiscales; no entregamos en claro.      |
| Sin credenciales en la URL (`https://user:pass@…`)                                                       | El secreto de firma es el mecanismo de autenticación, no la URL. |
| Sin `localhost`, `*.localhost`, `*.local`, `*.internal`                                                  | No son alcanzables desde fuera de tu red.                        |
| Sin IPs privadas o reservadas: `10.x`, `127.x`, `172.16–31.x`, `192.168.x`, `169.254.x`, `0.x`, `224.x+` | Defensa contra SSRF.                                             |
| Sin literales IPv6 (`https://[2001:db8::1]/…`)                                                           | Un endpoint público se publica bajo un hostname.                 |
| Máximo 255 caracteres                                                                                    |                                                                  |

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

| Código                        | Cuándo se emite                                                        | Campos de `data`                                                                      |
| ----------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `cfdi.timbrado`               | Un CFDI se timbró correctamente (`POST /cfdi/timbrar`).                | base + `stamped_at`                                                                   |
| `pago.timbrado`               | Se timbró un complemento de pago (CFDI tipo P) contra una factura PPD. | base + `stamped_at`                                                                   |
| `cfdi.cancelado`              | Una cancelación quedó **firme** ante el SAT de forma inmediata.        | base + `motivo_cancelacion`, `estatus_cancelacion`, `cancelled_at`                    |
| `cfdi.cancelacion_en_proceso` | La cancelación quedó **en espera de la aceptación del receptor**.      | base + `motivo_cancelacion`, `estatus_cancelacion`, `cancelled_at`                    |
| `cfdi.cancelacion_rechazada`  | Una cancelación en proceso terminó **rechazada**.                      | base + `motivo_cancelacion`, `estatus_cancelacion`, `cancelled_at`                    |
| `csd.por_vencer`              | Al CSD activo de una empresa le quedan 30, 15 o 7 días de vigencia.    | `company_id`, `rfc`, `numero_certificado`, `valido_hasta`, `dias_restantes`, `umbral` |

Los campos **base**, presentes en todos los eventos de CFDI:

| Campo                        | Descripción                                                                  |
| ---------------------------- | ---------------------------------------------------------------------------- |
| `id`                         | Identificador interno del CFDI. Úsalo para `GET /cfdi/{id}`, `/xml`, `/pdf`. |
| `uuid`                       | Folio fiscal del SAT.                                                        |
| `serie`, `folio`             | Serie y folio del comprobante, si los usas.                                  |
| `rfc_emisor`, `rfc_receptor` | RFCs del comprobante.                                                        |
| `total`                      | Total del comprobante.                                                       |
| `estatus`                    | Estatus del CFDI: `timbrado`, `cancelado`, `en_proceso`, `rechazado`.        |

<Note>
  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.
</Note>

<Note>
  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](#aviso-de-vencimiento-del-csd).
</Note>

## Forma del payload

Toda entrega es un `POST` con `Content-Type: application/json` y este sobre:

```json theme={null}
{
  "event_id": "8c1f0b6e-5a24-4d31-9b77-0e2a6c4d1f93",
  "event": "cfdi.timbrado",
  "version": "v1",
  "created_at": "2026-08-26T18:42:07.512Z",
  "data": { }
}
```

| Campo del sobre | Descripción                                                                                          |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| `event_id`      | Identificador único de **esta entrega**. Es el mismo en todos los reintentos: úsalo para deduplicar. |
| `event`         | Código del catálogo.                                                                                 |
| `version`       | Versión del contrato del payload. Hoy siempre `v1`.                                                  |
| `created_at`    | Instante en que se generó el evento, ISO 8601 UTC. **No** es el instante del intento.                |
| `data`          | Cuerpo específico del evento.                                                                        |

Además, cada request lleva estos headers:

| Header             | Contenido                                                                              |
| ------------------ | -------------------------------------------------------------------------------------- |
| `X-Ipso-Signature` | `t=<epoch>,v1=<hmac_hex>` — ver [Verificación de la firma](#verificación-de-la-firma). |
| `X-Ipso-Event-Id`  | El mismo valor que `event_id` del cuerpo, para deduplicar sin parsear el JSON.         |

### Ejemplos por evento

<CodeGroup>
  ```json cfdi.timbrado theme={null}
  {
    "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",
      "serie": "A",
      "folio": 1042,
      "rfc_emisor": "EKU9003173C9",
      "rfc_receptor": "XAXX010101000",
      "total": 1160.00,
      "estatus": "timbrado",
      "stamped_at": "2026-08-26T18:42:06Z"
    }
  }
  ```

  ```json pago.timbrado theme={null}
  {
    "event_id": "b4d9e2a1-7c30-4f58-8a12-91cf35e7d604",
    "event": "pago.timbrado",
    "version": "v1",
    "created_at": "2026-08-26T19:05:44.108Z",
    "data": {
      "id": "77c1e0f2-3a45-4b6c-8d9e-0f1a2b3c4d5e",
      "uuid": "9B3D71A8-2E44-4C10-B0F5-7A62D9E4C118",
      "serie": "P",
      "folio": 318,
      "rfc_emisor": "EKU9003173C9",
      "rfc_receptor": "XAXX010101000",
      "total": 0,
      "estatus": "timbrado",
      "stamped_at": "2026-08-26T19:05:43Z"
    }
  }
  ```

  ```json cfdi.cancelado theme={null}
  {
    "event_id": "c7a52f18-9d06-4e73-bb21-4c8f0a5e6d92",
    "event": "cfdi.cancelado",
    "version": "v1",
    "created_at": "2026-08-26T20:11:02.774Z",
    "data": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "uuid": "6128396F-C09B-4EC6-8699-43DA5A244971",
      "serie": "A",
      "folio": 1042,
      "rfc_emisor": "EKU9003173C9",
      "rfc_receptor": "XAXX010101000",
      "total": 1160.00,
      "estatus": "cancelado",
      "motivo_cancelacion": "02",
      "estatus_cancelacion": "201",
      "cancelled_at": "2026-08-26T20:11:01Z"
    }
  }
  ```

  ```json cfdi.cancelacion_en_proceso theme={null}
  {
    "event_id": "d0e13b74-6f29-4a85-9c30-2b7e5d1a8f46",
    "event": "cfdi.cancelacion_en_proceso",
    "version": "v1",
    "created_at": "2026-08-26T20:14:39.220Z",
    "data": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "uuid": "6128396F-C09B-4EC6-8699-43DA5A244971",
      "serie": "A",
      "folio": 1042,
      "rfc_emisor": "EKU9003173C9",
      "rfc_receptor": "TME960709KP0",
      "total": 1160.00,
      "estatus": "en_proceso",
      "motivo_cancelacion": "02",
      "estatus_cancelacion": "En proceso",
      "cancelled_at": "2026-08-26T20:14:38Z"
    }
  }
  ```
</CodeGroup>

## 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:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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ó.
  </Step>
</Steps>

Los tres eventos comparten el mismo cuerpo `data` — los campos **base** más:

| Campo                 | Descripción                                                                                                                      |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `motivo_cancelacion`  | El motivo enviado en `POST /cfdi/cancelar` (`01`–`04`).                                                                          |
| `estatus_cancelacion` | El detalle del SAT en ese momento (`"En proceso"`, `"Solicitud rechazada"`, `"Cancelado con aceptacion"`, `"Plazo vencido"`, …). |
| `cancelled_at`        | Instante en que se solicitó la cancelación.                                                                                      |

<Note>
  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.
</Note>

### Timbrado tardío: `cfdi.timbrado` y `pago.timbrado`

Un timbrado que respondió `503 STAMPING_OUTCOME_UNKNOWN` (ver
[Recuperación tras timeout](/recuperacion-timeouts))
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.

| Ruta que respondió `503`                                                 | Evento tardío   |
| ------------------------------------------------------------------------ | --------------- |
| `POST /cfdi/timbrar`, `/cfdi/timbrar-xml`, `/cfdi/timbrar-sellado`       | `cfdi.timbrado` |
| `POST /cfdi/complemento-pago` (y un CFDI tipo P por `/cfdi/timbrar-xml`) | `pago.timbrado` |

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](/complemento-pago#si-el-timbrado-del-rep-no-responde).

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`

| Campo                | Descripción                                                                                  |
| -------------------- | -------------------------------------------------------------------------------------------- |
| `company_id`         | Empresa dueña del CSD. Úsalo para `GET /empresas/{id}` y para el alta del certificado nuevo. |
| `rfc`                | RFC de la empresa emisora.                                                                   |
| `numero_certificado` | Número de certificado (20 dígitos) del CSD que está por vencer.                              |
| `valido_hasta`       | Fin de vigencia del certificado, fecha ISO 8601 (`YYYY-MM-DD`).                              |
| `dias_restantes`     | Días completos de vigencia que le quedan el día del aviso.                                   |
| `umbral`             | El umbral que disparó este aviso: `30`, `15` o `7`.                                          |

```json csd.por_vencer theme={null}
{
  "event_id": "e5f24c90-1a83-4d67-b2c5-8d90fa316e7b",
  "event": "csd.por_vencer",
  "version": "v1",
  "created_at": "2026-08-26T06:15:03.041Z",
  "data": {
    "company_id": "3f8c1d52-9b64-4a07-8e13-c25d70a9b481",
    "rfc": "EKU9003173C9",
    "numero_certificado": "30001000000500003416",
    "valido_hasta": "2026-09-02",
    "dias_restantes": 7,
    "umbral": 7
  }
}
```

<Note>
  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.
</Note>

<Warning>
  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.
</Warning>

## 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:

```
X-Ipso-Signature: t=1774556527,v1=5d41402abc4b2a76b9719d911017c592...
```

* `t` — el instante del intento, en **segundos desde epoch**.
* `v1` — `HMAC-SHA256`, en hexadecimal minúscula, calculado sobre la cadena
  `"<t>.<cuerpo_crudo>"` usando tu `secret_key` como llave.

<Warning>
  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()`.
</Warning>

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'crypto';
  import express from 'express';

  const app = express();
  const SECRET = process.env.IPSO_WEBHOOK_SECRET; // whsec_live_...
  const TOLERANCIA_SEGUNDOS = 5 * 60;

  function verificar(rawBody, header, secret) {
    if (!header) return false;

    // t=<epoch>,v1=<hex>
    const partes = Object.fromEntries(
      header.split(',').map((p) => p.split('=', 2))
    );
    const t = partes.t;
    const recibida = partes.v1;
    if (!t || !recibida) return false;

    // 1. Tolerancia contra replay
    const edad = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
    if (!Number.isFinite(edad) || edad > TOLERANCIA_SEGUNDOS) return false;

    // 2. HMAC-SHA256 sobre "<t>.<cuerpo_crudo>"
    const esperada = crypto
      .createHmac('sha256', secret)
      .update(`${t}.${rawBody.toString('utf8')}`)
      .digest('hex');

    // 3. Comparación en tiempo constante
    const a = Buffer.from(esperada, 'utf8');
    const b = Buffer.from(recibida, 'utf8');
    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'), SECRET)) {
        return res.status(400).send('firma inválida');
      }

      const evento = JSON.parse(req.body.toString('utf8'));

      // Responde YA; procesa en segundo plano.
      res.status(200).send('ok');
      encolar(evento); // dedupe por evento.event_id
    }
  );
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  from flask import Flask, request

  app = Flask(__name__)
  SECRET = os.environ["IPSO_WEBHOOK_SECRET"]  # whsec_live_...
  TOLERANCIA_SEGUNDOS = 5 * 60


  def verificar(raw_body: bytes, header: str | None, secret: str) -> bool:
      if not header:
          return False

      # t=<epoch>,v1=<hex>
      try:
          partes = dict(p.split("=", 1) for p in header.split(","))
          t = partes["t"]
          recibida = partes["v1"]
      except (ValueError, KeyError):
          return False

      # 1. Tolerancia contra replay
      try:
          if abs(int(time.time()) - int(t)) > TOLERANCIA_SEGUNDOS:
              return False
      except ValueError:
          return False

      # 2. HMAC-SHA256 sobre "<t>.<cuerpo_crudo>"
      firmado = t.encode("utf-8") + b"." + raw_body
      esperada = hmac.new(
          secret.encode("utf-8"), firmado, hashlib.sha256
      ).hexdigest()

      # 3. Comparación en tiempo constante
      return hmac.compare_digest(esperada, recibida)


  @app.post("/webhooks/ipsofactura")
  def recibir():
      raw = request.get_data()  # bytes crudos, sin parsear
      if not verificar(raw, request.headers.get("X-Ipso-Signature"), SECRET):
          return "firma inválida", 400

      evento = json.loads(raw)
      encolar(evento)  # dedupe por evento["event_id"]
      return "ok", 200
  ```
</CodeGroup>

### 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** (`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:

| Intento | Cuándo                                                    |
| ------- | --------------------------------------------------------- |
| 1       | Inmediato (justo después de que la operación se confirma) |
| 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`**: no se reintenta más de forma
automática (puedes [reenviarla a mano](#reenviar-una-entrega)). El lapso total desde el primer
intento hasta el último es de poco más de **10 horas y media**.

### Timeouts

| Límite                  | Valor       |
| ----------------------- | ----------- |
| Conexión                | 3 segundos  |
| Lectura de la respuesta | 10 segundos |

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

| Estado      | Significado                                                                                   |
| ----------- | --------------------------------------------------------------------------------------------- |
| `pending`   | Encolada, aún sin intentos (o esperando el primero).                                          |
| `delivered` | Tu endpoint respondió 2xx. Terminada.                                                         |
| `failed`    | Un intento falló y **habrá reintento**; `next_attempt_at` dice cuándo.                        |
| `dead`      | Se agotaron los 6 intentos (o la suscripción dejó de aceptar entregas). No se reintenta sola. |

### 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:

| `status`   | Significado                                                                             |
| ---------- | --------------------------------------------------------------------------------------- |
| `active`   | Sana: la última entrega funcionó.                                                       |
| `failing`  | Hay entregas fallando (con reintentos en vuelo o ya muertas). Sigue recibiendo.         |
| `disabled` | Apagada por la plataforma tras 5 entregas muertas consecutivas. **No** recibe entregas. |

Para reactivarla, arregla tu endpoint y haz:

```bash cURL theme={null}
curl -X PATCH https://api.ipsofactura.com/webhooks/{webhook_id} \
  -H "x-api: {tu_api_key}" \
  -H "Content-Type: application/json" \
  -d '{"active": true}'
```

Esto devuelve `status` a `active` y limpia `last_error`. Cambiar la `url` tiene el mismo efecto:
el error anterior pertenecía al destino anterior.

<Warning>
  Las entregas que quedaron `dead` mientras la suscripción estaba apagada **no se reenvían
  solas** al reactivarla. Reenvíalas con [`POST .../resend`](#reenviar-una-entrega) o
  reconstruye el estado consultando la API.
</Warning>

## 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`:

```javascript theme={null}
const yaProcesado = await redis.set(
  `ipso:webhook:${evento.event_id}`,
  '1',
  { NX: true, EX: 60 * 60 * 24 * 7 } // 7 días > el horizonte de reintentos
);
if (!yaProcesado) return; // duplicado: ignóralo
```

Un TTL de una semana cubre con margen las \~10.5 horas del ciclo de reintentos y los reenvíos
manuales.

<Note>
  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.
</Note>

## Historial de entregas

### Listar entregas

```bash cURL theme={null}
curl "https://api.ipsofactura.com/webhooks/{webhook_id}/deliveries?status=failed&per_page=20" \
  -H "x-api: {tu_api_key}"
```

| Parámetro  | Descripción                                           |
| ---------- | ----------------------------------------------------- |
| `page`     | Página a consultar.                                   |
| `per_page` | Tamaño de página.                                     |
| `status`   | Filtra por `pending`, `delivered`, `failed` o `dead`. |

```json theme={null}
{
  "deliveries": [
    {
      "id": "8c1f0b6e-5a24-4d31-9b77-0e2a6c4d1f93",
      "event": "cfdi.timbrado",
      "status": "failed",
      "attempt_count": 3,
      "last_status_code": 502,
      "last_error": "HTTP 502",
      "last_attempt_at": "2026-08-26T18:48:07Z",
      "next_attempt_at": "2026-08-26T19:18:07Z",
      "created_at": "2026-08-26T18:42:07Z"
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 20,
    "total": 1,
    "total_pages": 1
  }
}
```

### Ver una entrega con su historial de intentos

```bash cURL theme={null}
curl https://api.ipsofactura.com/webhooks/{webhook_id}/deliveries/{delivery_id} \
  -H "x-api: {tu_api_key}"
```

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:

```json theme={null}
{
  "id": "8c1f0b6e-5a24-4d31-9b77-0e2a6c4d1f93",
  "webhook_id": "3f2a91c4-2b6d-4a8e-9f01-5c7d2e8b4a10",
  "event": "cfdi.timbrado",
  "status": "failed",
  "attempt_count": 2,
  "last_status_code": 502,
  "last_error": "HTTP 502",
  "last_attempt_at": "2026-08-26T18:43:07Z",
  "next_attempt_at": "2026-08-26T18:48:07Z",
  "created_at": "2026-08-26T18:42:07Z",
  "payload": "{\"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\"}}",
  "attempts": [
    {
      "id": "1d7c4b2a-3e58-4069-a1b7-9f0e6d5c4b3a",
      "attempt_number": 1,
      "status_code": 502,
      "response_body": "<html><head><title>502 Bad Gateway</title></head>...",
      "response_truncated": false,
      "succeeded": false,
      "attempted_at": "2026-08-26T18:42:07Z"
    },
    {
      "id": "2e8d5c3b-4f69-4170-b2c8-0a1f7e6d5c4b",
      "attempt_number": 2,
      "status_code": null,
      "response_body": "ResourceAccessException: I/O error on POST request",
      "response_truncated": false,
      "succeeded": false,
      "attempted_at": "2026-08-26T18:43:07Z"
    }
  ]
}
```

`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

```bash cURL theme={null}
curl -X POST https://api.ipsofactura.com/webhooks/{webhook_id}/deliveries/{delivery_id}/resend \
  -H "x-api: {tu_api_key}"
```

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

```bash cURL theme={null}
curl -X POST https://api.ipsofactura.com/webhooks/{webhook_id}/test \
  -H "x-api: {tu_api_key}"
```

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.

<Note>
  `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.
</Note>

## Gestionar suscripciones

```bash cURL theme={null}
# Listar (nunca devuelve el secreto, solo su prefijo)
curl https://api.ipsofactura.com/webhooks -H "x-api: {tu_api_key}"

# Actualizar parcialmente: url, events, active y/o description
curl -X PATCH https://api.ipsofactura.com/webhooks/{webhook_id} \
  -H "x-api: {tu_api_key}" -H "Content-Type: application/json" \
  -d '{"events": ["cfdi.timbrado", "cfdi.cancelado", "pago.timbrado"]}'

# Eliminar (204)
curl -X DELETE https://api.ipsofactura.com/webhooks/{webhook_id} \
  -H "x-api: {tu_api_key}"
```

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](/sandbox): mismo flujo de alta, mismo sobre, misma
firma, mismos reintentos.

| Diferencia            | Sandbox                                                        |
| --------------------- | -------------------------------------------------------------- |
| Base URL              | `https://sandbox.api.ipsofactura.com`                          |
| Prefijo del secreto   | `whsec_test_` en lugar de `whsec_live_`                        |
| Origen de los eventos | El timbrado simulado dispara `cfdi.timbrado` igual que el real |

Las suscripciones de sandbox son independientes de las de producción, igual que las
[API keys](/api-keys): una suscripción de sandbox no recibe eventos de producción ni viceversa.

<Note>
  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.
</Note>

## Buenas prácticas

<Steps>
  <Step title="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`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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}`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>
