Holded tiene una API pública bien documentada, y casi todo lo que promete la documentación funciona. El problema es el «casi». En los últimos meses hemos conectado Holded con otros sistemas en cuatro empresas distintas: una distribuidora de material sanitario, un almacén logístico, una app de gastos y un panel de gestión. En todas aparecieron las mismas sorpresas, y ninguna estaba en la referencia. Esta entrada las junta. Lo de la API v2 lo hemos vuelto a comprobar el 4 de noviembre de 2026 contra una cuenta real; donde algo solo lo hemos observado, o no lo hemos probado, lo decimos.
Si todavía no sabes qué es Holded ni qué se puede automatizar con él, empieza por qué es Holded y qué se puede automatizar con su API.
Leer: lo que parece una lista completa y no lo es
La paginación ignora el parámetro page. La propia descripción de la API dice que se pagina con page y limit. No es así: page se ignora en silencio y siempre vuelve la primera página. Lo que funciona es el cursor: cada respuesta trae cursor y has_more, y se pide la siguiente con cursor=… hasta que has_more sea falso. Un importador escrito con page procesa el primer bloque una y otra vez, y parece que funciona.
El límite real es 200. Por defecto llegan 50 registros. Si pides 500, llegan 200, sin ningún error ni aviso.
El listado de compras de la v2 se deja documentos fuera. Es el hallazgo más grave. En una misma ventana de dos meses, con la paginación recorrida entera y los mismos filtros, la v1 devolvió 157 compras y la v2, 128. Las 29 que faltaban eran tickets de gasto: aparcamientos, peajes, taxis. Si se piden una a una por su identificador, la v2 sí las devuelve: existen, pero el listado no las enseña, y ningún campo las distingue de las demás. Lo hemos repetido en nuestra propia cuenta el 4 de noviembre: catorce compras de este año que devuelve la v1 no salen en el listado de la v2, y sí al pedirlas una a una. Tampoco hay forma de listar las rectificativas de compra: solo se pueden crear y leer de una en una. Si espejas las compras en otro sistema y das de baja lo que deja de aparecer, acabarás borrando documentos que siguen en Holded. Nosotros seguimos leyendo las compras por la v1.
Los contactos no se pueden traer «solo lo nuevo». Los filtros por fecha (updated_at_start, start_date) se ignoran sin avisar y devuelven la misma primera página. Buscar por correo (email) o por código (code) sí funciona, pero el filtro vat_number se ignora, y el NIF puede estar guardado en code o en vat_number según cómo se creara el contacto: buscar por NIF solo encuentra los que lo tienen en code. Para detectar cambios hay que recorrerlos todos o escuchar los avisos automáticos (webhooks) de Holded. Las compras y las ventas, en cambio, sí filtran bien por fecha.
Las horas llegan sin zona horaria. La fecha de aprobación de un documento (approved_at) viene en hora de Madrid pero sin indicarlo: «2026-09-25T19:20:48» son las 17:20 en hora universal. Las fechas de los contactos, en cambio, sí traen la zona. Si el servidor interpreta la primera como hora universal, un documento aprobado a las 23:30 del último día del trimestre cae en el trimestre siguiente. En la v1 hay otra trampa: un 0 en una fecha significa «sin valor», no el 1 de enero de 1970.
Importes: texto, y no siempre con el mismo separador
Todos los importes llegan como texto ("1234.56"), no como números. Eso ya obliga a convertirlos con cuidado, y conviene hacerlo a céntimos enteros. Lo que no esperábamos es esto:
- El separador decimal depende de la cuenta. El mismo día, en los mismos puntos de la API (facturas, compras, presupuestos y productos), una cuenta devolvía
"1234,56"y otra"1234.56". En una de ellas llevaba así desde agosto. Los pagos, en cambio, llegaban con punto en todas. Las nóminas, con coma. - El error no se ve. Un conversor que solo espere punto no falla:
parseFloat("1234,56")devuelve 1234 y se pierden los céntimos; otros convierten el importe en cero. Nadie se entera hasta que no cuadra un trimestre. - Al escribir, manda números. Si lees un producto, cambias un campo y lo reenvías tal cual, el precio que venía como
"5900,00"se guarda vacío. La respuesta es un 200. Así se quedaron sin precio de compra fichas reales de un catálogo. - Holded recalcula el IVA desde la base. Si envías la base con dos decimales, el total puede salir un céntimo distinto del cobrado. La solución fue emitir con la base a cuatro decimales. Y nunca calcules el tipo de IVA dividiendo cuota entre base si la línea tiene descuento: con un descuento, un 21 % salía como un 18 %.
- El signo es un dato. Para dejar los abonos en negativo, una sincronización hacía «signo por valor absoluto». Eso volvía positivas las rectificativas que Holded ya devolvía en negativo, y tres ejercicios de ventas salían inflados en cifras de seis dígitos que no existían. La regla buena: un abono resta siempre; en una factura, el signo se respeta. Lo detectó una prueba que comparaba con Holded: cuadraba el número de documentos y no el importe, que es la firma de un signo cambiado y no de una factura que falta.
En la v2, el catálogo también redondea los precios y costes de producto a dos decimales: un coste de 0,0011 que la v1 devuelve bien, en la v2 es «0.00». Si imputas costes por unidad, lee el producto por la v1.
Escribir: un 200 no prueba nada
Un PUT sustituye la ficha entera. Al actualizar un contacto o un producto por la v2 no basta con mandar el campo que cambia: lo que no envías se borra. Un contacto perdió así su cuenta contable de proveedor, y las compras de ese proveedor se contabilizaron donde no tocaba. Hay que leer la ficha, cambiar solo lo necesario, convertir los importes a número y reenviarla completa.
Holded acepta campos que luego ignora. Responde 200 y aplica lo demás. Lo hemos visto con:
- el control de stock de un producto (
hasStock), que es de solo lectura; - el proveedor, los impuestos de compra, el código de fábrica y el stock mínimo de un producto, que la v2 no guarda (el soporte de Holded nos lo confirmó);
- las líneas de un pedido de compra mandadas como
lines: al escribir se llamanitems, y al leer,lines; - y la creación de productos por la v1, que ignora el proveedor, el almacén y la cuenta de gasto, y pone de caducidad de los lotes la fecha de hoy.
La regla que aplicamos en todas: después de cada escritura, leer otra vez y comparar campo por campo. Antes de tocar fichas de verdad, guardar una copia en disco y hacer una escritura «en vacío» con los mismos datos que ya hay, para ver qué cambia.
Que falle la red no quiere decir que no se haya escrito. Un pedido de compra devolvió «upstream connect error» sin ningún dato, y sí se había creado. En otro proyecto, el cliente HTTP reintentaba solo las creaciones que tardaban, y cada expedición acababa con dos albaranes iguales: fue un incidente abierto durante semanas. Ahora solo se reintenta lo que se puede repetir sin efecto; para las creaciones, se guarda una marca única en las notas del documento y, antes de volver a intentarlo, se busca si ya existe.
La numeración se gasta aunque falle. Holded reserva el número de la serie al abrir el documento, no al aprobarlo, y no lo recicla. Un intento fallido deja un hueco en una serie fiscal que no se puede recuperar.
El stock suma, no fija. La llamada para cambiar el stock de un producto aplica una variación: si mandas 10, suma 10. En la v2 se llama así (stock_variation) y se entiende. En la v1, el mismo tipo de llamada fijaba el valor en abril y desde octubre suma, sin aviso de cambio. Un reintento descuenta dos veces.
Las líneas usan el subtotal como precio unitario. Holded calcula el importe de cada línea como unidades por subtotal. Mandar en subtotal el total de la línea, o latas donde se esperaban cajas, multiplica el importe: en pruebas, un albarán salió 2.772 veces más caro.
Productos con lotes: qué se puede y qué no
Es donde la documentación se queda más corta, y donde más tiempo se pierde. En Holded, cada lote es una «variante» del producto, con su número (que es el sku de la variante: el campo lot_number que mandes se ignora), su fecha de inicio y su caducidad. Esto es lo que sabemos, con su grado de certeza:
| Qué | Se puede | Cómo y cuidado | Certeza |
|---|---|---|---|
| Crear un producto con lotes | Sí | Por la v1 se crea, pero ignora proveedor, almacén y cuenta de gasto, y pone la caducidad en el día de hoy | Comprobado |
| Añadir un lote a un producto que ya existe | Solo por la v2 | Reenviar el producto entero con todos los lotes existentes, cada uno con su id, más el nuevo. El que no reenvías desaparece | Comprobado |
| Editar un producto con lotes por la v1 | No | Responde 400 «Cannot update product lots» aunque solo cambies el proveedor | Comprobado |
| Cambiar solo la caducidad de un lote | Sí, por la v2 | El mismo PUT del producto entero, cambiando solo esa fecha | Comprobado |
| Dar stock a un lote | Sí, aparte | El stock que va dentro del lote no llega al almacén: hay que hacer después el ajuste de stock indicando el lote | Comprobado |
| Leer el stock de cada lote por almacén | Sí, por la v1 | Con rarezas: oculta lotes a cero, enseña lotes ya borrados y el total puede no coincidir con la suma; manda el total | Comprobado |
| Elegir el lote al recibir un pedido de compra | No por API | La recepción por API no admite lote: se hace en la interfaz. La v1, además, ata la línea al primer lote que exista | Comprobado |
| Lotes creados al recibir un pedido | Sí, en la interfaz | Se guardan con inicio y caducidad un día antes de la fecha marcada; hay que releer y corregir | Observado |
| Indicar o leer el lote en facturas y albaranes de venta | — | Es la trazabilidad, y aún no lo hemos probado | No probado |
| Cambiar un producto de simple a lotes | — | Sin probar | No probado |
Y lo que, directamente, Holded no ofrece, ni por API ni que hayamos visto en la interfaz: no elige solo el lote que caduca antes, el coste de cada lote siempre vale cero (hay que valorar con el coste medio del producto), no da el stock a una fecha pasada (hacemos una foto a final de mes) y no mueve lotes entre almacenes salvo con la plantilla de inventario. Un consejo que nos dio el propio soporte: no borres desde la interfaz un lote que tenga stock, porque deja un huérfano en el almacén. Ponlo a cero y archívalo.
No somos los únicos que lo echamos en falta. En el portal de peticiones de Holded, donde los usuarios proponen mejoras y las votan, siguen abiertas la selección automática del lote según caducidad y el coste por lote. Lo que sí figura como hecho es un informe de trazabilidad por lote en la interfaz y la edición por API de productos con lotes, que es lo que funciona por la v2.
Una lección cara sobre el stock: sumar los lotes en lugar de leer el total del producto dejó un inventario un 12 % corto, porque los productos sin lotes no aparecen en esa suma y uno con lotes tenía el total en negativo.
Claves, errores y cuotas
- Un token inválido devuelve 403, no 401. Si tu código reconoce «credencial mala» por el 401, con Holded nunca lo hará.
- Las dos versiones no se entienden. La v1 usa una clave en la cabecera
keyy la v2 un tokenBearer pat_…. Un token de la v2 contra la v1 responde 400 «Invalid key»: la clave está bien, lo que está mal es la versión. Y si en la configuración hay dos variables con el mismo nombre, la librería de turno se queda con la última y todo da 403, que parece un token caducado. Elegid la clave por su prefijo, no por su posición. - No registréis el mensaje de error en crudo en el código que maneja la clave: un error de cabecera llevaba la propia clave dentro del mensaje, y acabó en los registros del servidor.
- En la v1, una ruta que no existe puede devolver 200 con la página de la aplicación en HTML (la v2 sí responde con un 404). Comprobad que la respuesta es JSON antes de darla por buena.
- No hay cabeceras de límite de uso. La documentación habla de 100 peticiones por minuto, pero la respuesta no dice cuántas te quedan, y a veces llega un 529 sin indicar cuándo reintentar. La cuota mensual sí se puede consultar, pero cada consulta cuenta como una llamada: un monitor que la mire cada minuto gasta 1.440 al mes. En la v1 no hay límite publicado; una petición cada 250 milisegundos ha sido segura.
Lo que no hemos podido comprobar
Para que nadie lo dé por cierto por estar aquí: no tenemos datos propios sobre la firma, los reintentos, el orden o los duplicados de los avisos automáticos (webhooks), ni sobre Verifactu por API, la conciliación bancaria por API o las cuentas con varias empresas. Si los has probado, nos interesa saberlo.
Antes de poner una integración en marcha
- Pagina por cursor y para cuando
has_moresea falso. - Convierte los importes a céntimos aceptando coma y punto; si un valor no está claro, que falle a la vista.
- Si espejas compras, compara la v1 con la v2 en una ventana real antes de elegir.
- Lee, cambia, convierte a número y reenvía la ficha entera. Después, vuelve a leer y compara.
- No reintentes a ciegas una creación: busca primero si ya existe.
- Interpreta las horas de los documentos como hora de Madrid.
- Con lotes, edita solo por la v2 y vuelve a leer las caducidades después de cada recepción.
Si trabajáis con Holded y queréis que otros sistemas lean o escriban en él sin estas sorpresas, es lo que hacemos en desarrollos e integraciones con Holded.
