TicketBAI
Alta, subsanación y anulación de facturas TicketBAI ante las haciendas forales.
Los endpoints TicketBAI remiten facturas a la hacienda foral que indique provincia. Base URL: https://sandbox.veribai.com (TEST) o https://api.veribai.com (LIVE), con autenticación x-api-key.
| Método | Endpoint | Descripción |
|---|---|---|
POST |
/v1/ticketbai/crear |
Alta de una factura TicketBAI. |
PUT |
/v1/ticketbai/subsanar |
Subsanación de un registro remitido. |
POST |
/v1/ticketbai/anular |
Anulación de un registro remitido. |
A diferencia de VeriFactu, la firma y el encadenado TicketBAI se completan en el momento del alta: la respuesta ya incluye el identificador TBAI y el QR. La remisión a la hacienda foral se completa en segundo plano.
En LIVE, todas las operaciones exigen que el emisor tenga su representación firmada; en caso contrario devuelven
403 REPRESENTATION_PENDING. En TEST no se exige.
POST /v1/ticketbai/crear
En TicketBAI los datos de identificación viajan en el nivel superior del JSON (no hay bloque cabecera) y el desglose fiscal es la lista plana desglose:
curl -X POST https://sandbox.veribai.com/v1/ticketbai/crear \
-H "x-api-key: TU_CLAVE" \
-H "Content-Type: application/json" \
-d '{
"version": "1.0",
"provincia": "bizkaia",
"serie": "A",
"numero": "12345",
"fechaExpedicion": "03-04-2026",
"tipoFactura": "F1",
"descripcion": "Servicios de consultoría",
"emisor": {
"nif": "B76116342",
"nombre": "Prueba 1 SL"
},
"destinatario": {
"nif": "B17195595",
"nombre": "Nombre cliente, SL"
},
"desglose": [
{
"baseImponible": "100.00",
"tipoImpositivo": "21.00",
"cuota": "21.00",
"descripcion": "Consultoría"
}
],
"importeTotal": "121.00"
}'
from datetime import date
from decimal import Decimal
import veribai
client = veribai.Client(api_key="TU_CLAVE", environment="test")
respuesta = client.ticketbai.crear({
"version": "1.0",
"provincia": "bizkaia",
"serie": "A",
"numero": "12345",
"fechaExpedicion": date(2026, 4, 3),
"tipoFactura": "F1",
"descripcion": "Servicios de consultoría",
"emisor": {
"nif": "B76116342",
"nombre": "Prueba 1 SL",
},
"destinatario": {
"nif": "B17195595",
"nombre": "Nombre cliente, SL",
},
"desglose": [
{
"baseImponible": Decimal("100.00"),
"tipoImpositivo": Decimal("21.00"),
"cuota": Decimal("21.00"),
"descripcion": "Consultoría",
},
],
"importeTotal": Decimal("121.00"),
})
print(respuesta["idTbai"], respuesta["urlValidacion"])
const respuesta = await fetch("https://sandbox.veribai.com/v1/ticketbai/crear", {
method: "POST",
headers: {
"x-api-key": "TU_CLAVE",
"Content-Type": "application/json",
},
body: JSON.stringify({
"version": "1.0",
"provincia": "bizkaia",
"serie": "A",
"numero": "12345",
"fechaExpedicion": "03-04-2026",
"tipoFactura": "F1",
"descripcion": "Servicios de consultoría",
"emisor": {
"nif": "B76116342",
"nombre": "Prueba 1 SL"
},
"destinatario": {
"nif": "B17195595",
"nombre": "Nombre cliente, SL"
},
"desglose": [
{
"baseImponible": "100.00",
"tipoImpositivo": "21.00",
"cuota": "21.00",
"descripcion": "Consultoría"
}
],
"importeTotal": "121.00"
}),
});
if (!respuesta.ok) throw new Error(`VeriBai ${respuesta.status}`);
const datos = await respuesta.json();
Campos
| Campo | Notas |
|---|---|
provincia |
Obligatorio: araba, bizkaia o gipuzkoa. Determina la hacienda foral de destino y sus reglas. Debe corresponder con la hacienda del emisor. La discrepancia se rechaza con 400 PROVINCE_MISMATCH. |
emisor |
Obligatorio: nif (9 caracteres) y nombre. El NIF debe corresponder a un cliente dado de alta con la hacienda foral correspondiente. |
serie |
Opcional. TicketBAI admite factura sin serie. |
numero |
Obligatorio. |
fechaExpedicion |
Obligatorio. Formato dd-mm-aaaa; no puede ser posterior a hoy, en hora peninsular. Ver Fechas de la factura. |
horaExpedicion |
Opcional. HH:MM:SS en formato 24 h, dos dígitos por componente. Si se omite, se usa la hora peninsular del momento del alta. Se valida como hora real: 24:00:00 se rechaza. |
fechaOperacion |
Opcional, dd-mm-aaaa, si difiere de la expedición. Cada hacienda foral aplica sus propias reglas. Ver Fechas de la factura. |
tipoFactura |
Obligatorio: F1–F4, R1–R5. Son los de VeriFactu más F4 (asiento resumen), propio de TicketBAI. |
descripcion |
Descripción de la operación. |
destinatario |
Obligatorio salvo en F2 y R5 (simplificadas), que no lo admiten. nombre más nif o idOtro. |
desglose |
Obligatorio, mínimo una línea: baseImponible, tipoImpositivo (0–100) y cuota, como cadenas decimales. La cuota total se calcula como la suma de las líneas. En simplificadas F2, la suma de baseImponible y cuota de todas las líneas no puede superar los 3.000 €. El tope es exclusivo: 3.000,00 € se acepta y 3.000,01 € devuelve 400 VALIDATION_ERROR nombrando desglose. El recargo de equivalencia queda fuera de esa suma, así que una F2 con recargo puede pasar de 3.000 € de total y seguir siendo válida. Las R5 no tienen tope. El límite es el de la regla AEAT 1150, que recoge el tope de la factura simplificada del art. 4 del RD 1619/2012: ley nacional, no una exigencia foral. Las haciendas forales no lo comprueban; la API sí lo aplica, para que la factura no salga fuera de norma. |
importeTotal |
Importe total de la factura, IVA incluido y sin descontar el IRPF. Ver Retención de IRPF. |
rectificativa |
Obligatorio en tipos R1–R5: tipo (S o I) y facturasRectificadas con al menos una referencia (serie, numero, fechaExpedicion). |
retencionSoportada |
Opcional. Retención de IRPF de la factura, como cadena decimal. Se declara aparte: no se resta de importeTotal. |
lineas |
Líneas de detalle de la factura: descripcion, importeUnitario (sin IVA), importeTotal (con IVA); cantidad opcional (por defecto 1) y descuento opcional. Obligatorio en Araba; opcional en Bizkaia y Gipuzkoa. Máximo 250 líneas por factura, en las tres provincias y tanto en crear como en subsanar. |
subsanacion / rechazoPrevio |
Opcionales, booleanos. En la práctica no los envías: PUT /subsanar marca la subsanación automáticamente. |
idMaquina |
Opcional. Identificador del dispositivo o TPV emisor (máx. 64, A-Za-z0-9._-). Es metadato salvo en los emisores con Alta capacidad activa, donde encamina la cadena y se declara en el documento firmado con sus últimos 30 caracteres. |
Tipos y formato del cuerpo
TicketBAI valida el tipo de cada campo antes de construir el XML, para que un dato mal tipado sea un 400 inmediato y no un fallo aguas abajo:
provincia,serie,numero,fechaExpedicion,tipoFactura,emisor.nifyemisor.nombredeben ser cadenas JSON. Un número,null, un array o un objeto devuelve400 VALIDATION_ERRORnombrando el campo (o400 INVALID_FIELD_TYPEenanular).- Las longitudes se comprueban en la entrada, con los máximos del propio XSD de TicketBAI 1.2.2:
serieynumero, 20 caracteres cada uno (TextMax20Type);descripcionylineas[].descripcion, 250;emisor.nombreydestinatario.nombre, 120;destinatario.direccion, 250;destinatario.codigoPostal, 20. Pasarse devuelve400 VALIDATION_ERRORnombrando el campo y su máximo, sin llegar a firmar. - Los caracteres
#,&y'se rechazan enserieynumero: Araba los rechaza con el código004, y lo hace con el documento ya firmado./, el espacio,Ñ,-,_,.,<,>y"sí se admiten. - Los campos de texto no pueden contener caracteres de control ilegales en XML. El tabulador y los saltos de línea sí se admiten, y se eliminan al serializar.
- Los campos de texto tampoco admiten entidades XML o HTML ya codificadas (
<,�,&…). El escapado del XML lo hace la API, así que ese texto es una doble codificación y devuelve400nombrando el campo. Un&suelto dentro de un nombre (Tom & Jerry) es correcto. - El cuerpo debe ser un objeto JSON. Un array, una cadena, un número o
nulldevuelven400 BAD_REQUEST.
Retención de IRPF (autónomos)
Una factura corriente de un autónomo con retención lleva tres importes: base 550,00, IVA 115,50 y retención de IRPF 82,50. El campo importeTotal es el importe con IVA, antes de la retención: aquí "665.50", no "583.00". La retención viaja aparte, en retencionSoportada.
Restar el IRPF de importeTotal es el error habitual, y no pasa silenciosamente: el total deja de cuadrar con el desglose y la petición responde 400, indicando que la retención se declara en su propio campo.
Reglas por provincia
Cada hacienda foral valida cosas distintas. La API las comprueba en el alta, para que el error llegue como un 400 inmediato y no como un rechazo foral diferido:
- Araba exige las líneas de detalle:
lineases obligatorio. - Gipuzkoa exige
descripcionen cada línea dedesglose. - Bizkaia opera dentro de Batuz/LROE (el envoltorio lo gestiona la API sin cambios en tu petición) y solo admite ejercicios desde 2024.
desglose y lineas son cosas distintas: el desglose es el resumen fiscal por tipo impositivo; las líneas son los conceptos de la factura. Ninguno se deriva del otro.
Respuesta
200 OK. La factura queda firmada y encadenada en el momento:
{
"nifEmisor": "B76116342",
"idFactura": "…",
"estado": "en_cola",
"sistemaFiscal": "ticketbai",
"provincia": "bizkaia",
"serieNumero": "A12345",
"tipoFactura": "F1",
"importeTotal": "121.00",
"cuotaTotal": "21.00",
"creadoEn": "2026-04-03T10:00:00Z",
"idTbai": "TBAI-B76116342-…",
"urlValidacion": "https://…",
"qrBase64": "…"
}
idTbai: el identificador TBAI, generado a partir de la firma.estadovaleen_cola: firmada y encadenada, pendiente de remisión foral. Estado final en/estado.qrBase64yurlValidacion: el QR TicketBAI, verificable ante la hacienda foral.
Los duplicados son idempotentes: reenviar un alta con la misma identidad devuelve 200 con el registro ya existente, no un error. En la repetición, estado trae el valor de ciclo de vida (registrada, anulada o rectificada) y no se incluyen los campos del QR.
La repetición funciona también con la remisión en curso: si el original ya está firmado pero aún no registrado (o fue rechazado), la respuesta trae la identidad almacenada (idTbai, urlValidacion, el estado de la remisión, p. ej. en_cola o rechazada) más "yaExistente": true. La factura nunca se vuelve a firmar, así que el identificador TBAI es estable entre reintentos. En la ventana breve en que una petición idéntica anterior sigue firmando, la respuesta es 409 INVOICE_SIGNING_IN_FLIGHT: reintenta en unos segundos.
PUT /v1/ticketbai/subsanar
Corrige un registro ya remitido. El cuerpo es el mismo que el de crear con los datos corregidos; la API marca la subsanación automáticamente. Acepta rechazoPrevio (booleano) para declarar un rechazo foral previo.
POST /v1/ticketbai/anular
El cuerpo es plano e incluye provincia, como el alta:
curl -X POST https://sandbox.veribai.com/v1/ticketbai/anular \
-H "x-api-key: TU_CLAVE" \
-H "Content-Type: application/json" \
-d '{
"provincia": "bizkaia",
"nifEmisor": "B76116342",
"serie": "A",
"numero": "12345",
"fechaExpedicion": "03-04-2026"
}'
from datetime import date
import veribai
client = veribai.Client(api_key="TU_CLAVE", environment="test")
respuesta = client.ticketbai.anular({
"provincia": "bizkaia",
"nifEmisor": "B76116342",
"serie": "A",
"numero": "12345",
"fechaExpedicion": date(2026, 4, 3),
})
const respuesta = await fetch("https://sandbox.veribai.com/v1/ticketbai/anular", {
method: "POST",
headers: {
"x-api-key": "TU_CLAVE",
"Content-Type": "application/json",
},
body: JSON.stringify({
"provincia": "bizkaia",
"nifEmisor": "B76116342",
"serie": "A",
"numero": "12345",
"fechaExpedicion": "03-04-2026"
}),
});
if (!respuesta.ok) throw new Error(`VeriBai ${respuesta.status}`);
const datos = await respuesta.json();
| Campo | Notas |
|---|---|
provincia |
Obligatorio: araba, bizkaia o gipuzkoa. |
nifEmisor |
Obligatorio. NIF del emisor de la factura a anular. |
numero / fechaExpedicion |
Obligatorios. serie solo si la factura la tenía. Los tres deben coincidir exactamente con la factura original (la fecha forma parte de su identidad). |
nombreEmisor |
Opcional. Por defecto, el nombre registrado del cliente emisor. |
idMaquina |
Opcional. |
Respuesta
{
"nifEmisor": "B76116342",
"idFactura": "…",
"idAnulacion": "…",
"estado": "en_cola",
"sistemaFiscal": "ticketbai",
"provincia": "bizkaia",
"serie": "A",
"numero": "12345",
"fechaExpedicion": "03-04-2026",
"creadoEn": "2026-04-03T11:00:00Z"
}
La anulación se firma y encadena en el momento, como el alta. No genera QR: es un registro de baja, no una factura.
Repetición idempotente: si la factura ya tiene una anulación registrada o en curso, la respuesta es un 200 con la misma forma (sin creadoEn) más "yaAnulada": true; estado refleja el envío de la anulación previa (registrada o en_cola). Una anulación previamente rechazada no bloquea el reintento.
Errores
Los códigos comunes están en Errores. Específicos de estos endpoints:
| Código | Cuándo |
|---|---|
400 INVALID_PROVINCE |
provincia ausente o distinta de araba, bizkaia, gipuzkoa. En crear/subsanar, el cuerpo incluye provinciaRecibida con el valor recibido. |
400 PROVINCE_MISMATCH |
provincia no coincide con la hacienda registrada del emisor. |
400 VALIDATION_ERROR |
El cuerpo no supera la validación, incluidas las reglas por provincia y los tipos de campo. |
400 INVALID_FIELD_TYPE |
anular: los campos están, pero con el tipo JSON equivocado. |
400 MISSING_FIELDS |
anular: faltan campos obligatorios. El message los enumera. |
400 BAD_REQUEST |
El cuerpo no es un objeto JSON, o el idMaquina tiene formato inválido. |
400 R3_TIMING_ERROR |
crear: rectificativa R3 con menos de seis meses desde la factura que rectifica. Ver Rectificar y anular. |
400 INVOICE_HAS_RECTIFICATIVAS |
No puedes anular una factura con rectificativas: anula primero las rectificativas. |
403 UNAUTHORIZED_NIF |
El emisor.nif no está dado de alta bajo tu cuenta. |
403 REPRESENTATION_PENDING |
Solo LIVE: el emisor no tiene la representación firmada. |
404 INVOICE_NOT_FOUND |
anular: no existe la factura original para ese emisor. |
409 DUPLICATE_INVOICE |
Caso límite: el registro existe pero sus datos no pudieron recuperarse. El duplicado normal no es un error, sino una repetición idempotente con 200. |
409 INVOICE_SIGNING_IN_FLIGHT |
Una petición idéntica anterior sigue completando su firma. Reintenta en unos segundos. |
500 XML_PERSIST_ERROR |
No se pudo guardar un XML tras tres intentos. Si el message dice que no se ha enviado nada (subsanar, anulación corregida), reintenta. En crear la factura quedó firmada y encadenada y el reintento no la desbloquea: escríbenos. |
503 CHAIN_CONTENTION |
La cadena de huellas del emisor se estaba escribiendo simultáneamente. No se firmó ni registró nada: reintenta la misma petición. |