Webhooks de facturación: qué eventos existen y cómo no procesarlos dos veces

· Equipo Factuneo

Un webhook mal integrado duplica facturas en tu ERP o se pierde avisos importantes. Qué eventos emite Factuneo, por qué el nombre del evento importa más que el cuerpo, y cómo deduplicar por id de evento.

El problema de sondear

El envío a la AEAT es asíncrono. Creas la factura, obtienes huella y QR al instante, y el resultado del envío llega después. La tentación es sondear: un cron que cada minuto pregunta GET /invoices/:id por cada factura pendiente.

Funciona con diez facturas. Con mil, estás haciendo mil peticiones por minuto para descubrir que casi ninguna ha cambiado. Los webhooks invierten la relación: te avisamos nosotros cuando pasa algo.

Los seis eventos

El nombre del evento te dice qué ha pasado. Enrutas por event y no necesitas inspeccionar el cuerpo para saber a qué parte de tu código mandarlo:

Evento Cuándo llega
invoice.accepted La AEAT aceptó la factura
invoice.rejected La AEAT la rechazó. El motivo, en data.aeat
invoice.error No se pudo enviar. El porqué, en data.reason
invoice.cancelled La AEAT aceptó una anulación
certificate.expiring Un certificado tuyo caduca en menos de 30 días
certificate.expired Un certificado tuyo ya ha caducado

Los dos últimos son los que más disgustos evitan. Un certificado caducado no da un error bonito: deja de firmar, y las facturas de ese emisor se quedan sin enviar. Enterarte con treinta días de margen es la diferencia entre renovarlo con calma y descubrirlo un viernes por la tarde.

Aceptada con errores es aceptada

La AEAT puede responder AceptadoConErrores: el asiento queda registrado, con avisos subsanables. Eso llega como invoice.accepted con data.acceptedWithWarnings: true, no como error.

Es una distinción que importa. Si tu código trata ese caso como un fallo y reenvía la factura, acabas con un duplicado ante la AEAT: la factura ya estaba presentada. Revisa el aviso, corrige lo que toque en la siguiente, pero no la vuelvas a mandar.

Anulaciones: dos ids, y solo uno te sirve

En VeriFactu una anulación no es un cambio de estado: es un registro nuevo que apunta al original. Eso genera dos identificadores, y es fácil quedarse con el que no es.

En invoice.cancelled, data.invoiceId es la factura original anulada —la que te devolvimos al crearla y la que tienes guardada en tu base de datos— y data.cancellationId es el registro de anulación.

{
  "id": "evt_3f8a1c22-9d41-4b7e-8a55-1f2e3d4c5b6a",
  "event": "invoice.cancelled",
  "environment": "live",
  "data": {
    "invoiceId": "la-factura-que-ya-tienes",
    "cancellationId": "el-registro-de-anulacion",
    "recordType": "ANULACION",
    "status": "ACEPTADO"
  }
}

Y si la anulación falla, recibes invoice.rejected con data.recordType: "ANULACION". Mira ese campo antes de marcar nada: lo que ha fallado es la anulación, no la factura original, que sigue siendo válida.

Deduplicar: el campo id

Todo evento trae un id con el formato evt_.... Guárdalo y descarta los que ya hayas procesado. No es una recomendación de manual: es lo que te protege de contabilizar dos veces la misma factura.

Hay dos motivos por los que un evento puede llegarte repetido:

  1. Tu servidor tardó en responder. Si no contestas 2xx reintentamos hasta cinco veces con espera creciente. Si procesaste el evento pero el 200 se perdió por el camino, el siguiente intento te lo trae otra vez.
  2. Alguien lo reenvió a mano. Desde el panel se puede reenviar cualquier entrega, incluidas las que ya se entregaron correctamente.

En los dos casos el id es el mismo. Lo que cambia es la cabecera X-Webhook-Delivery, que identifica el intento concreto de entrega. Uno sirve para deduplicar; el otro, para soporte.

app.post('/webhooks/factuneo', (req, res) => {
  const { id, event, data } = req.body

  // Idempotencia: la clave primaria hace el trabajo.
  if (yaProcesado(id)) return res.sendStatus(204)

  switch (event) {
    case 'invoice.accepted': marcarAceptada(data.invoiceId); break
    case 'invoice.rejected': marcarRechazada(data.invoiceId, data.aeat); break
    case 'invoice.cancelled': marcarAnulada(data.invoiceId); break
  }

  guardarProcesado(id)
  res.sendStatus(204)
})

La forma más simple de yaProcesado es una tabla con el id como clave primaria: insertas, y si choca es que ya estaba. Te ahorra pensar en carreras entre dos entregas simultáneas.

Reenviar un evento ya entregado

Que una entrega figure como Entregado no significa que tu sistema lo procesara. El caso típico: tu endpoint respondió 200, encoló el mensaje internamente y algo se lo comió después —una cola que se vació en un despliegue, un bug que ya arreglaste—.

Por eso el botón Reenviar del panel está en todas las entregas, no solo en las fallidas. Manda el mismo payload, con el mismo id, a la misma URL. Si deduplicas correctamente, reenviar es inofensivo: eso es justo lo que te permite usarlo sin miedo cuando lo necesitas.

El sandbox no se cuela en producción

Factuneo tiene entorno de pruebas: las claves vsk_test_ van contra la preproducción de la AEAT, sin coste ni cómputo de facturas.

Los webhooks de prueba llegan a la misma URL que los reales —no tienes que montar un segundo endpoint—, pero se distinguen de dos formas:

Lo segundo es lo importante. Si tu ERP de producción solo conoce el secreto live, un evento de sandbox no valida la firma y lo rechazas automáticamente. No depende de que alguien se acuerde de comprobar un campo: si te equivocas de entorno, el sistema te protege solo.

Cada secreto se obtiene en GET /api/v1/account, autenticando con la clave del entorno que quieras consultar.

Verifica siempre la firma

La cabecera X-Webhook-Signature es el HMAC-SHA256 del cuerpo tal y como llega, en hexadecimal y sin prefijos. Dos detalles que rompen integraciones:

Las demás cabeceras —X-Webhook-Id, X-Webhook-Event, X-Webhook-Environment— duplican campos que ya van dentro del cuerpo firmado. Están para que puedas enrutar sin parsear. Para decidir algo importante, léelo del cuerpo: es lo único que la firma protege.

Resumen

La referencia completa —todos los campos de cada evento, códigos de reason y ejemplos de verificación en varios lenguajes— está en la documentación.

← Volver al blog · Factuneo