Webhooks

La remisión a la AEAT es asíncrona. Un webhook avisa a tu sistema cuando un registro termina —aceptado, rechazado, subsanado— sin que tengas que sondear la API.

Crear una suscripción

Desde la consola (Webhooks → Nuevo) o con POST https://verifactu-api.zctechpartners.cloud/v1/webhooks y una clave administrativa: una URL HTTPS de tu sistema y la lista de eventos. La respuesta incluye una sola vez el secret de firma; guárdalo en la configuración del servidor que recibe las entregas.

POST https://verifactu-api.zctechpartners.cloud/v1/webhooks
{
  "url": "https://tu-sistema.com/webhooks/verifactu",
  "eventos": ["registro.aceptado", "registro.aceptado_con_errores", "registro.rechazado", "registro.subsanado", "registro.reenvio_agotado"]
}
Las suscripciones pertenecen a la cuenta, no a un entorno. Si tienes obligados en preproducción y en producción, la misma URL recibirá los eventos de los dos; distingue por data.obligado_nif y por el registro consultado.

Eventos

typeCuándo se emite
registro.aceptadoLa AEAT aceptó el registro (EstadoRegistro Correcto).
registro.aceptado_con_erroresAceptado con incidencias no bloqueantes (AceptadoConErrores). Conviene revisar el detalle.
registro.rechazadoRechazado por la AEAT (Incorrecto). data.codigo trae el código de error.
registro.subsanadoSe emitió y aceptó la subsanación de un registro rechazado.
registro.reenvio_agotadoSe agotaron los reintentos de envío: nada quedó registrado en la AEAT y hace falta intervenir.
obligado.certificado_por_caducarEl certificado de un obligado caduca pronto.
obligado.legitimacion_fallidaLa AEAT rechazó la legitimación del certificado o del apoderamiento.

La entrega

Cada evento es un POST a tu URL con un cuerpo JSON estable:

POST https://tu-sistema.com/webhooks/verifactu
X-Verifactu-Signature: t=1788912345,v1=5f2a…c9
Content-Type: application/json

{
  "id": "evt_9c1d…",
  "type": "registro.rechazado",
  "created_at": "2027-01-07T10:15:03.000Z",
  "data": {
    "registro_id": "0d3f2c1e-…",
    "obligado_nif": "B21778451",
    "codigo": 1234
  }
}

El cuerpo lleva lo justo para saber qué registro y qué pasó; el detalle (descripción del error, CSV, historial) se consulta con GET /v1/registros/{registro_id}. Así un webhook interceptado no expone datos fiscales, y tu sistema siempre lee el estado actual y no una foto antigua.

Responde 2xx en pocos segundos. Cualquier otra respuesta, o un timeout, cuenta como fallo y se reintenta.

Verificar la firma

La cabecera X-Verifactu-Signature tiene la forma t=<epoch en segundos>,v1=<hmac>. El HMAC es SHA-256 con tu secreto sobre la cadena "<t>.<cuerpo crudo>". Verifica siempre antes de procesar:

  1. Lee el cuerpo crudo (bytes tal cual), antes de parsear el JSON.
  2. Separa t y v1 de la cabecera.
  3. Calcula HMAC-SHA256(secreto, t + "." + cuerpo) en hexadecimal.
  4. Compara con v1 en tiempo constante.
  5. Rechaza si t se aleja más de 5 minutos de tu reloj (protección contra reenvíos).
Node.js (Next.js Route Handler)
import { createHmac, timingSafeEqual } from "node:crypto"

export async function POST(req: Request) {
  const cuerpo = await req.text()                       // crudo, sin parsear
  const cabecera = req.headers.get("x-verifactu-signature") ?? ""
  const partes = Object.fromEntries(cabecera.split(",").map((p) => p.split("=")))
  const t = Number(partes.t)
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
    return new Response("firma caducada", { status: 401 })
  }
  const esperado = createHmac("sha256", process.env.VERIFACTU_WEBHOOK_SECRET!)
    .update(`${t}.${cuerpo}`).digest("hex")
  const a = Buffer.from(esperado), b = Buffer.from(String(partes.v1 ?? ""))
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("firma inválida", { status: 401 })
  }
  const evento = JSON.parse(cuerpo)
  // … procesar evento.type / evento.data.registro_id …
  return new Response(null, { status: 204 })
}

Reintentos e idempotencia

Si tu endpoint no responde 2xx, la API reintenta con espera creciente —1 minuto, 4, 16, luego cada hora— hasta 6 intentos; el despachador pasa cada 15 minutos, así que el espaciado real nunca baja de eso. Agotados los intentos, la entrega queda marcada como fallida y se ve en el registro.

Un mismo evento puede llegar más de una vez (por ejemplo, si respondiste 2xx tarde). Usa id (evt_…) para deduplicar, y trata el procesamiento como idempotente: leer el estado actual del registro y guardarlo.

En preproducción puedes probar la recepción emitiendo una factura y esperando al lote: en pocos minutos llegará registro.aceptado (o el rechazo, si el cuerpo tenía un error de fondo). Ver también Consultar el estado.