1. Primeros pasos
- Entre en el panel con un usuario que tenga permiso para gestionar las claves de la API y seleccione en la cabecera la empresa con la que va a trabajar.
- Vaya a Claves API y cree una clave. Elija sólo los permisos que su programa necesite.
- Copie la clave (
tbk_...) y el secreto (tbs_...). El secreto sólo se muestra una vez: guárdelo como guarda una contraseña. - Pruebe la conexión con
GET /api/v1/ping. Si contesta200, la firma está bien y el reloj sincronizado: lo que falle después será de los datos, no de la autenticación.
Dirección base de la API:
https://tobooky.es/api/v1
- Sólo HTTPS. Las peticiones por http se rechazan.
- Los cuerpos se envían y se reciben en JSON UTF-8 (
Content-Type: application/json). - Las rutas van en minúsculas y sin barra final.
- Fechas en formato
AAAA-MM-DD. Números como número JSON o como texto con punto decimal ("12.50"; la coma no se admite). - Cada clave trabaja sobre una sola empresa, la que estaba activa al crearla. Para varias empresas, una clave por empresa.
- Una clave puede ir atada a una serie de facturación de la empresa (ver Series). Es lo habitual cuando una empresa factura varios proyectos y cada uno numera aparte.
2. Autenticación y firma
Cada petición se firma con su secreto mediante HMAC-SHA256. El secreto nunca viaja: su programa y el servidor calculan la firma cada uno por su lado y sólo se comparan los resultados. La firma cubre el cuerpo completo, así que nadie puede alterar por el camino un importe, un cliente o una línea sin que la petición se rechace.
Cabeceras obligatorias
| Cabecera | Valor |
|---|---|
X-Api-Key | La clave pública: tbk_ + 24 caracteres hexadecimales. |
X-Api-Timestamp | Hora actual en segundos Unix (UTC). Se admite una diferencia de hasta 300 segundos con el servidor: mantenga el reloj sincronizado por NTP. |
X-Api-Nonce | Valor aleatorio distinto en cada petición, de 16 a 64 caracteres [A-Za-z0-9_-]. Por ejemplo 16 bytes aleatorios en hexadecimal. Un nonce repetido se rechaza: así una petición capturada no se puede reenviar. |
X-Api-Signature | v1= seguido del HMAC-SHA256 en hexadecimal minúsculas de la cadena a firmar. |
Cadena a firmar
Siete líneas unidas por un salto de línea \n (sin salto final):
v1
<X-Api-Timestamp>
<X-Api-Nonce>
<MÉTODO en mayúsculas: GET o POST>
<ruta, sin dominio ni query: /api/v1/invoices-issued>
<query canónica, o línea vacía si no hay>
<SHA-256 del cuerpo en hexadecimal minúsculas>
- Query canónica: los parámetros ordenados alfabéticamente por nombre (y por valor si se repite el nombre), con nombre y valor codificados según RFC 3986 (espacio =
%20, no+) y unidos por&. Ejemplo:?per_page=10&page=2→page=2&per_page=10. - Hash del cuerpo: de los bytes exactos que envía. Si serializa el JSON dos veces, firme y envíe el mismo texto. En un
GETel cuerpo es vacío, y su hash ese3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Ejemplo para comprobar su implementación
Con estos datos su código tiene que obtener exactamente la misma firma:
secreto tbs_0000000000000000000000000000000000000000000000000000000000000000
timestamp 1760518800
nonce 3f9c1a7e5b2d4c6f8a0e
método POST
ruta /api/v1/invoices-issued
cuerpo {"client":{"cif":"B12345678","name":"Cliente Demo SL"},"lines":[{"subject":"Consultoría","quantity":2,"unit_price":100,"perc_iva":21}]}
sha256(cuerpo) fa9b485ed2d7242af75368f059dff8d00f4c29cbb5add76a222cc1ff1f0ef935
firma v1=bce47a63760f9be8924e66199c243cb5eb4d2f60ec1908782e7dc3c1903cb7b4
Y una petición GET /api/v1/invoices-issued?per_page=10&date_from=2026-01-01&page=2 con el mismo secreto, timestamp y nonce:
cadena v1\n1760518800\n3f9c1a7e5b2d4c6f8a0e\nGET\n/api/v1/invoices-issued\ndate_from=2026-01-01&page=2&per_page=10\ne3b0c442...b855
firma v1=e8afc390e9fffaf41855ead971086e82b00bad3adfd41095330d5a64bf775972
Firma de la respuesta
Toda respuesta a una petición autenticada lleva sus propias X-Api-Timestamp y X-Api-Signature, calculadas con el mismo secreto sobre:
v1
<X-Api-Timestamp de la respuesta>
<X-Api-Nonce que USTED envió en la petición>
<código HTTP, p. ej. 201>
<SHA-256 del cuerpo de la respuesta>
Comprobarla le garantiza que la respuesta la ha generado ToBooky y que contesta a esa petición y no a otra. Los clientes de ejemplo ya lo hacen. Los errores previos a la autenticación (401 por clave o firma inválidas, por ejemplo) no van firmados, porque en ese punto todavía no se sabe con qué secreto.
Permisos de la clave
| Permiso | Qué permite |
|---|---|
issued:write | Crear facturas emitidas y abonos |
issued:read | Consultar facturas emitidas y abonos |
received:write | Registrar facturas recibidas |
received:read | Consultar facturas recibidas |
Además, al crear la clave se puede limitar a una lista de IPs o rangos CIDR y ponerle fecha de caducidad. Una clave se puede revocar en cualquier momento desde el panel, con efecto inmediato.
3. Reintentos sin duplicados (Idempotency-Key)
Si una conexión se corta después de enviar una factura, su programa no sabe si llegó. Reintentar a ciegas podría crearla dos veces. Para evitarlo, envíe en cada POST la cabecera:
Idempotency-Key: pedido-2026-001234
- Use un valor único por documento (8 a 80 caracteres
[A-Za-z0-9_-:.]): el id de su pedido o ticket es ideal. - Si repite la petición con la misma clave, no se crea nada nuevo: recibe la misma respuesta que la primera vez, con la cabecera
Idempotent-Replayed: true. - Reutilizar el mismo valor con otro contenido devuelve
422 idempotency_key_reused. - Si la primera petición todavía se está procesando, recibe
409 idempotency_in_progress: espere unos segundos y repita. - Recuerde que cada reintento necesita un nonce y un timestamp nuevos (y por tanto una firma nueva). El cuerpo y la Idempotency-Key, en cambio, deben ser los mismos.
4. Endpoints
| Método y ruta | Permiso | Qué hace |
|---|---|---|
GET /api/v1/ping | cualquiera | Comprueba credenciales. Devuelve la clave, sus permisos y la empresa. |
GET /api/v1/catalogs | cualquiera | Estados, formas de cobro/pago, tipos de IVA, claves vat_operation e irpf_key e inmuebles de la empresa. |
POST /api/v1/invoices-issued | issued:write | Crea una factura emitida o un abono. |
GET /api/v1/invoices-issued | issued:read | Lista facturas emitidas, con filtros y paginación. |
GET /api/v1/invoices-issued/{id} | issued:read | Una factura emitida. |
POST /api/v1/invoices-received | received:write | Registra una factura recibida de un proveedor. |
GET /api/v1/invoices-received | received:read | Lista facturas recibidas. |
GET /api/v1/invoices-received/{id} | received:read | Una factura recibida. |
POST /api/v1/tax/quote | issued:write | Qué IVA lleva una venta a un cliente, antes de cobrarla. Ver IVA según el cliente. |
Listados
Los listados devuelven data (lista de facturas, más recientes primero) y meta con la paginación. Parámetros de la query, todos opcionales:
page, per_page | Página (desde 1) y tamaño (por defecto 25, máximo 100). |
date_from, date_to | Rango de la fecha de factura, ambos incluidos. |
num_document | Número exacto. |
state | Estado por su keyword (ver /catalogs). |
is_credit_note | 1 sólo abonos, 0 sólo facturas. |
id_client / id_provider | Sólo las de ese cliente (emitidas) o proveedor (recibidas). |
{
"data": [ ... ],
"meta": { "page": 1, "per_page": 25, "total": 134, "total_pages": 6 }
}
5. Crear una factura emitida
IVA según el cliente
Si la factura no trae vat_operation, ToBooky decide si lleva IVA según dónde está el cliente (país, código postal y NIF), con las reglas de la venta de servicios de una empresa establecida en la península o Baleares:
| Cliente | Resultado |
|---|---|
| España (península y Baleares) | S1, con el IVA de las líneas |
| Empresa de Canarias, Ceuta o Melilla (código postal 35, 38, 51, 52) | N2, sin IVA (no sujeta) |
| Empresa de otro país de la UE con NIF-IVA válido en VIES | N2, sin IVA (inversión del sujeto pasivo). La comprobación en VIES queda guardada |
| Particular (o empresa sin NIF-IVA en VIES) de otro país de la UE | S1, con IVA español, mientras la empresa no supere el límite anual de la ventanilla única |
| Empresa de fuera de la UE | N2, sin IVA (no sujeta) |
| Particular de Canarias, Ceuta o Melilla; particular de fuera de la UE; particular de la UE por encima del límite | No se factura: 422 vat_not_allowed |
Las facturas sin IVA llevan su mención legal (vat_mention) y la respuesta trae el motivo en warnings.
La misma decisión, antes de cobrar: para que su tienda aplique en el pago el tipo correcto (21 % o 0 %) y la factura que después cree aquí salga igual. Responde 200 también cuando la venta no se permite (allowed: false). Con retry: true no se ha podido comprobar el NIF-IVA en VIES: vuelva a preguntar en unos minutos.
{
"client": { "country": "FR", "cp": "75001", "cif": "FR12345678901", "is_business": true },
"amount": 9.00
}
{
"data": {
"allowed": true,
"case": "eu_business",
"vat_operation": "N2",
"rate": 0,
"reverse_charge": true,
"mention": "Operación no sujeta al IVA español (art. 69 de la Ley 37/1992). Inversión del sujeto pasivo...",
"message": "Empresa de la UE con NIF-IVA válido en VIES: sin IVA (inversión del sujeto pasivo).",
"retry": false,
"vies": { "status": "valid", "country": "FR", "number": "12345678901", "name": "ACME SAS", "request_id": "WAPI...", "date": "2026-10-19 12:00:00", "error": "" }
}
}
amount (opcional) es la base sin IVA de la venta: cuenta para el límite de la ventanilla única. Valores de case: es, es_special_business, es_special_consumer, eu_business, eu_consumer, eu_consumer_over_limit, non_eu_business, non_eu_consumer, vies_unavailable, seller_outside_vat.
Series
Si la clave está atada a una serie, la factura se numera en esa serie (la de facturas o la de abonos) y lleva impreso su nombre comercial, si lo tiene, junto a la razón social y el NIF de la empresa, que siguen siendo los del emisor. La serie no se indica en la petición: la decide la clave, porque un proyecto no cambia de serie. Además, esa clave sólo puede consultar las facturas de su serie. Sin serie, la clave usa la serie por defecto de la empresa y sólo ve las facturas de ésa. GET /api/v1/ping dice en qué serie factura cada clave.
La empresa de la clave es la emisora. La factura queda exactamente igual que si se hubiera tecleado en el panel: misma serie de numeración, mismos cálculos, mismos datos fiscales congelados y, si la empresa está acogida a Veri*factu, mismo registro de facturación encadenado.
{
"client": {
"cif": "B12345678",
"name": "Cliente Demo SL",
"address": "Calle Mayor 1, Madrid",
"cp": "28013",
"email": "admin@cliente-demo.es"
},
"date": "2026-10-15",
"date_expiration": "2026-11-14",
"lines": [
{ "subject": "Consultoría octubre", "quantity": 2, "unit_price": 100, "perc_iva": 21 },
{ "id_product": 57, "quantity": 1 }
],
"comments": "Pedido web #1234"
}
Campos
| Campo | Tipo | Descripción |
|---|---|---|
client | objeto, obligatorio |
El cliente, de una de dos formas: • { "id": 45 }: una ficha de cliente que ya existe en la empresa.• { "cif", "name", "address", "cp", "email", "phone", "country", "is_business" }: se busca la ficha por CIF; si no existe se da de alta con estos datos (name obligatorio en ese caso). Una ficha que ya existe no se modifica, pero los datos enviados son los que quedan impresos en esta factura.• { "name", "email", "address", "cp", "country" } sin cif: un consumidor final (una venta de una tienda online). La factura sale simplificada, sin destinatario identificado (Veri*factu tipo F2). Se reutiliza la ficha sin NIF con ese email o, sin email, la ficha Consumidor final de la empresa. Vale hasta 400 € (3.000 € en comercio minorista): por encima, la respuesta trae un aviso en warnings.country: país del cliente, código ISO de dos letras (ES si no se indica). is_business: si es empresa o autónomo (si no se indica, se considera empresa quien trae cif). Con el código postal, deciden el IVA: ver IVA según el cliente.
|
lines | lista, obligatoria | De 1 a 500 líneas. Ver tabla siguiente. |
num_document | texto | Número de factura. Si no se envía, se asigna el siguiente de la serie de la clave (lo recomendable). Si se envía y ya existe en la serie: 422. |
is_credit_note | booleano | true para un abono (factura rectificativa). Usa la serie de abonos y lleva los importes en negativo: envíe las líneas en positivo, el signo se aplica solo. |
id_invoice_src | entero | Sólo en abonos: id de la factura que rectifica (de esta misma empresa y serie). |
date | fecha | Fecha de la factura. Por defecto, hoy. |
date_issue | fecha | Fecha de expedición. Por defecto, igual que date. |
date_expiration | fecha | Vencimiento. |
date_ini, date_end | fecha | Periodo facturado, si procede. |
state | texto | Estado inicial por keyword. Por defecto PENDENT_SEND. Ver /catalogs. |
perc_irpf | número | % de retención. En las emitidas, por defecto el configurado en la empresa, y sólo se aplica si la empresa es autónomo. En las recibidas, el que aplica el proveedor en su factura (por defecto 0). |
is_equivalence_surcharge | booleano | El cliente está acogido al recargo de equivalencia: se le aplica el recargo de cada tipo de IVA. |
vat_operation | texto | Calificación de la operación a efectos de IVA (claves de la AEAT). Si no se indica, ToBooky la decide según dónde está el cliente (ver IVA según el cliente); un abono con id_invoice_src toma la de su factura. E5 entrega intracomunitaria, N2 servicio a un cliente extranjero, E2 exportación, S2 inversión del sujeto pasivo, E1/E6 exenta, N1 no sujeta. Todas salvo S1 van sin IVA, y deciden en qué casillas del 303 y el 390 se declaran y si entran en el 349. En las recibidas: INT (por defecto), AIS/AIB servicios o bienes de otro país de la UE, ISP inversión del sujeto pasivo, IMP importación, NS sin IVA deducible. |
vat_mention | texto | Mención legal de una factura sin IVA (por qué no lo lleva), que se imprime en el PDF. Si no se indica, la de la operación. Sólo emitidas. |
payment | objeto | { "id_type", "bank", "bic", "iban" }: forma y cuenta de cobro. Por defecto, las de la empresa. id_type de payment_collect_types en /catalogs. |
comments | texto | Observaciones. |
Líneas
| Campo | Tipo | Descripción |
|---|---|---|
subject | texto | Concepto. Obligatorio salvo que se indique id_product. |
unit_price | número ≥ 0 | Precio unitario sin IVA. Obligatorio salvo que se tome del producto. |
perc_iva | número 0-100 | % de IVA. Obligatorio salvo que se tome del producto. Ver iva_rates en /catalogs. |
quantity | número > 0 | Por defecto 1. Admite decimales. |
perc_discount | número 0-100 | % de descuento sobre el precio. Por defecto 0. |
id_product | entero | Producto del catálogo de la empresa. Lo que no venga en la línea (concepto, código, precio, IVA) se toma de él. |
code | texto | Referencia o código del artículo (máx. 20). |
Respuesta: 201 Created con la factura completa en data (ver el objeto factura) y una lista de avisos en warnings, por ejemplo cuando se ha dado de alta un cliente nuevo.
6. Registrar una factura recibida
La empresa de la clave es la receptora; el emisor es un proveedor. Los campos son los mismos que en las emitidas, con estas diferencias:
| Campo | Diferencia |
|---|---|
provider | Obligatorio, en lugar de client. Mismas dos formas: { "id" } o { "cif", "name", ... }. |
num_document | Obligatorio: es el número que figura en la factura del proveedor. La misma factura del mismo proveedor no se puede registrar dos veces (422). |
state | Por defecto PENDENT_PAYMENT; PAYED si ya está pagada. |
date_payment | Fecha de pago, si ya se ha pagado. |
vat_deductible_perc | % del IVA que se puede deducir (uso mixto, vehículos...). Por defecto 100. |
irpf_key | Si la factura lleva retención, a qué modelo va: G profesional o actividad económica (111 y 190), A alquiler de inmueble (115 y 180). Sin ella se declara como G, y la respuesta lo avisa en warnings. |
id_property | Sólo en alquileres (irpf_key: A): el inmueble alquilado, que el 180 exige identificar. Ver properties en /catalogs. |
payment | Forma y cuenta de pago; id_type de payment_types en /catalogs. |
attachment | Opcional: el documento del proveedor, { "filename": "F-2026-0042.pdf", "content_base64": "JVBERi0x..." }. Admite pdf, jpg, jpeg, png, gif, webp, heic, tif y tiff, hasta 10 MB. El contenido tiene que corresponder a la extensión. |
id_invoice_src | No se usa. |
{
"provider": { "cif": "A87654321", "name": "Suministros Norte SA" },
"num_document": "F-2026-0042",
"date": "2026-10-10",
"lines": [
{ "subject": "Material de oficina", "quantity": 1, "unit_price": 84.30, "perc_iva": 21 }
],
"attachment": { "filename": "F-2026-0042.pdf", "content_base64": "JVBERi0xLjQK..." }
}
7. El objeto factura
Es lo que devuelven el alta y las consultas. Ejemplo de una factura emitida:
{
"data": {
"id": 2107,
"object": "invoice_issued",
"is_credit_note": false,
"num_document": "2026/0153",
"series": { "id": 4, "name": "Proyecto Tienda", "trade_name": "La Tienda de Ejemplo" },
"state": { "keyword": "PENDENT_SEND", "name": "PENDIENTE ENVIO" },
"id_invoice_src": null,
"id_budget_src": null,
"date": "2026-10-15",
"date_issue": "2026-10-15",
"date_expiration": "2026-11-14",
"date_ini": null,
"date_end": null,
"issuer": { "name": "Mi Empresa SL", "cif": "B11111111", "address": "Av. Principal 5, 41001" },
"client": { "id": 45, "name": "Cliente Demo SL", "cif": "B12345678", "address": "Calle Mayor 1, Madrid, 28013" },
"is_equivalence_surcharge": false,
"vat_operation": "S1",
"totals": {
"taxable_base": 200.0,
"iva": 42.0,
"equivalence_surcharge": 0.0,
"perc_irpf": 0.0,
"irpf": 0.0,
"total": 242.0,
"payed": 0.0
},
"payment": { "id_type": 2, "bank": "Banco Ejemplo", "bic": null, "iban": "ES9121000418450200051332" },
"lines": [
{
"id": 9001, "order": 1, "id_product": null, "code": "", "subject": "Consultoría octubre",
"quantity": 2.0, "unit_price": 100.0, "perc_discount": 0.0, "unit_discount": 0.0,
"perc_iva": 21.0, "unit_iva": 21.0, "total": 200.0
}
],
"comments": "Pedido web #1234",
"public_url": "https://www.tobooky.es/d/i2107-4f6c1a9b7e3d2058",
"pdf_url": "https://www.tobooky.es/d/i2107-4f6c1a9b7e3d2058/pdf",
"verifactu": null
},
"warnings": []
}
issueryclientson los datos fiscales congelados en la factura: los del día en que se emitió, aunque luego cambie la ficha.public_urles la página pública del documento, la misma a la que apunta el QR impreso en el PDF;pdf_urldescarga el PDF. Ninguna de las dos necesita autenticación, así que puede enviárselas a su cliente.verifactusólo aparece si la empresa está acogida:{ "operation", "hash", "state", "csv", "qr_url" }.- En las líneas,
unit_discountyunit_ivason por unidad, ytotales la base de la línea (precio con descuento por cantidad, sin IVA). - Una factura recibida tiene
provideren vez declient,recipient(su empresa) en vez deissuer,date_payment,vat_deductible_perc,irpf_key,id_propertyyhas_attachment; no llevapublic_url,pdf_urlniverifactu.
8. Cómo se calculan los importes
Los importes los calcula siempre ToBooky a partir de las líneas: base, IVA, recargo, IRPF y total. Si su petición incluye totales, se ignoran. Compare los de la respuesta con los suyos si necesita cuadrarlos.
- Por línea:
descuento = round(precio × %dto / 100, 2),iva unitario = round((precio − descuento) × %iva / 100, 2),total = round((precio − descuento) × cantidad, 2). - Cabecera: base = suma de los
total; IVA = suma deiva unitario × cantidad. - IRPF: sólo si la empresa es autónomo;
round(base × %irpf / 100, 2). - Recargo de equivalencia: sólo si el régimen de IVA de la empresa es el de recargo, con los porcentajes de su zona fiscal.
- Total = base − IRPF + IVA + recargo.
- En los abonos todo sale en negativo.
9. Errores
Todos los errores tienen la misma forma. Base su lógica en code, que es estable; message es para personas y puede cambiar.
{
"error": {
"code": "validation_error",
"message": "Hay datos que no son válidos",
"details": {
"client.cif": "Es obligatorio si no se indica el id",
"lines.1.perc_iva": "Fuera de rango [0, 100]"
}
}
}
En los errores de validación se devuelven todos los fallos a la vez, cada uno con la ruta del campo (lines.1.perc_iva es la segunda línea, empezando en 0).
| HTTP | code | Qué pasa / qué hacer |
|---|---|---|
| 400 | invalid_json, invalid_parameter, invalid_idempotency_key | Petición mal formada. Corríjala; reintentar no sirve. |
| 401 | missing_credentials, invalid_key, invalid_timestamp, invalid_nonce | Faltan cabeceras, o la clave no existe, está revocada o caducada. |
| 401 | invalid_signature | La firma no cuadra. Revise la cadena a firmar con el ejemplo de comprobación: lo habitual es firmar un cuerpo distinto del enviado, o la query sin ordenar. |
| 401 | timestamp_out_of_range | Su reloj se aleja más de 5 minutos del servidor. Sincronícelo (NTP). |
| 401 | replayed_nonce | Nonce repetido. Genere uno nuevo en cada petición, también en los reintentos. |
| 403 | insufficient_scope | La clave no tiene el permiso necesario. |
| 403 | plan_not_included | El plan contratado no incluye la API. Reintentar no sirve: hay que cambiar de plan. |
| 403 | ip_not_allowed | La clave no admite su IP. |
| 403 | https_required | Use https. |
| 403 | company_unavailable, user_unavailable | La empresa o el usuario de la clave ya no están activos. |
| 404 | not_found | El endpoint o la factura no existen (o son de otra empresa). |
| 409 | idempotency_in_progress | Hay otra petición con la misma Idempotency-Key en curso. Reintente en unos segundos. |
| 413 | payload_too_large | Cuerpo de más de 16 MB. |
| 415 | unsupported_media_type | Falta Content-Type: application/json. |
| 422 | validation_error | Datos no válidos; ver details. |
| 422 | vat_not_allowed | Por dónde está el cliente, esa venta no se puede facturar (ver IVA según el cliente). Reintentar no sirve. |
| 503 | vat_check_unavailable | No se ha podido comprobar el NIF-IVA del cliente en VIES. Reintente en unos minutos (con la misma Idempotency-Key). |
| 422 | idempotency_key_reused | Esa Idempotency-Key ya se usó con otro contenido. |
| 429 | rate_limited | Demasiadas peticiones. Espere los segundos que indica Retry-After. |
| 429 | plan_limit | Se ha agotado el cupo diario de peticiones del plan. Se renueva a medianoche (hora de España). |
| 500 | internal_error | Error nuestro. Puede reintentar (con la misma Idempotency-Key no hay riesgo de duplicar). |
| 503 | api_disabled | API desactivada temporalmente. Reintente más tarde. |
10. Clientes de ejemplo
Clientes mínimos, sin dependencias externas, que firman las peticiones y comprueban la firma de las respuestas. Úselos tal cual o como referencia para su lenguaje.
<?php
/**
* Cliente mínimo de la API de facturas de ToBooky (PHP 7.4+, extensiones curl y json).
*
* Uso:
*
* $api = new TobookyClient('https://www.tobooky.es', 'tbk_...', 'tbs_...');
* $res = $api->request('POST', '/api/v1/invoices-issued', [...], 'pedido-1234');
* echo $res['status'], ' ', $res['body']['data']['num_document'];
*
* Documentación: https://www.tobooky.es/developers/api
*/
class TobookyClient
{
private $base;
private $key;
private $secret;
public function __construct(string $base, string $key, string $secret)
{
$this->base = rtrim($base, '/');
$this->key = $key;
$this->secret = $secret;
}
/**
* @param string $method GET o POST
* @param string $path Ruta, p. ej. /api/v1/invoices-issued
* @param array|null $data Cuerpo (POST) o parámetros de la query (GET)
* @param string|null $idempotencyKey Recomendado en POST: un valor único por documento
*
* @return array ['status' => int, 'body' => array, 'headers' => array]
*/
public function request(string $method, string $path, ?array $data = null, ?string $idempotencyKey = null): array
{
$method = strtoupper($method);
$query = '';
$body = '';
if ($method === 'GET' && $data) {
$query = http_build_query($data, '', '&', PHP_QUERY_RFC3986);
} elseif ($data !== null) {
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$timestamp = (string)time();
$nonce = bin2hex(random_bytes(16));
$toSign = implode("\n", [
'v1', $timestamp, $nonce, $method, $path, self::canonicalQuery($query), hash('sha256', $body)
]);
$headers = [
'Accept: application/json',
'X-Api-Key: '.$this->key,
'X-Api-Timestamp: '.$timestamp,
'X-Api-Nonce: '.$nonce,
'X-Api-Signature: v1='.hash_hmac('sha256', $toSign, $this->secret),
];
if ($body !== '') {
$headers[] = 'Content-Type: application/json';
}
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: '.$idempotencyKey;
}
$received = [];
$ch = curl_init($this->base.$path.($query !== '' ? '?'.$query : ''));
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body !== '' ? $body : null,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$received) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$received[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException('Error de conexión: '.$error);
}
// Comprobamos que la respuesta la firma ToBooky y que contesta a ESTA petición
if (isset($received['x-api-signature'])) {
$expected = 'v1='.hash_hmac('sha256', implode("\n", [
'v1', $received['x-api-timestamp'] ?? '', $nonce, (string)$status, hash('sha256', $raw)
]), $this->secret);
if (!hash_equals($expected, $received['x-api-signature'])) {
throw new RuntimeException('La firma de la respuesta no es válida');
}
}
return ['status' => $status, 'body' => json_decode($raw, true), 'headers' => $received];
}
/** Parámetros ordenados por nombre y codificados según RFC 3986 */
public static function canonicalQuery(string $query): string
{
if ($query === '') {
return '';
}
$pairs = [];
foreach (explode('&', $query) as $pair) {
if ($pair === '') {
continue;
}
$p = explode('=', $pair, 2);
$pairs[] = [
rawurlencode(rawurldecode(str_replace('+', ' ', $p[0]))),
rawurlencode(rawurldecode(str_replace('+', ' ', $p[1] ?? ''))),
];
}
usort($pairs, function ($a, $b) {
return [$a[0], $a[1]] <=> [$b[0], $b[1]];
});
return implode('&', array_map(function ($p) {
return $p[0].'='.$p[1];
}, $pairs));
}
}
// Ejemplo: php tobooky_client.php (con TOBOOKY_URL, TOBOOKY_KEY y TOBOOKY_SECRET en el entorno)
if (PHP_SAPI === 'cli' && realpath($argv[0] ?? '') === __FILE__) {
$api = new TobookyClient(getenv('TOBOOKY_URL') ?: 'https://www.tobooky.es', (string)getenv('TOBOOKY_KEY'), (string)getenv('TOBOOKY_SECRET'));
$ping = $api->request('GET', '/api/v1/ping');
echo 'ping: ', $ping['status'], ' ', json_encode($ping['body'], JSON_UNESCAPED_UNICODE), PHP_EOL;
}
"""
Cliente mínimo de la API de facturas de ToBooky (Python 3.8+, sólo biblioteca estándar).
api = TobookyClient("https://www.tobooky.es", "tbk_...", "tbs_...")
status, body, headers = api.request("POST", "/api/v1/invoices-issued", {...}, idempotency_key="pedido-1234")
Documentación: https://www.tobooky.es/developers/api
"""
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
def canonical_query(query: str) -> str:
"""Parámetros ordenados por nombre y codificados según RFC 3986."""
pairs = []
for pair in query.split("&"):
if not pair:
continue
name, _, value = pair.partition("=")
pairs.append((
urllib.parse.quote(urllib.parse.unquote_plus(name), safe="-_.~"),
urllib.parse.quote(urllib.parse.unquote_plus(value), safe="-_.~"),
))
pairs.sort()
return "&".join(f"{n}={v}" for n, v in pairs)
def sign(secret: str, string: str) -> str:
return "v1=" + hmac.new(secret.encode(), string.encode(), hashlib.sha256).hexdigest()
class TobookyClient:
def __init__(self, base: str, key: str, secret: str):
self.base = base.rstrip("/")
self.key = key
self.secret = secret
def request(self, method, path, data=None, idempotency_key=None):
method = method.upper()
query, body = "", b""
if method == "GET" and data:
query = urllib.parse.urlencode(data, quote_via=urllib.parse.quote)
elif data is not None:
body = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
to_sign = "\n".join([
"v1", timestamp, nonce, method, path, canonical_query(query), hashlib.sha256(body).hexdigest()
])
headers = {
"Accept": "application/json",
"X-Api-Key": self.key,
"X-Api-Timestamp": timestamp,
"X-Api-Nonce": nonce,
"X-Api-Signature": sign(self.secret, to_sign),
}
if body:
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
url = self.base + path + ("?" + query if query else "")
req = urllib.request.Request(url, data=body or None, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as res:
status, raw, received = res.status, res.read(), res.headers
except urllib.error.HTTPError as err:
status, raw, received = err.code, err.read(), err.headers
# Comprobamos que la respuesta la firma ToBooky y que contesta a ESTA petición
signature = received.get("X-Api-Signature")
if signature:
expected = sign(self.secret, "\n".join([
"v1", received.get("X-Api-Timestamp", ""), nonce, str(status), hashlib.sha256(raw).hexdigest()
]))
if not hmac.compare_digest(expected, signature):
raise RuntimeError("La firma de la respuesta no es válida")
return status, json.loads(raw or b"null"), dict(received)
if __name__ == "__main__":
api = TobookyClient(os.environ.get("TOBOOKY_URL", "https://www.tobooky.es"),
os.environ["TOBOOKY_KEY"], os.environ["TOBOOKY_SECRET"])
print(api.request("GET", "/api/v1/ping"))
/**
* Cliente mínimo de la API de facturas de ToBooky (Node.js 18+, sin dependencias).
*
* const api = new TobookyClient('https://www.tobooky.es', 'tbk_...', 'tbs_...');
* const { status, body } = await api.request('POST', '/api/v1/invoices-issued', {...}, 'pedido-1234');
*
* Documentación: https://www.tobooky.es/developers/api
*/
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
const sign = (secret, string) => 'v1=' + createHmac('sha256', secret).update(string).digest('hex');
const rfc3986 = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
const decode = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
/** Parámetros ordenados por nombre y codificados según RFC 3986 */
export function canonicalQuery(query) {
return query.split('&').filter(Boolean)
.map((pair) => {
const i = pair.indexOf('=');
const name = i < 0 ? pair : pair.slice(0, i);
const value = i < 0 ? '' : pair.slice(i + 1);
return [rfc3986(decode(name)), rfc3986(decode(value))];
})
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
.map(([n, v]) => `${n}=${v}`)
.join('&');
}
export class TobookyClient {
constructor(base, key, secret) {
this.base = base.replace(/\/+$/, '');
this.key = key;
this.secret = secret;
}
async request(method, path, data = null, idempotencyKey = null) {
method = method.toUpperCase();
let query = '';
let body = '';
if (method === 'GET' && data) {
query = Object.entries(data).map(([k, v]) => `${rfc3986(k)}=${rfc3986(String(v))}`).join('&');
} else if (data !== null) {
body = JSON.stringify(data);
}
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString('hex');
const toSign = ['v1', timestamp, nonce, method, path, canonicalQuery(query), sha256(body)].join('\n');
const headers = {
Accept: 'application/json',
'X-Api-Key': this.key,
'X-Api-Timestamp': timestamp,
'X-Api-Nonce': nonce,
'X-Api-Signature': sign(this.secret, toSign),
};
if (body) headers['Content-Type'] = 'application/json';
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const res = await fetch(this.base + path + (query ? '?' + query : ''), { method, headers, body: body || undefined });
const raw = Buffer.from(await res.arrayBuffer());
// Comprobamos que la respuesta la firma ToBooky y que contesta a ESTA petición
const signature = res.headers.get('x-api-signature');
if (signature) {
const expected = sign(this.secret, ['v1', res.headers.get('x-api-timestamp') ?? '', nonce, String(res.status), sha256(raw)].join('\n'));
if (expected.length !== signature.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
throw new Error('La firma de la respuesta no es válida');
}
}
return { status: res.status, body: raw.length ? JSON.parse(raw.toString('utf8')) : null, headers: res.headers };
}
}
// Ejemplo: node tobooky_client.mjs (con TOBOOKY_URL, TOBOOKY_KEY y TOBOOKY_SECRET en el entorno)
if (import.meta.url === `file://${process.argv[1]}`) {
const api = new TobookyClient(process.env.TOBOOKY_URL ?? 'https://www.tobooky.es', process.env.TOBOOKY_KEY, process.env.TOBOOKY_SECRET);
console.log(await api.request('GET', '/api/v1/ping'));
}
#!/usr/bin/env bash
# Petición firmada a la API de facturas de ToBooky, con curl y openssl.
# Documentación: https://www.tobooky.es/developers/api
#
# Uso: TOBOOKY_KEY=tbk_... TOBOOKY_SECRET=tbs_... bash tobooky_curl.sh
API="${TOBOOKY_URL:-https://www.tobooky.es}"; KEY="$TOBOOKY_KEY"; SECRET="$TOBOOKY_SECRET"
METHOD="POST"; PATH_="/api/v1/invoices-issued"; QUERY=""
BODY='{"client":{"cif":"B12345678","name":"Cliente Demo SL"},"lines":[{"subject":"Consultoría","quantity":2,"unit_price":100,"perc_iva":21}]}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
TO_SIGN=$(printf 'v1\n%s\n%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "$METHOD" "$PATH_" "$QUERY" "$BODY_HASH")
SIG="v1=$(printf '%s' "$TO_SIGN" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"
curl -sS -X "$METHOD" "$API$PATH_" \
-H "Content-Type: application/json" \
-H "X-Api-Key: $KEY" -H "X-Api-Timestamp: $TS" -H "X-Api-Nonce: $NONCE" -H "X-Api-Signature: $SIG" \
-H "Idempotency-Key: pedido-1234" \
--data-binary "$BODY"
Uso típico (PHP)
require 'tobooky_client.php';
$api = new TobookyClient('https://www.tobooky.es', getenv('TOBOOKY_KEY'), getenv('TOBOOKY_SECRET'));
$res = $api->request('POST', '/api/v1/invoices-issued', [
'client' => ['cif' => 'B12345678', 'name' => 'Cliente Demo SL'],
'lines' => [
['subject' => 'Consultoría octubre', 'quantity' => 2, 'unit_price' => 100, 'perc_iva' => 21],
],
], 'pedido-1234'); // Idempotency-Key: el id de su pedido
if ($res['status'] === 201) {
echo 'Factura ', $res['body']['data']['num_document'], ': ', $res['body']['data']['pdf_url'];
} else {
echo 'Error ', $res['body']['error']['code'], ': ', $res['body']['error']['message'];
}
11. Buenas prácticas y límites
- El secreto, sólo en el servidor. Nunca lo ponga en una web, una app móvil o cualquier código que se ejecute en el equipo de un tercero: quien lo tenga puede emitir facturas en su nombre. Guárdelo en una variable de entorno o un gestor de secretos.
- Una clave por programa, con los permisos justos. Si un programa se retira o el secreto se expone, revoque sólo esa clave.
- Si sus servidores tienen IP fija, limite la clave a esas IPs: un secreto filtrado no servirá desde fuera.
- Envíe siempre Idempotency-Key en los POST y reintente los errores 409, 429, 500 y 503 y los cortes de red con espera creciente (1 s, 2 s, 4 s...).
- Límite: 120 peticiones por minuto y clave. Las respuestas llevan
X-RateLimit-Limit,X-RateLimit-RemainingyX-RateLimit-Reset. - Compruebe la firma de las respuestas antes de dar una factura por creada.
- Para no depender de ids, guarde en su sistema el
idy elnum_documentque devuelve el alta. - La versión va en la ruta (
/v1). Dentro de una versión sólo se añaden campos: ignore los que no conozca. Quitar o cambiar algo implicaría una/v2, con aviso previo.