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:
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
| Evento | Cuándo se emite |
|---|---|
invoice.created | Se crea una factura (borrador). |
invoice.updated | Se edita una factura. |
invoice.issued | Se emite una factura: numerada, congelada y con su registro Veri*Factu generado. |
invoice.cancelled | Se anula una factura emitida. |
verifactu.submitted | El registro se ha enviado a la AEAT; esperando respuesta. |
verifactu.accepted | La AEAT aceptó el registro. |
verifactu.rejected | La AEAT rechazó el registro. |
verifactu.error | Error 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.
{
"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—truesi el evento viene de tu tenant de producción;falsesi 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.*
| Campo | Tipo | Notas |
|---|---|---|
id | uuid | Identificador de la factura. |
type | string | Tipo de factura: F1, F2, F3, R1–R5. |
state | string | draft, issued, canceled, replaced, rectified_subst, rectified_diff, needs_fix… |
currency_code | string | Moneda (ISO 4217). |
total | string | Importe total (como string). |
number | string | Número asignado. Solo en facturas ya emitidas. |
partner_id | uuid | Cliente, si la factura lo tiene. |
issued_at | date | Fecha de emisión. Solo en facturas ya emitidas. |
Objeto de los eventos verifactu.*
| Campo | Tipo | Notas |
|---|---|---|
id | uuid | Identificador del registro Veri*Factu. |
invoice_id | uuid | Factura a la que pertenece el registro. |
number | int | Número correlativo del registro. |
state | string | pending, in-process, accepted, accepted_error, rejected, unknown_response. |
operation_type | string | registration (alta) o cancellation (anulación). |
hash | string | Huella SHA-256 del registro (encadenamiento). |
batch_id | uuid | Lote de envío a la AEAT, si ya se envió. |
response_code | string | Código de respuesta de la AEAT, si lo hay. |
response_message | string | Mensaje de la AEAT, si lo hay. |
sent_at | datetime | Instante de envío a la AEAT, si ya se envió. |
previous_hash | string | Huella 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:
- 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). - Compáralo con
v1en tiempo constante. - Comprueba que
tno 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.
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()
}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) <= tolerancefunction 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;
}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 enfailed(visible en el historial del panel, desde donde puedes reenviarla). 410 Gone= darse de baja. Si respondes410, 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
iddel evento (evt_…): procesa cadaiduna sola vez y responde2xxa los repetidos. - El historial no es permanente. Las entregas ya cerradas (
deliveredofailed) se conservan un tiempo limitado (por defecto ~90 días) y luego se purgan automáticamente; laspending(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
2xxrápido; procesa en segundo plano. - Ignora tipos de evento desconocidos (habrá nuevos).
- Usa
livemodepara 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}).