Ir al contenido

Webhooks ​

Bilteo te notifica los eventos relevantes (emisión de facturas, respuesta de la AEAT…) mediante webhooks: peticiones POST HTTP a una URL tuya, en lugar de que tengas que hacer polling. Es la forma recomendada de cerrar el ciclo asíncrono de Veri*Factu: emites una factura y, cuando la AEAT responde, recibes un verifactu.accepted o verifactu.rejected.

Conceptos ​

  • Endpoint — una URL https:// tuya que registras en Bilteo para recibir eventos. Puedes tener varios.
  • Evento — algo que ha pasado (invoice.issued, verifactu.accepted…). Cada endpoint se suscribe a los tipos que le interesan.
  • Entrega (delivery) — cada intento de enviar un evento a un endpoint.
  • Secret — clave con la que Bilteo firma cada entrega para que verifiques que viene de Bilteo y no ha sido manipulada.

Configurar un endpoint ​

Puedes gestionar los endpoints desde el panel de Bilteo o desde la API pública. Con una API key o token OAuth2 que tenga webhooks:write:

bash
curl -X POST https://api.test.bilteo.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer bk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://ejemplo.com/webhooks","description":"Mi integración","events":["invoice.issued","verifactu.*"]}'

La respuesta incluye el secret de firma. Guárdalo de forma segura. La API permite GET /webhook-endpoints y GET /webhook-endpoints/{id} con webhooks:read, y PATCH /webhook-endpoints/{id} y DELETE /webhook-endpoints/{id} con webhooks:write. Los listados usan limit y cursor como el resto de la API. Las respuestas de lectura y edición no incluyen el secret.

Consultar y regenerar el secret

El secret es consultable en cualquier momento desde la página de detalle del endpoint en el panel. Desde la API pública puedes regenerarlo con POST /webhook-endpoints/{id}/actions/roll-secret (webhooks:write), que devuelve el nuevo valor. Al regenerarlo, el secret anterior deja de valer de inmediato: las entregas pendientes se firmarán con el nuevo secret, así que actualiza tu endpoint receptor cuanto antes. Si necesitas una transición sin cortes, crea otro endpoint y retira el viejo cuando hayas migrado.

Al suscribirte puedes usar el tipo exacto (invoice.issued), un comodín por recurso (invoice.*, verifactu.*) o el comodín global (*).

Eventos disponibles ​

EventoCuándo se emite
invoice.createdSe crea una factura (borrador).
invoice.updatedSe edita una factura.
invoice.issuedSe emite una factura: numerada, congelada y con su registro Veri*Factu generado.
invoice.cancelledSe anula una factura emitida.
verifactu.submittedEl registro se ha enviado a la AEAT; esperando respuesta.
verifactu.acceptedLa AEAT aceptó el registro.
verifactu.rejectedLa AEAT rechazó el registro.
verifactu.errorError técnico procesando el registro (sin respuesta clara de la AEAT).

Añadir eventos nuevos es un cambio compatible: diseña tu receptor para ignorar los tipos que no conozca.

Estructura del evento ​

El cuerpo es JSON. El objeto es una representación fina del recurso (ids + campos clave); para el detalle completo, pide el recurso a la API con su id.

json
{
  "id": "evt_0192f3a4-...",
  "type": "verifactu.accepted",
  "created": 1716120000,
  "tenant_id": "0192...",
  "livemode": true,
  "api_version": "v1",
  "data": {
    "object": {
      "id": "0192...",
      "invoice_id": "0192...",
      "number": 42,
      "state": "accepted",
      "operation_type": "registration",
      "response_code": "...",
      "hash": "A1B2C3...",
      "sent_at": "2026-05-19T10:00:03Z"
    }
  }
}
  • id — identificador único y estable del evento (evt_…). Es el mismo en los reintentos y en los reenvíos manuales: úsalo para deduplicar.
  • livemode — true si el evento viene de tu tenant de producción; false si viene del entorno de pruebas. Ver Entorno de pruebas.
  • created — timestamp Unix (segundos) de emisión del evento.

El contenido de data.object es fino (ids + campos clave) y depende del recurso del evento. Los campos opcionales solo aparecen cuando tienen valor.

Objeto de los eventos invoice.* ​

CampoTipoNotas
iduuidIdentificador de la factura.
typestringTipo de factura: F1, F2, F3, R1–R5.
statestringdraft, issued, canceled, replaced, rectified_subst, rectified_diff, needs_fix…
currency_codestringMoneda (ISO 4217).
totalstringImporte total (como string).
numberstringNúmero asignado. Solo en facturas ya emitidas.
partner_iduuidCliente, si la factura lo tiene.
issued_atdateFecha de emisión. Solo en facturas ya emitidas.

Objeto de los eventos verifactu.* ​

CampoTipoNotas
iduuidIdentificador del registro Veri*Factu.
invoice_iduuidFactura a la que pertenece el registro.
numberintNúmero correlativo del registro.
statestringpending, in-process, accepted, accepted_error, rejected, unknown_response.
operation_typestringregistration (alta) o cancellation (anulación).
hashstringHuella SHA-256 del registro (encadenamiento).
batch_iduuidLote de envío a la AEAT, si ya se envió.
response_codestringCódigo de respuesta de la AEAT, si lo hay.
response_messagestringMensaje de la AEAT, si lo hay.
sent_atdatetimeInstante de envío a la AEAT, si ya se envió.
previous_hashstringHuella del registro anterior de la cadena.

También cubren Verifactu Direct API

Los eventos verifactu.* se emiten igual para las facturas registradas con Verifactu Direct API (/external-invoices), que comparten la misma cadena Veri*Factu. En ese caso invoice_id es null (no son facturas nativas): correlaciona el evento con tu factura externa por el campo id del objeto, que es el verifactu_record_id que te devolvió POST /external-invoices.

El objeto es un resumen

Para el detalle completo de la factura o del registro, pide el recurso a la API con su id (GET https://api.test.bilteo.com/api/v1/invoices/{id} o GET https://api.test.bilteo.com/api/v1/verifactu-records/{id}).

Evento de prueba ​

El botón «Enviar evento de prueba» del panel manda un evento de tipo webhook.test al endpoint elegido, sin importar a qué eventos esté suscrito. Su data.object es { "message": "…" }. Úsalo para comprobar que tu endpoint recibe y verifica la firma correctamente; no corresponde a ningún hecho de negocio, así que trátalo como informativo.

Cabeceras ​

Cada entrega incluye:

Content-Type: application/json
User-Agent: Bilteo-Webhooks/1
Bilteo-Event-Id: evt_0192f3a4-...
Bilteo-Event-Type: verifactu.accepted
Bilteo-Signature: t=1716120000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e...

Verificar la firma ​

Bilteo-Signature lleva un timestamp (t, Unix) y la firma (v1):

Bilteo-Signature: t=1716120000,v1=<hex>

donde v1 = HMAC_SHA256(secret, "<t>.<cuerpo_crudo>") en hexadecimal.

Para verificar:

  1. Calcula el HMAC-SHA256 de "<t>.<cuerpo>" con tu secret, usando el cuerpo crudo tal cual llegó (no lo re-serialices: cambiaría los bytes y la firma).
  2. Compáralo con v1 en tiempo constante.
  3. Comprueba que t no esté más de unos minutos en el pasado (anti-replay).

Usa el cuerpo sin parsear

Lee el raw body antes de deserializar el JSON. Frameworks que parsean el cuerpo automáticamente pueden re-serializarlo y romper la verificación.

go
func Verify(secret, header string, body []byte, tolerance time.Duration) bool {
	var ts int64
	var v1 string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(part, "=")
		switch k {
		case "t":
			ts, _ = strconv.ParseInt(v, 10, 64)
		case "v1":
			v1 = v
		}
	}
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.", ts)
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(v1)) {
		return false
	}
	return math.Abs(float64(time.Now().Unix()-ts)) <= tolerance.Seconds()
}
python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        return False
    return abs(time.time() - t) <= tolerance
php
function bilteo_verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
        $parts[$k] = $v;
    }
    $t = (int)($parts['t'] ?? 0);
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    if (!hash_equals($expected, $parts['v1'] ?? '')) {
        return false;
    }
    return abs(time() - $t) <= $tolerance;
}
js
const crypto = require('crypto')

function verify(secret, header, rawBody, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
  const t = parseInt(parts.t, 10)
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(parts.v1 || '')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false
  return Math.abs(Date.now() / 1000 - t) <= toleranceSec
}

Responde 2xx en cuanto hayas verificado y aceptado el evento. Haz el trabajo pesado de forma asíncrona: si tardas, Bilteo considerará la entrega fallida y reintentará.

Entrega: reintentos y duplicados ​

  • Reintentos. Si tu endpoint no responde 2xx, Bilteo reintenta con backoff creciente: 1m, 5m, 30m, 2h, 6h, 12h, 24h, 24h, 24h (hasta ~10 intentos en ~4 días). Agotados, la entrega queda en failed (visible en el historial del panel, desde donde puedes reenviarla).
  • 410 Gone = darse de baja. Si respondes 410, Bilteo considera la entrega cerrada y desactiva el endpoint (deja de enviarle eventos).
  • Entrega at-least-once. Una misma entrega puede llegar más de una vez (un reintento tras un timeout en el que tu servidor sí procesó, o la red de recuperación interna). Deduplica por el id del evento (evt_…): procesa cada id una sola vez y responde 2xx a los repetidos.
  • El historial no es permanente. Las entregas ya cerradas (delivered o failed) se conservan un tiempo limitado (por defecto ~90 días) y luego se purgan automáticamente; las pending (aún en reintentos) no se purgan. El historial del panel es una ayuda de diagnóstico, no un registro de auditoría: si necesitas conservar los eventos a largo plazo, guárdalos en tu lado al recibirlos.

Verifica siempre la firma

Trata el cuerpo como no confiable hasta validar Bilteo-Signature. Sin verificación, cualquiera que conozca tu URL podría enviarte eventos falsos.

Buenas prácticas ​

  • Verifica la firma y comprueba el t (anti-replay).
  • Deduplica por id (evt_…).
  • Responde 2xx rápido; procesa en segundo plano.
  • Ignora tipos de evento desconocidos (habrá nuevos).
  • Usa livemode para no mezclar pruebas con producción.
  • No uses el historial de entregas como auditoría: se purga; persiste lo que necesites al recibir el evento.
  • Para el detalle completo de un recurso, pídelo a la API con su id (p. ej. GET https://api.test.bilteo.com/api/v1/invoices/{id}).

Bilteo · Documentación