> ## Documentation Index
> Fetch the complete documentation index at: https://ac-core.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Recibe los cambios de estado de tus e-CF y verifica cada entrega.

Registra una URL HTTPS de tu servidor y te avisamos cuando cambia el estado de un e-CF ante la DGII, sin que tengas que consultar.

## Registrar un webhook

```bash theme={null}
curl -X POST https://e-cf.acpay.do/sandbox/v1/webhooks \
  -H "Authorization: Bearer $ACPAY_TEST_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://erp.example.com/webhooks/ecf", "events": ["ecf.accepted", "ecf.rejected", "ecf.cancelled"] }'
```

* `url`: HTTPS, hasta 2048 caracteres. Solo entregamos a direcciones públicas en el puerto 443 u 8443, y no seguimos redirecciones.
* `events`: al menos uno de `ecf.accepted`, `ecf.rejected` y `ecf.cancelled`, sin repetir.

> **El `secret` solo se devuelve en esta respuesta.** Guárdalo de inmediato en tu gestor de secretos: con él verificas cada entrega. Si lo pierdes, borra el webhook y crea otro.

Para probar tu endpoint: `POST /v1/webhooks/{id}/test` envía un evento `test` y te dice si respondió `2xx`. Para pausarlo sin borrarlo: `PATCH /v1/webhooks/{id}` con `{ "active": false }`. `GET /v1/webhooks/{id}` muestra las entregas recientes con su estado (`delivered`, `pending` o `failed`) y sus intentos.

**Mientras desarrollas en tu computadora**, tu servidor local no es alcanzable desde internet. Expón tu puerto con un túnel HTTPS (por ejemplo, el de tu proveedor de nube o una herramienta de túneles) y registra esa URL en el sandbox. No lo dejes abierto más de lo necesario.

## Qué recibes

Un `POST` con cuerpo JSON:

```json theme={null}
{
  "event": "ecf.accepted",
  "data": {
    "documentType": "invoice",
    "id": "3f1c2b9e-8a4d-4f6e-9c1a-2b7d5e8f0a13",
    "encf": "E310000000001",
    "ecfType": 31,
    "status": "accepted",
    "statusMessage": null,
    "invoiceId": null,
    "occurredAt": "2026-09-27T14:15:00.000Z"
  },
  "timestamp": "2026-09-27T14:15:01.000Z",
  "deliveryId": "b3d4f5a6-1c2e-4f7a-8b9c-0d1e2f3a4b5c"
}
```

| Campo | Contenido |
| - | - |
| `event` | `ecf.accepted`, `ecf.rejected`, `ecf.cancelled` o `test`. |
| `data.documentType` | `invoice`, `credit_note`, `debit_note` o `special_document`. |
| `data.status` / `data.statusMessage` | El nuevo estado y su detalle (por ejemplo, el motivo del rechazo). |
| `data.invoiceId` | En las notas, la factura que modifican; `null` en el resto. |
| `timestamp` | Cuándo se envió esta entrega. |
| `deliveryId` | Id de la entrega; se repite en los reintentos. |

| Encabezado | Contenido |
| - | - |
| `X-Acpay-Signature` | `sha256=<hex>`: HMAC-SHA256 del cuerpo **crudo** con el secreto del webhook. |
| `X-Acpay-Event` | El mismo `event` del cuerpo. |
| `X-Acpay-Delivery` | El mismo `deliveryId` del cuerpo. |

Los eventos son livianos: para el detalle consulta el documento según `data.documentType`, con `GET /v1/invoices/{id}`, `GET /v1/notes/{id}` o `GET /v1/special-documents/{id}`.

## Verifica cada entrega

Cualquiera puede enviar un `POST` a tu URL. Antes de procesar una entrega:

1. **Verifica la firma** sobre el cuerpo crudo, antes de parsear el JSON (re-serializarlo cambia los bytes y la firma), con una comparación en tiempo constante. Si no coincide, responde `401` y descártala.
2. **Rechaza entregas viejas:** si `timestamp` tiene más de 5 minutos.
3. **Deduplica por `deliveryId`:** un reintento trae el mismo `deliveryId`; si ya lo procesaste, responde `200` sin repetir el trabajo.

```js theme={null}
const crypto = require('crypto');
const express = require('express');

const WEBHOOK_SECRET = process.env.ECF_WEBHOOK_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000;

const app = express();

// express.raw: se necesita el cuerpo crudo para verificar la firma.
app.post(
  '/webhooks/ecf',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = Buffer.from(
      'sha256=' +
        crypto
          .createHmac('sha256', WEBHOOK_SECRET)
          .update(req.body)
          .digest('hex'),
    );
    const received = Buffer.from(req.get('X-Acpay-Signature') ?? '');
    if (
      received.length !== expected.length ||
      !crypto.timingSafeEqual(received, expected)
    ) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    const age = Date.now() - Date.parse(payload.timestamp);
    if (!Number.isFinite(age) || Math.abs(age) > MAX_AGE_MS) {
      return res.sendStatus(401);
    }

    // Deduplica por payload.deliveryId y procesa en segundo plano.
    res.sendStatus(200);
  },
);
```

## Responde rápido

Responde `2xx` en menos de 10 segundos; si tu proceso tarda más, encólalo y responde de inmediato. Cualquier otra respuesta, un error de conexión o pasar de 10 segundos cuentan como fallo.

Después de cada fallo esperamos antes del siguiente intento: 1 minuto, luego 5 minutos, 30 minutos, 2 horas y 24 horas, hasta 6 intentos en total. Las esperas se suman: el último intento llega unas 26 horas y 36 minutos después del primero. Cada espera puede alargarse hasta un minuto.

## Garantías de entrega

* **Cada intento se firma de nuevo**, con un `timestamp` nuevo, y conserva el mismo `deliveryId`. Por eso un reintento horas después pasa la regla de los 5 minutos.
* **Un `deliveryId` por webhook y por evento.** Si dos webhooks tuyos reciben el mismo evento, cada uno tiene su propio `deliveryId`.
* **Guarda los `deliveryId` procesados al menos 27 horas,** lo que dura el ciclo completo de reintentos. Para no procesar dos veces, marca el `deliveryId` y encola el trabajo en la misma transacción de tu base de datos.
* **No garantizamos el orden.** Un reintento puede llegar después de un evento más reciente. Usa `data.occurredAt` o consulta el documento para saber su estado actual.
* **Al menos una vez, no exactamente una vez:** una entrega que tu servidor procesó, pero cuya respuesta no nos llegó, se repite con el mismo `deliveryId`.
* **El secreto** son 64 caracteres hexadecimales. Úsalo tal cual, como texto, como clave del HMAC (no lo decodifiques a bytes). Si reintentas el registro con la misma `Idempotency-Key` y el mismo cuerpo, la respuesta vuelve a traer el mismo secreto.
* **Si se agotan los intentos,** la entrega queda como `failed` en `GET /v1/webhooks/{id}`. Para no perder estados, concilia periódicamente con `GET /v1/invoices`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.