Desarrolladores

API de facturas

Cree y consulte facturas emitidas y recibidas en ToBooky desde su propio programa: tienda online, TPV, ERP, lector de correo de proveedores...

1. Primeros pasos

  1. 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.
  2. Vaya a Claves API y cree una clave. Elija sólo los permisos que su programa necesite.
  3. Copie la clave (tbk_...) y el secreto (tbs_...). El secreto sólo se muestra una vez: guárdelo como guarda una contraseña.
  4. Pruebe la conexión con GET /api/v1/ping. Si contesta 200, 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://www.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

CabeceraValor
X-Api-KeyLa clave pública: tbk_ + 24 caracteres hexadecimales.
X-Api-TimestampHora actual en segundos Unix (UTC). Se admite una diferencia de hasta 300 segundos con el servidor: mantenga el reloj sincronizado por NTP.
X-Api-NonceValor 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-Signaturev1= 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 GET el cuerpo es vacío, y su hash es e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

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

PermisoQué permite
issued:writeCrear facturas emitidas y abonos
issued:readConsultar facturas emitidas y abonos
received:writeRegistrar facturas recibidas
received:readConsultar 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 rutaPermisoQué hace
GET /api/v1/pingcualquieraComprueba credenciales. Devuelve la clave, sus permisos y la empresa.
GET /api/v1/catalogscualquieraEstados, formas de cobro/pago, tipos de IVA, claves vat_operation e irpf_key e inmuebles de la empresa.
POST /api/v1/invoices-issuedissued:writeCrea una factura emitida o un abono.
GET /api/v1/invoices-issuedissued:readLista facturas emitidas, con filtros y paginación.
GET /api/v1/invoices-issued/{id}issued:readUna factura emitida.
POST /api/v1/invoices-receivedreceived:writeRegistra una factura recibida de un proveedor.
GET /api/v1/invoices-receivedreceived:readLista facturas recibidas.
GET /api/v1/invoices-received/{id}received:readUna factura recibida.
POST /api/v1/tax/quoteissued:writeQué 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_pagePágina (desde 1) y tamaño (por defecto 25, máximo 100).
date_from, date_toRango de la fecha de factura, ambos incluidos.
num_documentNúmero exacto.
stateEstado por su keyword (ver /catalogs).
is_credit_note1 sólo abonos, 0 sólo facturas.
id_client / id_providerSó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

POST/api/v1/invoices-issued

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:

ClienteResultado
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 VIESN2, 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 UES1, con IVA español, mientras la empresa no supere el límite anual de la ventanilla única
Empresa de fuera de la UEN2, sin IVA (no sujeta)
Particular de Canarias, Ceuta o Melilla; particular de fuera de la UE; particular de la UE por encima del límiteNo 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.

POST/api/v1/tax/quote

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

CampoTipoDescripción
clientobjeto, 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.
lineslista, obligatoriaDe 1 a 500 líneas. Ver tabla siguiente.
num_documenttextoNú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_notebooleanotrue 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_srcenteroSólo en abonos: id de la factura que rectifica (de esta misma empresa y serie).
datefechaFecha de la factura. Por defecto, hoy.
date_issuefechaFecha de expedición. Por defecto, igual que date.
date_expirationfechaVencimiento.
date_ini, date_endfechaPeriodo facturado, si procede.
statetextoEstado inicial por keyword. Por defecto PENDENT_SEND. Ver /catalogs.
perc_irpfnú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_surchargebooleanoEl cliente está acogido al recargo de equivalencia: se le aplica el recargo de cada tipo de IVA.
vat_operationtextoCalificació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_mentiontextoMenció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.
paymentobjeto{ "id_type", "bank", "bic", "iban" }: forma y cuenta de cobro. Por defecto, las de la empresa. id_type de payment_collect_types en /catalogs.
commentstextoObservaciones.

Líneas

CampoTipoDescripción
subjecttextoConcepto. Obligatorio salvo que se indique id_product.
unit_pricenúmero ≥ 0Precio unitario sin IVA. Obligatorio salvo que se tome del producto.
perc_ivanúmero 0-100% de IVA. Obligatorio salvo que se tome del producto. Ver iva_rates en /catalogs.
quantitynúmero > 0Por defecto 1. Admite decimales.
perc_discountnúmero 0-100% de descuento sobre el precio. Por defecto 0.
id_productenteroProducto del catálogo de la empresa. Lo que no venga en la línea (concepto, código, precio, IVA) se toma de él.
codetextoReferencia 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

POST/api/v1/invoices-received

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:

CampoDiferencia
providerObligatorio, en lugar de client. Mismas dos formas: { "id" } o { "cif", "name", ... }.
num_documentObligatorio: es el número que figura en la factura del proveedor. La misma factura del mismo proveedor no se puede registrar dos veces (422).
statePor defecto PENDENT_PAYMENT; PAYED si ya está pagada.
date_paymentFecha de pago, si ya se ha pagado.
vat_deductible_perc% del IVA que se puede deducir (uso mixto, vehículos...). Por defecto 100.
irpf_keySi 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_propertySólo en alquileres (irpf_key: A): el inmueble alquilado, que el 180 exige identificar. Ver properties en /catalogs.
paymentForma y cuenta de pago; id_type de payment_types en /catalogs.
attachmentOpcional: 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_srcNo 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": []
}
  • issuer y client son los datos fiscales congelados en la factura: los del día en que se emitió, aunque luego cambie la ficha.
  • public_url es la página pública del documento, la misma a la que apunta el QR impreso en el PDF; pdf_url descarga el PDF. Ninguna de las dos necesita autenticación, así que puede enviárselas a su cliente.
  • verifactu sólo aparece si la empresa está acogida: { "operation", "hash", "state", "csv", "qr_url" }.
  • En las líneas, unit_discount y unit_iva son por unidad, y total es la base de la línea (precio con descuento por cantidad, sin IVA).
  • Una factura recibida tiene provider en vez de client, recipient (su empresa) en vez de issuer, date_payment, vat_deductible_perc, irpf_key, id_property y has_attachment; no lleva public_url, pdf_url ni verifactu.

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 de iva 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).

HTTPcodeQué pasa / qué hacer
400invalid_json, invalid_parameter, invalid_idempotency_keyPetición mal formada. Corríjala; reintentar no sirve.
401missing_credentials, invalid_key, invalid_timestamp, invalid_nonceFaltan cabeceras, o la clave no existe, está revocada o caducada.
401invalid_signatureLa 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.
401timestamp_out_of_rangeSu reloj se aleja más de 5 minutos del servidor. Sincronícelo (NTP).
401replayed_nonceNonce repetido. Genere uno nuevo en cada petición, también en los reintentos.
403insufficient_scopeLa clave no tiene el permiso necesario.
403plan_not_includedEl plan contratado no incluye la API. Reintentar no sirve: hay que cambiar de plan.
403ip_not_allowedLa clave no admite su IP.
403https_requiredUse https.
403company_unavailable, user_unavailableLa empresa o el usuario de la clave ya no están activos.
404not_foundEl endpoint o la factura no existen (o son de otra empresa).
409idempotency_in_progressHay otra petición con la misma Idempotency-Key en curso. Reintente en unos segundos.
413payload_too_largeCuerpo de más de 16 MB.
415unsupported_media_typeFalta Content-Type: application/json.
422validation_errorDatos no válidos; ver details.
422vat_not_allowedPor dónde está el cliente, esa venta no se puede facturar (ver IVA según el cliente). Reintentar no sirve.
503vat_check_unavailableNo se ha podido comprobar el NIF-IVA del cliente en VIES. Reintente en unos minutos (con la misma Idempotency-Key).
422idempotency_key_reusedEsa Idempotency-Key ya se usó con otro contenido.
429rate_limitedDemasiadas peticiones. Espere los segundos que indica Retry-After.
429plan_limitSe ha agotado el cupo diario de peticiones del plan. Se renueva a medianoche (hora de España).
500internal_errorError nuestro. Puede reintentar (con la misma Idempotency-Key no hay riesgo de duplicar).
503api_disabledAPI 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.

Descargar tobooky_client.php

<?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;
}

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-Remaining y X-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 id y el num_document que 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.