Skip to content

The Holded API: what the documentation does not tell you

Published on 6 November 2026

Photograph of a work desk with two monitors: one shows blurred blocks of code, the other a table with rows and columns; next to the keyboard, an open notebook with handwritten notes and a pen.

Holded has a well-documented public API, and almost everything the documentation promises works. The trouble is the «almost». Over the last few months we have connected Holded to other systems at four different companies: a medical supplies distributor, a logistics warehouse, an expenses app and a management dashboard. The same surprises turned up in all of them, and none was in the reference. This post brings them together. We re-checked the v2 behaviour on 4 November 2026 against a real account; where something has only been observed, or not tested, we say so.

If you do not know yet what Holded is or what it can automate, start with what Holded is and what you can automate with its API.

Reading: what looks like a complete list and is not

Pagination ignores the page parameter. The API's own description says you paginate with page and limit. You do not: page is silently ignored and you always get the first page back. What works is the cursor: every response carries cursor and has_more, and you request the next batch with cursor=… until has_more is false. An importer written with page processes the first batch over and over, and looks as if it works.

The real limit is 200. By default you get 50 records. Ask for 500 and you get 200, with no error or warning.

The v2 purchases listing leaves documents out. This is the most serious finding. Over the same two-month window, with pagination run to the end and the same filters, v1 returned 157 purchases and v2 returned 128. The 29 missing ones were expense receipts: parking, tolls, taxis. Requested one by one by their identifier, v2 does return them: they exist, but the listing does not show them, and no field tells them apart from the rest. We repeated it on our own account on 4 November: fourteen purchases from this year that v1 returns do not appear in the v2 listing, and do when requested one by one. Nor is there a way to list purchase credit notes: they can only be created and read one at a time. If you mirror purchases into another system and retire whatever stops appearing, you will end up deleting documents that are still in Holded. We still read purchases through v1.

Contacts cannot be fetched «only what changed». The date filters (updated_at_start, start_date) are silently ignored and return the same first page. Searching by email or by code does work, but the vat_number filter is ignored, and the tax ID may be stored in code or in vat_number depending on how the contact was created: searching by tax ID only finds the ones that have it in code. To detect changes you have to walk through all of them or listen to Holded's automatic notifications (webhooks). Purchases and sales, on the other hand, do filter properly by date.

Times arrive without a time zone. A document's approval time (approved_at) comes in Madrid time without saying so: «2026-09-25T19:20:48» is 17:20 universal time. Contact dates, by contrast, do carry the zone. If the server reads the first as universal time, a document approved at 23:30 on the last day of the quarter falls into the next quarter. In v1 there is another trap: a 0 in a date means «no value», not 1 January 1970.

Amounts: text, and not always with the same separator

Every amount arrives as text ("1234.56"), not as a number. That alone means converting them carefully, ideally into whole cents. What we did not expect is this:

  • The decimal separator depends on the account. On the same day, at the same API endpoints (invoices, purchases, estimates and products), one account returned "1234,56" and another "1234.56". One of them had been like that since August. Payments, however, came with a dot in every account. Payroll, with a comma.
  • The error does not show. A converter that only expects a dot does not fail: parseFloat("1234,56") returns 1234 and the cents are lost; others turn the amount into zero. Nobody notices until a quarter does not add up.
  • When writing, send numbers. If you read a product, change one field and send it back as it came, the price that arrived as "5900,00" is saved empty. The response is a 200. That is how real catalogue records lost their purchase price.
  • Holded recalculates VAT from the base. If you send the base with two decimals, the total can come out one cent off what was charged. The fix was to issue with the base at four decimals. And never work out the VAT rate by dividing tax by base if the line has a discount: with a discount, 21 % came out as 18 %.
  • The sign is data. To make credit notes negative, one sync applied «sign times absolute value». That turned positive the corrective invoices Holded already returned as negative, and three years of sales came out inflated by six-figure sums that did not exist. The right rule: a credit note always subtracts; on an invoice, the sign is respected. A test that compared against Holded caught it: the document count matched and the amount did not, which is the signature of a flipped sign rather than a missing invoice.

In v2 the catalogue also rounds product prices and costs to two decimals: a cost of 0.0011 that v1 returns correctly becomes «0.00» in v2. If you allocate costs per unit, read the product through v1.

Writing: a 200 proves nothing

A PUT replaces the whole record. When you update a contact or a product through v2, sending the changed field is not enough: whatever you do not send is deleted. One contact lost its supplier ledger account that way, and that supplier's purchases were booked to the wrong account. You have to read the record, change only what is needed, convert amounts to numbers and send it back complete.

Holded accepts fields it then ignores. It answers 200 and applies the rest. We have seen it with:

  • a product's stock-tracking flag (hasStock), which is read-only;
  • a product's supplier, purchase taxes, factory code and minimum stock, which v2 does not save (Holded support confirmed it);
  • purchase order lines sent as lines: when writing they are called items, and when reading, lines;
  • and product creation through v1, which ignores the supplier, warehouse and expense account, and sets batch expiry to today's date.

The rule we apply everywhere: after every write, read again and compare field by field. Before touching real records, keep a copy on disk and do a «dry» write with the same data that is already there, to see what changes.

A network failure does not mean nothing was written. A purchase order returned «upstream connect error» with no data, and it had been created. In another project the HTTP client retried slow creations on its own, and every shipment ended up with two identical delivery notes: it was an open incident for weeks. Now only what can be repeated without side effects is retried; for creations, a unique marker is stored in the document's notes and, before trying again, we look for whether it already exists.

Numbering is used up even when it fails. Holded reserves the series number when the document is opened, not when it is approved, and does not recycle it. A failed attempt leaves a gap in a tax series that cannot be recovered.

Stock adds, it does not set. The call that changes a product's stock applies a variation: send 10 and it adds 10. In v2 it is named that way (stock_variation) and is clear. In v1, the same kind of call set the value in April and has added since October, with no change notice. A retry deducts twice.

Lines use the subtotal as the unit price. Holded calculates each line's amount as units times subtotal. Sending the line total in subtotal, or cans where cases were expected, multiplies the amount: in testing, one delivery note came out 2,772 times too expensive.

Batch-tracked products: what you can and cannot do

This is where the documentation falls shortest, and where most time is lost. In Holded each batch is a «variant» of the product, with its number (which is the variant's sku: any lot_number you send is ignored), its start date and its expiry. This is what we know, and how sure we are:

WhatPossibleHow, and what to watchCertainty
Create a batch-tracked productYesThrough v1 it is created, but supplier, warehouse and expense account are ignored and expiry is set to todayChecked
Add a batch to an existing productOnly through v2Send the whole product back with every existing batch, each with its id, plus the new one. Any batch you leave out disappearsChecked
Edit a batch-tracked product through v1NoAnswers 400 «Cannot update product lots» even if you only change the supplierChecked
Change only a batch's expiryYes, through v2The same PUT of the whole product, changing only that dateChecked
Give a batch some stockYes, separatelyStock sent inside the batch never reaches the warehouse: you then have to adjust stock naming the batchChecked
Read stock per batch and warehouseYes, through v1With quirks: it hides zero batches, shows already deleted ones and the total may not match the sum; trust the totalChecked
Choose the batch when receiving a purchase orderNot by APIReceiving by API accepts no batch: it is done in the interface. v1 also ties the line to the first existing batchChecked
Batches created while receiving an orderYes, in the interfaceStart and expiry are saved one day before the date entered; read them back and correctObserved
Set or read the batch on sales invoices and delivery notes—This is traceability, and we have not tested it yetNot tested
Switch a product from simple to batch-tracked—Not testedNot tested

And what Holded simply does not offer, by API or anywhere we have seen in the interface: it does not pick the batch that expires first on its own, each batch's cost is always zero (value with the product's average cost instead), it does not give stock at a past date (we take a snapshot at month end) and it does not move batches between warehouses except through the stocktake template. One piece of advice from Holded support itself: do not delete a batch that still has stock from the interface, because it leaves an orphan in the warehouse. Set it to zero and archive it.

We are not the only ones who miss these. On Holded's feature request portal, where users suggest improvements and vote on them, automatic batch selection by expiry date and cost per batch are still open. What is marked as done is a batch traceability report in the interface and editing batch-tracked products through the API, which is what works through v2.

An expensive lesson about stock: adding up batches instead of reading the product total left an inventory 12 % short, because products without batches do not appear in that sum and one batch-tracked product had a negative total.

Keys, errors and quotas

  • An invalid token returns 403, not 401. If your code recognises «bad credentials» by the 401, with Holded it never will.
  • The two versions do not understand each other. v1 uses a key in the key header and v2 a Bearer pat_… token. A v2 token against v1 answers 400 «Invalid key»: the key is fine, the version is wrong. And if the configuration has two variables with the same name, the library in use keeps the last one and everything returns 403, which looks like an expired token. Pick the key by its prefix, not its position.
  • Do not log raw error messages in code that handles the key: one header error carried the key itself inside the message, and it ended up in the server logs.
  • In v1, a route that does not exist can return 200 with the application's HTML page (v2 does answer with a 404). Check the response is JSON before trusting it.
  • There are no rate-limit headers. The documentation mentions 100 requests per minute, but the response does not say how many you have left, and sometimes a 529 arrives with no hint of when to retry. The monthly quota can be queried, but each query counts as a call: a monitor checking it every minute uses 1,440 a month. v1 has no published limit; one request every 250 milliseconds has been safe.

What we have not been able to check

So nobody takes it as fact because it is here: we have no data of our own on the signing, retries, ordering or duplicates of automatic notifications (webhooks), nor on Verifactu through the API, bank reconciliation through the API or accounts with several companies. If you have tested them, we would like to know.

Before putting an integration live

  1. Paginate by cursor and stop when has_more is false.
  2. Convert amounts to cents accepting both comma and dot; if a value is unclear, make it fail visibly.
  3. If you mirror purchases, compare v1 and v2 over a real window before choosing.
  4. Read, change, convert to numbers and send the whole record back. Then read again and compare.
  5. Never blindly retry a creation: first check whether it already exists.
  6. Read document times as Madrid time.
  7. With batches, edit only through v2 and read expiry dates back after every receipt.

If you work with Holded and want other systems to read from it or write to it without these surprises, that is what we do in Holded development and integrations.

Source: Holded developer documentation

Did reading this raise a question?

Ask us. We answer even if you never become a client.