FrancodesystemsFran<code>systems
June 14, 2026 · 12 min read · By Juan Cantón Rodríguez

Holded API v1 to v2: the 8 real traps when migrating

The 8 things that break your integration when you migrate from Holded API v1 to v2: cursor vs offset pagination, RFC 7807 errors, granular scopes, bulk operations, webhooks that are not here yet, idempotency under concurrency. With real code and workarounds.

Executive summary
Change #1Single base URL (previously several surfaces per product)
Change #2Cursor pagination (not offset), breaks random iteration
Change #3Structured RFC 7807 errors, consistent
Change #4API key with granular scopes (a v1 key without scopes no longer fits)
Recommended parallel period2 weeks minimum
Native webhooksNot available yet (June 2026), announced as "coming soon"

Why migrate, and why now?

Holded consolidated its API into a single v2 at the end of 2024. v1 is still accessible as an archived reference and existing integrations keep working, but the old portal developers.holded.com has already been retired, and all new development goes exclusively to v2. The official recommendation: new integrations use v2, older integrations plan their migration whenever they tackle any major change.

In practice, what we see every week with clients: an integration built in 2022 against v1 that now has to be extended (add an endpoint, integrate another SaaS, a refactor forced by Verifactu). At that moment it is worth doing the full migration instead of patches on top of patches.

But migrating v1 to v2 is not a find-and-replace of URLs. There are 8 traps we see break repeatedly. We cover them below with real code and workarounds we have verified in production.

Trap #1, cursor pagination breaks v1 loops

v1 uses numeric offset/limit. Your code looked something like this:

// v1, numeric offset
let page = 1;
while (true) {
const items = await holdedV1.contacts.list({ page, limit: 50 });
if (items.length === 0) break;
await processBatch(items);
page++;
}

v2 uses an opaque cursor. The naive equivalent:

// v2, opaque cursor
let cursor = undefined;
while (true) {
const { data, has_more, next_cursor } = await holdedV2.contacts.list({ cursor, limit: 50 });
await processBatch(data);
if (!has_more) break;
cursor = next_cursor;
}

The real trap is when your code saved the position ("I am processing contacts 200 to 250") in a database so it could resume after a crash. With a cursor you cannot, the opaque cursor is only valid for the immediate next call. If you save the cursor and resume it 24h later, it may have expired.

Workaround: save the ID of the last item processed, not the cursor. When resuming, start from the beginning and discard until you reach the last known ID. It is less efficient but robust. For large collections (50k+ contacts), consider processing them in complete nightly batches instead of restarting mid-task.

Trap #2, RFC 7807 errors change your try/catch

v1 returned errors with an inconsistent shape: sometimes { error: "..." }, sometimes { message: "..." }, sometimes just HTTP 400 with no body. Your code probably did something like err.message || err.error || "unknown".

v2 consistently returns RFC 7807 (problem+json):

// HTTP 422 Unprocessable Entity
{
"type": "https://docs.holded.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "vatNumber is invalid for country code ES",
"instance": "/api/v2/contacts/123/update",
"errors": [
{ "field": "vatNumber", "code": "invalid_format" }
]
}

Workaround: update your error handler to extract type + title + status + detail and store it in structured logs. For validation errors, walk the errors array to tell the user exactly which fields failed. The good news: you can now categorise errors as infrastructure / business / validation reliably.

Trap #3, API key scopes leave you with a silent 403

v1 was all or nothing: one API key worked for every endpoint. v2 keeps Bearer authentication (not an OAuth flow) but now each API key is generated with granular scopes such as contacts:contacts.read, invoices:invoices.write, and so on.

The trap: you generate the key in the Holded portal with a couple of scopes picked by eye. Your v1 code calls 30 different endpoints. In staging you probably tested the 5 most common ones and everything worked. In production, after 2 weeks, someone calls a rarely used endpoint (e.g. create a bank remittance) and you get a 403 Forbidden. Your generic try/catch logs it as an error and moves on, but the operation never happened.

Workaround: audit every endpoint your code calls. List the minimum scopes required. Document the endpoint to scope mapping in a file in the repo (it can be a scopes.md or an assertion test that fails if you call an endpoint you did not request a scope for). When you regenerate the key, do it with the full list. And monitor 403s in production with a dedicated alert, the silent 403 is the bug that breaks your monthly close.

Trap #4, bulk operations do not guarantee order (kills Verifactu)

v2 adds bulk endpoints: create 100 contacts at once, approve 50 invoices, delete 300 products. Tempting for cutting down requests.

The trap: bulk does not guarantee internal order. When you send 50 invoices in a batch, Holded can process them in parallel. If your Verifactu chain depends on strict order (SHA-256 fingerprint chained to the immediately preceding one), bulk can break the chain. The AEAT detects it in an audit.

Workaround: distinguish which data is "bulkable" and which is not.

  • Safe to bulk: contacts, products, ledger entries, tags, custom fields, master data in general.
  • Dangerous to bulk: invoices, sales receipts, proformas, recurring invoices with a sensitive issuing order. Process one by one with concurrency 1 in the worker.

More context on why Verifactu requires strict order in the post on Verifactu in ecommerce.

Trap #5, idempotency without an Idempotency-Key

Stripe popularised the Idempotency-Key header as the standard solution. Holded v2 does NOT implement it (as of June 2026). If your webhook handler retries because the first call timed out, you can end up creating two invoices for the same order.

Workaround: an "operations performed" table in your database with a UNIQUE constraint on your external key. Pseudocode:

// pseudo idempotency
async function createInvoiceFromShopify(order) {
const externalKey = `shopify:order:$${order.id}`;
try {
await db.idempotencyKeys.insert({ key: externalKey, status: 'pending' });
} catch (e) {
if (isUniqueViolation(e)) return { status: 'already_processed' };
throw e;
}
const result = await holdedV2.invoices.create({ ...mapOrderToInvoice(order) });
await db.idempotencyKeys.update(externalKey, { status: 'done', holdedId: result.id });
return result;
}

Even with 5 workers processing the same retried webhook in parallel, only one wins the INSERT and the rest fall into the catch. Zero duplicates.

Trap #6, native webhooks do not exist yet (polling continues)

v2 announces webhooks as "coming soon" but as of June 2026 they are not available. Anyone waiting on them for a push-based architecture may be blocked.

Workaround: v2 cursor polling, more efficient than v1 offset polling thanks to cursor stability under inserts. Recommended frequency: 5 minutes for critical data (invoices, payments), 60 minutes for secondary data (contacts, products). And when the webhooks arrive, moving from polling to webhooks is done component by component without touching the rest of the flow.

Trap #7, field mapping changes more than it looks

v2 is not just "v1 with a cursor". Some fields were renamed, others were reorganised into nested objects, others appear and disappear depending on the endpoint. Typical examples we have seen:

  • vatnumber in v1 becomes tax_identifier.value in v2 (an object with a type field as well).
  • billAddress in v1 becomes a nested address object with sub-fields country_code, postal_code, etc. in v2.
  • Date fields in v1 came as a Unix timestamp; in v2 they are ISO 8601 strings. Some endpoints accept both for compatibility, others do not.

Workaround: do not map by hand. Build a v2 adapter layer that your domain consumes, and do all the translation inside the adapter. Your business code keeps talking in terms of the domain, not the API. When v3 arrives in a few years, you only change the adapter.

Trap #8, a parallel period without a safety net

The way to run a v1 to v2 migration that does not blow up in production: a parallel period of 2 weeks minimum.

  • Your production keeps running on v1, no outage for the client.
  • Your staging runs v2 connected to a Holded sandbox (or to a test tenant).
  • Every operation that enters production is replicated to staging through a parallel pipeline. You compare the results of both.
  • Differences (missing fields, incorrect mapping, edge cases) go into a manual review queue. You resolve them and test again.
  • Once you go 3 to 5 days without relevant differences, you do the cut-over: production starts using v2 and you leave v1 available for rollback.

Final trap: doing the cut-over late on a Friday and disabling v1 the same day. If something fails, you have no net. Better: cut-over on a Tuesday morning, leave v1 active for 4 weeks as a disabled but reactivable mirror. If all goes well, you switch v1 off afterwards.

Frequently asked questions

Do I have to migrate my Holded v1 integration to v2 no matter what?

As of June 2026 there is no announced shutdown date for v1. Existing integrations keep working. Holded's official recommendation is that all new development goes to v2 and that older integrations plan their migration whenever they tackle any major change. In practice: if your integration is going to touch code in the next 6 months, take the chance and migrate. If it is stable and you will not touch it, it can wait (but not indefinitely), and it is wise to review the official documentation periodically for roadmap changes.

What changes technically between Holded API v1 and v2?

Four structural changes: (1) a single consolidated base URL (/api/v2/<resource>) instead of several surfaces per product, (2) cursor pagination (with cursor + limit + has_more fields) instead of numeric offset, (3) structured RFC 7807 errors (problem+json) consistent across every endpoint instead of ad hoc messages, (4) API keys with granular scopes (authentication is still a Bearer token, not an OAuth flow). On top of that, v2 broadens coverage with bulk operations, multi-warehouse with stock in transit, and recurring invoices with a schedule.

Does v2 cursor pagination work the same as v1 offset?

Not exactly. With offset (v1) you could jump straight to page 47 if you wanted; with cursor (v2) you have to walk sequentially because each cursor is only valid for the next page. This breaks any flow that assumed 'I can fetch batch X arbitrarily'. The upside: cursor is stable under inserts. If you add an invoice while paginating, you get no duplicates and no gaps. The trap: your v1 code that iterated with offset += pageSize has to be rewritten to 'while has_more do cursor = next'.

Are v2 webhooks available yet?

Not fully as of June 2026. Holded lists them as 'coming soon' in the official documentation. In the meantime, serious integrations keep relying on v2 cursor polling (more efficient than v1 offset polling) or, depending on the case, on triggers from the source system side (Shopify orders/create, Stripe charge.succeeded, etc.) that land in a persisted queue. When native webhooks arrive, moving from polling to webhooks is done component by component without touching the rest of the flow.

How do I handle idempotency in v2 if I have concurrent processing?

v2 does not implement an Idempotency-Key header like Stripe, so you have to guarantee idempotency from your side. Recommended pattern: a unique external key in your system (Shopify order_id, Stripe charge_id, a deterministic hash of the payload fields) and an 'idempotency_keys' table with a UNIQUE constraint. Before calling Holded, INSERT into that table; if UNIQUE is violated, it was already processed. If it passes, call and store the result. This avoids duplicates even with 5 workers processing the same retried webhook in parallel.

Do v2 bulk operations preserve creation order for Verifactu?

Be careful here. Bulk operations are efficient, but the internal creation order is not guaranteed. Holded may parallelise the creation of the batch items. If your Verifactu chain depends on strict order (typically invoices and sales receipts), do NOT use bulk. Process one at a time with concurrency 1 on the worker that generates Verifactu invoices. For non-invoice data (contacts, products, ledger entries) bulk is safe.

What do I do about scopes? My v1 API key works for everything.

v2 introduces granular scopes on the API key (e.g. contacts:contacts.read, contacts:contacts.write, invoices:invoices.write). Authentication is still a Bearer token over HTTPS, not an OAuth flow. The only change is that each key is generated with an explicit set of permissions. If your v1 key was 'all or nothing', in v2 you have to request only the scopes you need when generating it. The good news: least privilege improves security. The bad news: if your code assumes any endpoint works with the same key, you can now get a 403 Forbidden out of nowhere. Audit the endpoints you call, list the minimum scopes required, and generate the key with those. Document it in the code.

Is there a recommended parallel period to validate the migration?

Yes: two weeks minimum. v1 keeps running in your production, v2 writes to a staging environment connected to a Holded test (or to a sandbox of your own). Every operation that enters the system is processed by both pipelines and you compare the outputs. Differences go into a manual review queue. Once you go 3 to 5 days without relevant differences, you do the cut-over. If something fails post-cutover, rolling back to the v1 endpoint is trivial because you have not shut it down yet.

Author

Juan Cantón Rodríguez

Founder and lead developer at Francodesystems. Maintains n8n-nodes-holded with 100% coverage of Holded API v2. By the time this post was published, he had already run v1 to v2 migrations for clients with Shopify, Stripe and Amazon webhooks, the workarounds above are tested in production.

Got a v1 integration that needs migrating?

30 minutes. We tell you whether your case falls into one of the 8 traps and what it costs to leave it clean. No strings attached.