VeriBaiDocs
Acceder

Webhooks

Notificaciones firmadas cuando una factura alcanza su estado final, sin polling.

La remisión a Hacienda es asíncrona. Los webhooks invierten la consulta: en lugar de sondear /estado, VeriBai llama a tu servidor cuando una factura alcanza su estado final ante la administración, con una petición firmada con HMAC.

Estos endpoints pertenecen a la API de gestión: base URL https://manage-api.veribai.com, misma clave x-api-key.

Endpoints

Método Endpoint Descripción
GET /v1/webhooks Listado de webhooks.
POST /v1/webhooks Crear un webhook.
GET /v1/webhooks/{idWebhook} Detalle.
PATCH /v1/webhooks/{idWebhook} Modificar URL, nombre, secreto, eventos o estado.
DELETE /v1/webhooks/{idWebhook} Eliminar.
GET /v1/webhooks/{idWebhook}/clientes Emisores vinculados al webhook.
POST /v1/webhooks/{idWebhook}/clientes Vincular emisores ({ "clientes": ["B98765432"] }).
DELETE /v1/webhooks/{idWebhook}/clientes/{nif} Desvincular un emisor.

Crear un webhook

POST/v1/webhooks

curl -X POST https://manage-api.veribai.com/v1/webhooks \
  -H "x-api-key: TU_CLAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "nombre": "Producción ERP",
    "url": "https://tu-servidor.com/webhooks/veribai",
    "secreto": "un-secreto-de-al-menos-16-caracteres"
  }'
Campo Notas
nombre Nombre descriptivo, para tu propio orden.
url HTTPS. El endpoint debe responder 2xx en menos de 10 segundos.
secreto Entre 16 y 512 caracteres. Lo eliges tú; es la clave HMAC-SHA256 con la que se firma cada entrega.
eventos Opcional. Lista de eventos a los que suscribirse. Omítelo para recibirlos todos. Ver Suscribirse a eventos concretos.

La respuesta identifica el webhook por idWebhook e incluye estado, eventos, fallosConsecutivos, creadoEn y ultimaModificacion. Un webhook solo recibe eventos de los emisores que tenga vinculados. Vincúlalos tras crearlo con POST /v1/webhooks/{idWebhook}/clientes.

Eventos

Evento Cuándo se envía Campos adicionales en datos
factura.registrada La administración aceptó el registro. Ninguno
factura.rechazada La administración rechazó el registro de forma definitiva. motivoRechazo, tipoRegistro
factura.anulada La administración aceptó una anulación. tipoRegistro

tipoRegistro indica qué operación alcanzó ese desenlace: alta, subsanacion o anulacion. Una anulación rechazada llega como factura.rechazada con tipoRegistro: "anulacion". No hay un cuarto evento para ese caso; bifurca sobre tipoRegistro.

Los eventos son definitivos: solo se envían cuando la administración ha emitido su veredicto y los reintentos internos han concluido. Un fallo transitorio o un duplicado reconciliado no genera evento. El emisor recibe únicamente el desenlace final.

Un rechazo significa que el registro no está presentado

Este es el evento sobre el que hay que actuar. Un registro rechazado no ha quedado presentado ante la administración, y la obligación de presentarlo es del obligado tributario, no de VeriBai.

{
  "evento": "factura.rechazada",
  "idEntrega": "b1946ac9-…",
  "timestamp": "2026-08-18T10:00:00Z",
  "datos": {
    "sistemaFiscal": "ticketbai",
    "nifEmisor": "B98765432",
    "idFactura": "abc123…",
    "numeroFactura": "A12345",
    "tipoFactura": "F1",
    "tipoRegistro": "alta",
    "importeTotal": "121.00",
    "cuotaTotal": "21.00",
    "estadoFactura": "rechazada",
    "provincia": "bizkaia",
    "motivoRechazo": {
      "codigo": "B4_2000080",
      "descripcion": "El epígrafe IAE no es válido para el obligado tributario"
    }
  }
}

motivoRechazo trae el código y la descripción de la propia administración, sin reinterpretar. Cuando el registro acumula más de un motivo, el propio motivoRechazo incluye motivosAdicionales con el resto, cada uno con su codigo y su descripcion:

"motivoRechazo": {
  "codigo": "B4_2000080",
  "descripcion": "El epígrafe IAE no es válido para el obligado tributario",
  "motivosAdicionales": [
    { "codigo": "B4_2000018", "descripcion": "Falta el destinatario" }
  ]
}

Corrígelos todos antes de reenviar. El webhook es el único sitio donde aparecen los motivos adicionales (ningún endpoint de lectura los expone), así que arreglar solo el primero lleva a un segundo rechazo por el motivo que quedó sin tratar.

El camino de vuelta es una subsanación con los datos corregidos:

PUT /v1/verifactu/subsanar
PUT /v1/ticketbai/subsanar

VeriBai no reintenta automáticamente un rechazo por datos. Reenviar lo mismo obtiene el mismo rechazo, y en TicketBAI cada intento avanza de forma permanente la cadena de huellas del emisor. La corrección la decide quien tiene el dato correcto.

Suscribirse a eventos concretos

eventos es opcional en POST y en PATCH. Si lo omites, recibes todos los eventos. Es el comportamiento por defecto.

PATCH/v1/webhooks/ID_WEBHOOK

curl -X PATCH https://manage-api.veribai.com/v1/webhooks/ID_WEBHOOK \
  -H "x-api-key: TU_CLAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "eventos": [
      "factura.rechazada",
      "factura.anulada"
    ]
  }'

Las reglas:

  • Los valores válidos son exactamente los tres eventos de la tabla anterior. Un nombre desconocido se rechaza con 400 VALIDATION_ERROR en lugar de guardarse. De otro modo no coincidiría con nada, en silencio, para siempre.
  • factura.rechazada no puede excluirse. Una suscripción que lo omita se rechaza. Un registro rechazado no está presentado y la obligación es del obligado tributario: no se ofrece la opción de no enterarse de los fallos.
  • PATCH con {"eventos": null} borra la suscripción y vuelve a «todos los eventos».
  • La lista vacía [] se rechaza: para dejar de recibir entregas, usa PATCH con {"estado": "inactivo"}.
  • El orden que envías no se conserva. Se almacena la lista canónica, sin duplicados.
  • Las respuestas siempre enumeran los eventos que se van a entregar de verdad: un webhook sin suscripción guardada responde con los tres, no con null.

PATCH sobre eventos, url, secreto o estado se propaga a todos los emisores vinculados. Si esa propagación queda a medias, la respuesta es 500 PROPAGATION_INCOMPLETE: el webhook sí se actualizó, pero algunos vínculos conservan los valores anteriores, así que las entregas todavía no coinciden con lo que devuelve un GET. Repite el mismo PATCH. Es idempotente y vuelve a propagar.

La entrega

Cuando una factura de un emisor vinculado alcanza su desenlace, VeriBai hace un POST a tu URL:

POST /webhooks/veribai HTTP/1.1
Content-Type: application/json
X-VeriBai-Event: factura.registrada
X-VeriBai-Delivery-Id: 3f2c1a9e-…
X-VeriBai-Timestamp: 2026-06-19T10:30:00.123Z
X-VeriBai-Signature: sha256=6a8b2c…
Idempotency-Key: 3f2c1a9e-…
User-Agent: VeriBai-Webhooks/1.0
{
  "evento": "factura.registrada",
  "idEntrega": "3f2c1a9e-…",
  "timestamp": "2026-06-19T10:30:00.123Z",
  "datos": {
    "sistemaFiscal": "verifactu",
    "nifEmisor": "B98765432",
    "idFactura": "abc123…",
    "numeroFactura": "A001",
    "tipoFactura": "F1",
    "importeTotal": "121.00",
    "cuotaTotal": "21.00",
    "estadoFactura": "registrada",
    "fechaRegistro": "2026-06-19T10:29:55Z",
    "csvAeat": "…",
    "hashVerifactu": "…"
  }
}

En facturas TicketBAI, datos sustituye csvAeat y hashVerifactu por provincia e idRegistroTbai.

Cabeceras y cuerpo son coherentes entre sí: X-VeriBai-Delivery-Id es el mismo valor que idEntrega; X-VeriBai-Timestamp es el mismo instante que timestamp (ISO 8601, sufijo Z); y X-VeriBai-Event, el mismo valor que evento.

Idempotencia: descarta las entregas repetidas

La entrega es at-least-once: si tu endpoint recibe el POST pero responde después del timeout de 10 segundos, lo contamos como fallo y lo reintentamos. Recibir la misma factura dos veces es un escenario normal, no excepcional, así que tu handler debe ser idempotente.

Para que puedas descartarlas, el identificador de entrega es determinista: se deriva del webhook, la factura y el evento, nunca es aleatorio. Cualquier reintento de una misma entrega llega con el mismo identificador y un cuerpo byte a byte idéntico (y por tanto la misma firma). Lo enviamos por duplicado, en dos cabeceras equivalentes. Usa la que te resulte más cómoda:

  • Idempotency-Key: la cabecera estándar (borrador IETF); muchos frameworks y API gateways la deduplican solos.
  • X-VeriBai-Delivery-Id: el mismo valor, y también el del campo idEntrega del cuerpo.

Guarda el identificador al procesar la entrega y responde 2xx sin volver a procesar si ya lo tienes:

if (await yaProcesada(req.headers['idempotency-key'])) {
  return res.sendStatus(200);   // duplicado: confirma y no repitas el trabajo
}

El identificador es único por (webhook, factura, evento). Dos consecuencias prácticas:

  • Si tienes dos webhooks vinculados al mismo emisor, cada uno recibe su propia entrega del mismo evento, con identificadores distintos (incluso si ambos apuntan a la misma URL).
  • Una factura rechazada y después registrada tras una subsanación produce dos entregas distintas, cada una con su identificador. Deduplícalas por separado: la segunda no es una repetición de la primera.

Verifica la firma

X-VeriBai-Signature es el HMAC-SHA256 del cuerpo crudo de la petición, calculado con tu secreto. Verifícala antes de procesar nada:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verificar(rawBody, cabeceraFirma, secreto) {
  const esperada = 'sha256=' + createHmac('sha256', secreto).update(rawBody).digest('hex');
  return timingSafeEqual(Buffer.from(cabeceraFirma), Buffer.from(esperada));
}

Calcula el HMAC sobre los bytes crudos del cuerpo. Deserializar y volver a serializar el JSON altera la firma.

Ese es el fallo más frecuente, y no avisa: el json.loads que hace tu framework antes de darte el cuerpo ya ha alterado los bytes. En Python, el cliente oficial verifica y parsea en la misma llamada, y devuelve el identificador de entrega sobre el que deduplicar:

import os

import veribai

@app.post("/webhooks/veribai")
def recibir():
    try:
        entrega = veribai.webhooks.parse_entrega(
            cuerpo=request.get_data(),  # bytes crudos, antes de parsear el JSON
            cabeceras=request.headers,
            secreto=os.environ["VERIBAI_WEBHOOK_SECRET"],
        )
    except veribai.WebhookSignatureError:
        return "", 401

    if ya_procesada(entrega.id_entrega):
        return "", 200  # duplicado: confirma y no repitas el trabajo

    if entrega.rechazada:
        motivo = entrega.motivo_rechazo  # código y descripción de la administración
        subsanar(entrega.id_factura, motivo.codigo)

    return "", 200

El ejemplo usa Flask, pero cabeceras acepta cualquier mapa de cabeceras y cuerpo cualquier bytes: en Django son request.headers y request.body, y en FastAPI request.headers y await request.body().

Reintentos y suspensión

  • Cada entrega tiene un timeout de 10 segundos. Responde 2xx rápido y procesa en segundo plano.
  • Una entrega fallida se reintenta hasta 3 veces con espera exponencial, siempre con el mismo Idempotency-Key.
  • Tras 20 fallos consecutivos, el webhook pasa a estado: "suspendido" y las entregas se detienen. El detalle incluye fallosConsecutivos, suspendidoEn y ultimoError. Reactívalo con PATCH /v1/webhooks/{idWebhook} y { "estado": "activo" } una vez corregido tu endpoint; el contador se reinicia.

Los estados posibles son activo, inactivo (lo fijas tú) y suspendido (lo fija la plataforma).

Los webhooks complementan, no sustituyen, a GET /v1/facturas/{idFactura}/estado: ante una caída prolongada de tu endpoint, el estado consultable por API sigue siendo la fuente de verdad.