Webhooks: que la AEAT te llame a ti
La remisión a la AEAT no es síncrona. En vez de preguntar cada pocos segundos si ya hay respuesta, registras una URL y te avisamos: aceptado, rechazado o error, con el CSV cuando lo hay.
¿Cómo sé si la AEAT ha aceptado una factura?
Con un webhook. Registras una URL y verifactu.co la llama en cuanto la AEAT contesta, con el evento registro.aceptado y el CSV, o con registro.rechazado y el código de error.
Cada envío va firmado con HMAC-SHA256 sobre el timestamp y el cuerpo, y se reintenta ocho veces con espera creciente si tu servidor no responde 2xx.
| Evento | Cuándo se dispara |
|---|---|
registro.aceptado | La AEAT aceptó el registro. Trae el CSV. |
registro.aceptado_con_errores | Aceptado con avisos. Casi siempre el formato de importes: merece revisión aunque no bloquee. |
registro.rechazado | Rechazo de contenido. No se reintenta: hay que corregir un dato. |
registro.error | Agotados los seis reintentos de un fallo transitorio. |
registro.anulado | El registro de anulación fue aceptado. |
emisor.certificado_por_caducar | Faltan 30 días para que caduque el certificado de un emisor. |
POST https://tu-app.example/verifactu
X-Verifactu-Firma: t=1756200000,v1=5f3c9a...
X-Verifactu-Evento: registro.aceptado
{
"id": "evt_7Hs2kP",
"tipo": "registro.aceptado",
"creado_en": "2026-08-26T10:16:12+01:00",
"datos": {
"id": "reg_9fK2mQ",
"numero": "F2026/0184",
"estado": "aceptado",
"csv": "A1B2C3D4E5F6"
}
}| Cabecera | Qué es |
|---|---|
X-Verifactu-Firma | t= timestamp y v1= HMAC-SHA256 de t.cuerpo con tu secreto. |
X-Verifactu-Evento | El tipo, para poder enrutar sin parsear el JSON. |
Tu endpoint de webhook es una URL pública que marca facturas como aceptadas ante Hacienda. Sin verificar la firma, cualquiera que la descubra puede mentirle a tu contabilidad.
// Verifica la firma ANTES de tocar el cuerpo. // Sin esto, cualquiera que conozca tu URL puede marcar // facturas como aceptadas. $firma = $_SERVER['HTTP_X_VERIFACTU_FIRMA']; $cuerpo = file_get_contents('php://input'); preg_match('/t=(\\d+),v1=([a-f0-9]+)/', $firma, $m); [, $ts, $recibida] = $m; // El timestamp entra en la firma: sin él, una petición // capturada se podría reenviar indefinidamente. if (abs(time() - (int)$ts) > 300) { http_response_code(400); exit; } $esperada = hash_hmac('sha256', $ts . '.' . $cuerpo, $secreto); if (!hash_equals($esperada, $recibida)) { http_response_code(400); exit; } http_response_code(200); // responde 2xx rápido
Esperamos un 2xx en 10 segundos
Si tardas más, se considera fallido. Contesta rápido y procesa después: no hagas el trabajo pesado dentro de la petición.
Ocho reintentos con espera creciente
1 minuto, 5, 15, 45, 2 horas, 6, 12 y 24. Un despliegue de diez minutos no te hace perder ningún evento.
Los eventos pueden llegar desordenados o repetidos
Usa el id del evento para descartar duplicados. Un reintento tras un timeout puede duplicar uno que sí procesaste.
Y si todo falla, están en la API
GET /v1/registros?estado=… siempre tiene la verdad. El webhook es una comodidad, no la única fuente.
¿Puedo tener varias URLs de webhook?
Sí, y se puede filtrar por tipo de evento en cada una. Es lo habitual cuando los rechazos van a un canal de soporte y las aceptaciones a la contabilidad.
¿Qué hago mientras desarrollo en local?
El sandbox acepta cualquier URL pública, así que sirve cualquier túnel HTTP. También puedes hacer polling de GET /v1/registros mientras montas el endpoint: en desarrollo no molesta a nadie.
¿El webhook sustituye a consultar el registro?
No del todo. El webhook te ahorra el polling, pero la fuente de verdad es siempre el registro en la API. Si tu endpoint estuvo caído más de 24 horas, reconcilia consultando.
Última revisión normativa: Especificaciones de huella AEAT v0.1.2 Revisado por el equipo técnico de verifactu.co