| Cambio #1 | URL base única (antes varias surfaces por producto) |
|---|---|
| Cambio #2 | Paginación cursor (no offset) — rompe iteración aleatoria |
| Cambio #3 | Errores RFC 7807 estructurados consistentes |
| Cambio #4 | API key con scopes granulares (key v1 sin scopes ya no encaja) |
| Periodo paralelo recomendado | 2 semanas mínimo |
| Webhooks nativos | Aún no disponibles (junio 2026) — anunciados "próximamente" |
¿Por qué migrar y por qué ahora?
Holded consolidó su API en una sola v2 a finales de 2024. La v1 sigue accesible como referencia archivada y las integraciones existentes siguen funcionando, pero el portal anterior developers.holded.com ya se retiró y todo el desarrollo nuevo va exclusivamente a v2. La recomendación oficial: integraciones nuevas usan v2, integraciones antiguas planifican migración cuando aborden cualquier cambio mayor.
En la práctica, lo que vemos cada semana en clientes: una integración hecha en 2022 contra v1 que ahora hay que ampliar (añadir un endpoint, integrar otro SaaS, refactor por culpa de Verifactu). En ese momento conviene hacer la migración completa en vez de parches sobre parches.
Pero migrar v1→v2 no es buscar y reemplazar URLs. Hay 8 trampas que vemos romperse repetidamente. Las cubrimos abajo con código real y workarounds que hemos verificado en producción.
Trampa #1 — Paginación cursor rompe bucles de v1
v1 usa offset/limit numérico. Tu código era algo así:
v2 usa cursor opaco. La equivalencia naive:
La trampa real es cuando tu código guardaba la posición ("estoy procesando contactos del 200 al 250") en una base de datos para poder retomar después de un crash. Con cursor no puedes — el cursor opaco solo es válido para la siguiente llamada inmediata. Si guardas el cursor y lo retomas 24h después, puede haber expirado.
Workaround: guarda el ID del último item procesado, no el cursor. Al retomar, empieza desde el principio y descarta hasta llegar al último ID conocido. Es menos eficiente pero robusto. Para colecciones grandes (50k+ contactos) considera procesarlas en lotes nocturnos completos en vez de re-iniciar a media tarea.
Trampa #2 — Errores RFC 7807 cambian tu try/catch
v1 devolvía errores con shape inconsistente: a veces { error: "..." }, a veces { message: "..." }, a veces solo HTTP 400 sin body. Tu código probablemente hacía algo como err.message || err.error || "unknown".
v2 devuelve consistentemente RFC 7807 (problem+json):
Workaround: actualiza tu error handler para extraer type + title + status + detail y guardar en logs estructurados. Para validation errors, recorre el array errors para devolver al usuario qué campos exactos fallaron. La buena noticia: ahora puedes categorizar errores en infrastructure / business / validation de forma fiable.
Trampa #3 — Scopes de API key te dejan en 403 silencioso
v1 era todo-o-nada: una API key funcionaba para todos los endpoints. v2 mantiene autenticación Bearer (no OAuth flow) pero ahora cada API key se genera con scopes granulares como contacts:contacts.read, invoices:invoices.write, etc.
La trampa: generas la key en el portal Holded con un par de scopes seleccionados a ojo. Tu código de v1 llama a 30 endpoints distintos. En staging probablemente has probado los 5 más comunes y todo funcionaba. En producción, al cabo de 2 semanas, alguien llama un endpoint poco usado (ej. crear remittance bancaria) y recibes 403 Forbidden. Tu try/catch genérico lo registra como error y sigue, pero la operación nunca se hizo.
Workaround: audita todos los endpoints que tu código llama. Lista los scopes mínimos necesarios. Documenta el mapeo endpoint → scope en un fichero del repo (puede ser un scopes.md o un assertion test que falla si llamas un endpoint para el que no pediste scope). Cuando regeneres la key, hazlo con la lista completa. Y monitoriza 403s en producción con alerta dedicada — el 403 silencioso es el bug que te rompe el cierre mensual.
Trampa #4 — Bulk operations no garantizan orden (mata Verifactu)
v2 añade endpoints bulk: crear 100 contactos de golpe, aprobar 50 facturas, eliminar 300 productos. Tentador para reducir requests.
La trampa: bulk no garantiza orden interno. Cuando mandas 50 facturas en un batch, Holded las puede procesar paralelamente. Si tu cadena Verifactu depende del orden estricto (huella SHA-256 encadenada con la inmediatamente anterior), bulk te puede romper la cadena. La AEAT lo detecta en inspección.
Workaround: distingue qué datos son "bulkables" y cuáles no.
- ✓Bulk seguro: contactos, productos, asientos contables, tags, custom fields, masters en general.
- ✕Bulk peligroso: facturas, sales receipts, proformas, recurring invoices con orden de emisión sensible. Procesa uno-a-uno con concurrencia 1 en el worker.
Más contexto sobre por qué Verifactu requiere orden estricto en el post de Verifactu en ecommerce.
Trampa #5 — Idempotencia sin Idempotency-Key
Stripe popularizó el header Idempotency-Key como solución estándar. v2 de Holded NO lo implementa (a junio 2026). Si tu webhook handler reintenta porque la primera llamada timed out, puedes acabar creando dos facturas para el mismo pedido.
Workaround: tabla de "operations performed" en tu base de datos con UNIQUE constraint sobre tu clave externa. Pseudocódigo:
Aún con 5 workers procesando en paralelo el mismo webhook reintentado, solo uno consigue el INSERT y el resto cae en el catch. Cero duplicados.
Trampa #6 — Webhooks nativos aún no existen (polling continúa)
v2 anuncia webhooks como "próximamente" pero a junio 2026 no están disponibles. Quien esté esperándolos para arquitectura push-based puede estar bloqueado.
Workaround: polling con cursor de v2 — más eficiente que polling con offset de v1 gracias a la estabilidad del cursor bajo inserciones. Frecuencia recomendada: 5 minutos para datos críticos (facturas, cobros), 60 minutos para datos secundarios (contactos, productos). Y cuando los webhooks lleguen, la migración del polling al webhook se hace por componente sin tocar el resto del flujo.
Trampa #7 — Mapeo de campos cambia más de lo que parece
v2 no es solo "v1 con cursor". Algunos campos cambiaron de nombre, otros se reorganizaron en objetos anidados, otros aparecen y desaparecen según el endpoint. Ejemplos típicos que hemos visto:
- →
vatnumberen v1 →tax_identifier.valueen v2 (objeto con campo type además). - →
billAddressen v1 → objetoaddressanidado con sub-campos country_code, postal_code, etc. en v2. - →Campos de fecha en v1 venían como timestamp Unix; en v2 son ISO 8601 string. Algunos endpoints aceptan ambos por compatibilidad, otros no.
Workaround: no mapees a mano. Crea una capa adapter v2 que tu dominio consume, y dentro del adapter haces toda la traducción. Tu código de negocio sigue hablando del dominio, no de la API. Cuando llegue v3 dentro de unos años, solo cambias el adapter.
Trampa #8 — Periodo paralelo sin red de seguridad
La forma de hacer una migración v1→v2 que no te explote en producción: periodo paralelo de 2 semanas mínimo.
- →Tu producción sigue corriendo en v1 — ningún corte para el cliente.
- →Tu staging corre v2 conectado a una sandbox de Holded (o a un tenant test).
- →Cada operación que entra a producción se replica a staging por pipeline paralelo. Comparas resultados de ambas.
- →Las diferencias (campos faltantes, mapeo incorrecto, edge cases) entran a una cola de revisión manual. Las resuelves y vuelves a probar.
- →Cuando llevas 3-5 días sin diferencias relevantes, haces el cut-over: producción empieza a usar v2 y dejas v1 disponible para rollback.
Trampa final: hacer el cut-over un viernes tarde y desactivar v1 el mismo día. Si algo falla, no tienes red. Mejor: cut-over un martes por la mañana, deja v1 activa 4 semanas como espejo deshabilitado pero reactivable. Si todo va bien, apagas v1 después.
Preguntas frecuentes
¿Tengo que migrar mi integración Holded v1 a v2 obligatoriamente?
A junio 2026 no hay fecha de apagado anunciada para v1. Las integraciones existentes siguen funcionando. La recomendación oficial de Holded es que todo desarrollo nuevo vaya a v2 y que las integraciones antiguas planifiquen migración cuando aborden cualquier cambio mayor. En la práctica: si tu integración va a tocar código en los próximos 6 meses, aprovecha y migra. Si está estable y no la vas a tocar, puede esperar — pero no indefinidamente, y conviene revisar la documentación oficial periódicamente por cambios en el roadmap.
¿Qué cambia técnicamente entre Holded API v1 y v2?
Cuatro cambios estructurales: (1) URL base única consolidada (/api/v2/<resource>) en lugar de varias surfaces por producto, (2) paginación por cursor (con campos cursor + limit + has_more) en lugar de offset numérico, (3) errores estructurados RFC 7807 (problem+json) consistentes en todos los endpoints en lugar de mensajes ad hoc, (4) API keys con scopes granulares (autenticación sigue siendo Bearer token, no OAuth flow). Además v2 amplía cobertura con operaciones bulk, multi-almacén con stock en tránsito y facturas recurrentes con calendario.
¿La paginación por cursor de v2 funciona igual que offset de v1?
No exactamente. Con offset (v1) podías saltar directamente a la página 47 si querías; con cursor (v2) tienes que recorrer secuencialmente porque cada cursor solo es válido para la siguiente página. Esto rompe cualquier flujo que asumía 'puedo recuperar el batch X arbitrariamente'. La ventaja: cursor es estable bajo inserciones — si añades una factura mientras paginas, no aparecen duplicados ni huecos. La trampa: tu código de v1 que iteraba con offset += pageSize hay que reescribirlo a 'while has_more do cursor = next'.
¿Los webhooks de v2 ya están disponibles?
No del todo a junio 2026 — Holded los anuncia como 'próximamente' en la documentación oficial. Mientras tanto, las integraciones serias siguen apoyándose en polling con cursor de v2 (más eficiente que el polling con offset de v1) o, según el caso, en triggers desde el lado del sistema origen (Shopify orders/create, Stripe charge.succeeded, etc.) que entran a una cola persistida. Cuando los webhooks nativos lleguen, la migración del polling al webhook se hace por componente sin tocar el resto del flujo.
¿Cómo gestiono la idempotencia en v2 si tengo procesamiento concurrente?
v2 no implementa Idempotency-Key header como Stripe — tienes que garantizar idempotencia desde tu lado. Patrón recomendado: clave externa única en tu sistema (order_id de Shopify, charge_id de Stripe, hash deterministico de los campos del payload) y una tabla 'idempotency_keys' con UNIQUE constraint. Antes de llamar a Holded, INSERT en esa tabla; si UNIQUE viola, ya se procesó. Si pasa, llama y guarda el resultado. Esto evita duplicados aún con 5 workers procesando en paralelo el mismo webhook reintentado.
¿Las operaciones bulk de v2 mantienen orden de creación para Verifactu?
Cuidado con esto. Las bulk operations son eficientes pero el orden de creación interno no está garantizado — Holded puede paralelizar la creación de los items del batch. Si tu cadena Verifactu depende del orden estricto (típicamente facturas, sales receipts), NO uses bulk. Procesa de uno en uno con concurrencia 1 sobre el worker que genera facturas Verifactu. Para datos que no son facturas (contactos, productos, asientos) bulk es seguro.
¿Qué hago con los scopes? Mi API key v1 funciona para todo.
v2 introduce scopes granulares en la API key (ej. contacts:contacts.read, contacts:contacts.write, invoices:invoices.write). La autenticación sigue siendo Bearer token sobre HTTPS, no OAuth flow — solo cambia que cada key se genera con un conjunto explícito de permisos. Si tu key v1 era 'todo o nada', en v2 tienes que pedir solo los scopes que necesitas al generarla. La buena noticia: principio de menor privilegio mejora la seguridad. La mala: si tu código asume que cualquier endpoint funciona con la misma key, ahora puedes recibir 403 Forbidden de la nada. Audita los endpoints que llamas, lista los scopes mínimos necesarios, y genera la key con esos. Documéntalo en el código.
¿Hay un periodo paralelo recomendado para validar la migración?
Sí: dos semanas mínimo. La v1 sigue produciendo en tu producción, la v2 escribe en un entorno de staging conectado a un Holded test (o a una sandbox tuya). Cada operación que entra al sistema se procesa por ambos pipelines y comparas las salidas. Las diferencias entran a una cola de revisión manual. Cuando llevas 3-5 días sin diferencias relevantes, haces el cut-over. Si algo falla post-cutover, rollback al endpoint v1 es trivial porque no lo has apagado todavía.
Lecturas relacionadas
Juan Cantón Rodríguez
Founder & lead developer de Francodesystems. Mantiene n8n-nodes-holded con cobertura 100% de Holded API v2. Cuando este post se publicó, ya había ejecutado migraciones v1→v2 en clientes con webhooks Shopify, Stripe y Amazon — los workarounds que aparecen arriba están probados en producción.