Migración desde Holded
Mapeo de recursos Holded → Factuarea, nomenclatura, endpoints equivalentes y script en Python.
Esta guía documenta la migración desde la API de Holded (uno de los principales competidores en el sector del SaaS de facturación español) a la Public API v1 de Factuarea. Cubre el mapeo de recursos, las diferencias de nomenclatura, los endpoints equivalentes y un script de ejemplo en Python que migra una empresa completa.
Mapeo de recursos
| Holded | Factuarea | Notas |
|---|---|---|
contacts | clients + suppliers | Holded los mezcla en contacts con un campo type. Factuarea los separa en dos endpoints distintos. |
products | products | Nomenclatura idéntica. |
documents/invoice | invoices | Endpoint dedicado. |
documents/estimate | quotes | Cambio de nombre: Holded usa "estimate", Factuarea "quote". |
documents/proform | proformas | Renombrado a "proforma" sin abreviar. |
documents/waybill | delivery_notes | Nomenclatura canónica española/legal. |
documents/purchase | purchase_invoices | |
documents/recurring | recurring_invoices | |
taxes | taxes | Mismo concepto. |
numerations | series | Holded "numeration", Factuarea "series". El format de Holded se mapea a number_format, una máscara de numeración configurable (padding + token de año + separador), p. ej. {code}-{YYYY}-{000}. |
tags | tags | Etiquetas de clasificación libre en un documento (slugs en minúscula, ≤ 40 caracteres, ≤ 30 por documento). |
| custom fields | custom_fields | Metadatos de integración tipados [{field, value}] en un documento (≤ 50 entradas). |
webhooks | webhook_endpoints (+ anidado deliveries) | Factuarea separa la configuración del endpoint de la trazabilidad de entregas (GET /v1/webhook_endpoints/{id}/deliveries). |
Diferencias clave
1. Autenticación
- Holded: header
key: <api_key>. - Factuarea:
Authorization: Bearer fact_live_...oX-API-Key: fact_live_.... OpenAPI estándar.
2. Identificadores
- Holded: IDs opacos de tipo string-numérico.
- Factuarea: cada recurso tiene una key
idcuyo valor es un UUID v7 (01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b) — codifica un timestamp y es ordenable lexicográficamente. Las foreign keys usan*_id(p. ej.client_id).
Guarda el ID de Holded en external_id — es la estrategia de migración
recomendada. Cada recurso de Factuarea acepta un external_id (una clave de
integración externa, ≤ 100 caracteres, única por empresa, distinta del tax_id
fiscal). Escribe el ID original de Holded en él en cada creación. Eso hace la
migración nativamente idempotente: no necesitas mantener una tabla de mapeo
holded_id ↔ factuarea_id — para encontrar el registro de Factuarea de un ID de
Holded, llama a POST /v1/{recurso}/find-by-external-id con el body
{ "external_id": "<holded_id>" }. Disponible en clients, suppliers,
products, invoices, quotes, proformas, delivery_notes,
purchase_invoices y recurring_invoices. Consulta
external_id en el glosario.
3. Paginación
- Holded:
?starttmp=...&endtmp=...(timestamps en la URL). - Factuarea: paginación por cursor (
starting_after,ending_before) por eliddel recurso. Consulta Paginación.
4. Errores
- Holded: status code + array
errorso stringerror. - Factuarea: envoltorio
{ error: { type, code, message, request_id, doc_url } }. Consulta Errores.
5. Webhooks
- Holded: payload sin firmar (validación basada en IP).
- Factuarea: firma HMAC SHA256 obligatoria, tolerancia de ±5min, reintentos exponenciales hasta 8 intentos. Consulta Webhooks.
6. Idempotencia
- Holded: no soportada.
- Factuarea: header
Idempotency-Keycon TTL de 24h. Consulta Idempotencia.
Endpoints equivalentes (operaciones más comunes)
| Operación | Holded | Factuarea |
|---|---|---|
| Listar facturas | GET /invoicing/v1/documents/invoice | GET /v1/invoices |
| Crear factura | POST /invoicing/v1/documents/invoice | POST /v1/invoices |
| Marcar factura como pagada | POST /invoicing/v1/documents/invoice/{id}/pay | POST /v1/invoices/{id}/mark-paid |
| Enviar factura por email | POST /invoicing/v1/documents/invoice/{id}/send | POST /v1/invoices/{id}/send |
| Descargar PDF | GET /invoicing/v1/documents/invoice/{id}/pdf | GET /v1/invoices/{id}/pdf |
| Listar clientes | GET /invoicing/v1/contacts?type=client | GET /v1/clients |
| Crear cliente | POST /invoicing/v1/contacts (con type=client) | POST /v1/clients |
| Convertir presupuesto en factura | POST /invoicing/v1/documents/estimate/{id}/convert | POST /v1/quotes/{id}/convert |
| Crear webhook | POST /invoicing/v1/webhooks | POST /v1/webhook_endpoints |
Diferencias de payload
Crear factura
Holded:
POST /invoicing/v1/documents/invoice
{
"contactId": "5e1c2a3b4f5d6e7f8a9b0c1d",
"date": 1747314060,
"items": [
{ "name": "Service", "units": 1, "subtotal": 99.00, "tax": 21 }
]
}Factuarea:
POST /v1/invoices
Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b
{
"client_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
"series_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a02",
"issued_on": "2026-05-15",
"due_on": "2026-06-15",
"lines": [
{
"description": "Service",
"quantity": 1,
"unit_price": 99.00,
"tax_rate_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03"
}
]
}Cambios:
contactId→client_id(FK explícita; el valor es un UUID v7).date(timestamp) →issued_on(YYYY-MM-DD), condue_onobligatorio.items[].subtotal(importe) →lines[].unit_price(precio unitario; la API calcula los totales).items[].tax(porcentaje en línea) →lines[].tax_rate_id(FK al catálogo de impuestos).series_idobligatorio — Factuarea exige configurar la serie antes de emitir (coherencia con la AEAT).
Webhooks: firma
Holded no firma. Factuarea sí (HMAC SHA256). Después de migrar debes validar la firma en tu handler. Consulta Webhooks.
Script mínimo de migración (Python)
Este script es ilustrativo, no listo para producción. Pruébalo en staging y valida los datos migrados manualmente antes de ejecutarlo contra producción.
"""
Migrate contacts and products from Holded to Factuarea.
Requires: pip install requests tenacity python-dotenv
"""
import os, time, uuid, requests
from tenacity import retry, stop_after_attempt, wait_exponential
HOLDED_API = 'https://api.holded.com/api/invoicing/v1'
FACTUAREA_API = 'https://api.factuarea.com/v1'
HOLDED_HEADERS = {'key': os.environ['HOLDED_KEY']}
FACTUAREA_HEADERS = {
'Authorization': f"Bearer {os.environ['FACTUAREA_KEY']}",
'Content-Type': 'application/json',
}
@retry(stop=stop_after_attempt(5), wait=wait_exponential(max=10))
def fact_post(path, payload, key=None):
headers = dict(FACTUAREA_HEADERS)
headers['Idempotency-Key'] = key or str(uuid.uuid4())
r = requests.post(f"{FACTUAREA_API}{path}", json=payload, headers=headers, timeout=30)
if r.status_code >= 500:
r.raise_for_status()
return r
def fetch_holded_contacts():
url = f"{HOLDED_API}/contacts"
while url:
r = requests.get(url, headers=HOLDED_HEADERS, timeout=30)
r.raise_for_status()
payload = r.json()
for c in payload.get('contacts', payload if isinstance(payload, list) else []):
yield c
url = payload.get('next') if isinstance(payload, dict) else None
def migrate_clients():
migrated = 0
for h in fetch_holded_contacts():
if h.get('type') != 'client':
continue
payload = {
'name': h['name'],
'tax_id': h.get('code') or h.get('vatnumber'),
'email': h.get('email'),
'phone': h.get('phone'),
'address': h.get('billAddress', {}).get('address'),
'postal_code': h.get('billAddress', {}).get('postalCode'),
'city': h.get('billAddress', {}).get('city'),
'province': h.get('billAddress', {}).get('province'),
'country': h.get('billAddress', {}).get('country', 'ES'),
'external_id': h['id'], # Holded ID → external_id: the mapping key. Reconcile later via POST /v1/clients/find-by-external-id
}
idem_key = f"migrate-client-{h['id']}" # deterministic for retry-safety
r = fact_post('/clients', {k: v for k, v in payload.items() if v is not None}, key=idem_key)
if r.status_code == 201:
migrated += 1
elif r.status_code == 409:
# already exists (another migration run)
pass
else:
print(f" ERROR {r.status_code} for {h['id']}: {r.text[:200]}")
time.sleep(0.1) # courtesy with rate limits
print(f"Clients migrated: {migrated}")
if __name__ == '__main__':
migrate_clients()Importación masiva de clientes desde el export de Holded
El script de arriba crea los clientes uno a uno con POST /v1/clients. Si
prefieres meter directamente el fichero de export de contactos de Holded,
usa POST /v1/clients/import (scope clients:write, multipart/form-data)
con el preset de mapeo de abajo. Recibe un file (CSV, XLSX, XLS, ODS o TXT,
hasta 10 MB), un objeto mapping y un flag dry_run.
El preset de mapeo
En mapping, la clave es la cabecera de columna tal cual aparece en tu
fichero y el valor es el campo destino. Holded traduce las cabeceras del
export al idioma de la cuenta, así que abre la primera línea de tu fichero y
ajusta las claves — los valores de la derecha no cambian nunca:
{
"Name": "name",
"Trade name": "commercial_name",
"VAT number": "tax_id",
"EU VAT number": "vat_id",
"Email": "email",
"Phone": "phone",
"Mobile": "mobile",
"Fax": "fax",
"Website": "website",
"Address": "address",
"City": "city",
"Postal code": "postal_code",
"Province": "province",
"Country": "country",
"IBAN": "bank_iban",
"Contact person": "contact_person",
"Notes": "notes"
}Tres reglas que la API impone sobre el propio mapeo:
nameytax_idson destinos obligatorios. Un mapeo sin ambos se rechaza con422antes de leer una sola fila.- Ningún destino dos veces. Dos cabeceras apuntando al mismo campo es un error, no un «gana la última» silencioso.
- Las columnas sin mapear se ignoran. La columna
Idde Holded es una de ellas — mira el paso 3.
Campos destino
Estos son los campos que acepta el importador de clientes. Un destino fuera de
esta tabla se ignora en silencio: ni se escribe ni se reporta como error,
exactamente igual que si la columna no se hubiera mapeado. Revisa tu mapping
contra esta tabla antes de la ejecución real — una errata como "e-mail" te
cuesta la columna entera en todas las filas, y la importación sigue respondiendo
200.
| Destino | Obligatorio | Se valida como |
|---|---|---|
name | sí | no vacío |
tax_id | sí | NIF/CIF/NIE español |
commercial_name | no | texto libre |
vat_id | no | texto libre |
email | no | dirección de correo |
phone | no | teléfono |
mobile | no | teléfono |
fax | no | texto libre |
website | no | texto libre |
address | no | texto libre |
address_line2 | no | texto libre |
address_number | no | texto libre |
address_floor | no | texto libre |
address_door | no | texto libre |
address_staircase | no | texto libre |
city | no | texto libre |
postal_code | no | texto libre |
province | no | texto libre |
country | no | texto libre |
bank_iban | no | texto libre — pasa a ser la cuenta bancaria por defecto del cliente |
default_vat_rate | no | numérico |
default_retention_rate | no | numérico |
default_discount | no | numérico |
payment_method | no | texto libre |
payment_terms_days | no | numérico |
contact_person | no | texto libre |
notes | no | texto libre |
Los decimales admiten tanto . como , de separador. El tax_id se guarda en
mayúsculas, así que búscalo en mayúsculas después. Los campos que el importador
no cubre — external_id, billing_emails, alternative_id, metadata,
los códigos DIR3 y el flag de recargo de equivalencia — solo se pueden fijar con
POST /v1/clients, POST /v1/clients/bulk-create o PUT /v1/clients/{id}.
Paso 1 — ejecución en seco
Empieza siempre con dry_run: true. No se escribe nada, no se consume cuota
mensual de filas, y obtienes el veredicto fila a fila:
curl -X POST https://api.factuarea.com/v1/clients/import \
-H "Authorization: Bearer fact_live_..." \
-F "file=@holded-contactos.csv" \
-F 'mapping={"Name":"name","VAT number":"tax_id","Email":"email"}' \
-F "dry_run=true"{
"data": {
"object": "client_import_preview",
"total_rows": 128,
"rows": [
{ "row": 2, "status": "valid", "errors": [], "warnings": [] },
{
"row": 3,
"status": "error",
"errors": [
{ "param": "tax_id", "code": "INVALID_FORMAT", "message": "..." }
],
"warnings": []
}
]
}
}row es el número de línea de tu fichero — la cabecera es la línea 1, así que
la primera fila de datos es la 2. Corrige en el fichero de origen cada fila
con status: "error" y repite la ejecución en seco hasta que todas salgan
valid.
La ejecución en seco previsualiza solo las 50 primeras filas. total_rows
cuenta el fichero entero, pero rows[] se corta en 50 — una ejecución en seco
limpia sobre un fichero de 400 filas no significa que de la 51 en adelante
esté limpio. Si el export es grande, trocéalo y haz la ejecución en seco de
cada trozo.
Paso 2 — la importación real
La misma llamada con dry_run=false (o sin el flag). Se aplican dos límites:
- Menos de 200 filas por petición. Un fichero con 200 filas o más se rechaza
con
422 client_import_too_large— la importación es síncrona para poder devolver el resultado por fila en la misma respuesta. Trocea el export. - Una cuota mensual de filas por plan: 100 filas en Emprendedor, 1.000 en Empresario, sin límite en Enterprise. Cuenta las filas realmente importadas, sumando todas las importaciones del mes natural.
La importación es best-effort por fila: cada fila es su propia transacción, así que una fila que falla no revierte las que ya se crearon. La respuesta te dice exactamente cuáles reenviar:
{
"data": {
"total": 128,
"successful": 126,
"failed": 2,
"failures": [
{
"index": 41,
"error_code": "INVALID_FORMAT",
"error_message": "...",
"errors": [{ "param": "tax_id", "code": "INVALID_FORMAT", "message": "..." }]
}
],
"results": [
{ "row": 43, "status": "error", "errors": [...], "warnings": [] }
]
}
}index va desde 0 sobre las filas de datos; row es la línea del fichero
(index + 2). Ojo: successful cuenta las filas creadas más las omitidas,
porque el importador deduplica por tax_id —tanto contra los clientes que ya
existen en tu empresa como contra filas repetidas dentro del mismo fichero— y
una fila omitida es un éxito, no un fallo. Eso es lo que hace seguro repetir una
importación, pero también significa que successful no es el número de clientes
creados. En results[] solo se detallan las filas que fallaron.
Paso 3 — reconciliar con el ID de Holded
external_id no es un campo destino de la importación. El importador de
fichero escribe los campos de la tabla de arriba y ninguno más, así que la
columna Id de Holded no puede viajar por ahí. Mapear "Id": "external_id"
no se rechaza: se ignora en silencio, y la importación responde 200 como
si hubiera funcionado. Estampa el id en una segunda pasada, como se explica
abajo.
La correspondencia que necesitas —ID de Holded ↔ NIF— ya está en el fichero de
export que acabas de subir. Estampa el external_id después, con una llamada
por cliente:
-
Resuelve el cliente por el NIF de esa fila:
curl -X POST https://api.factuarea.com/v1/clients/find-by-tax-id \ -H "Authorization: Bearer fact_live_..." \ -H "Content-Type: application/json" \ -d '{ "tax_id": "B12345678" }'Devuelve
200con el cliente, o404 client_not_foundsi esa fila fue una de las que falló en el paso 2. -
Escríbele el ID de Holded. El
PUTde un cliente es una actualización parcial, así que enviar soloexternal_iddeja intacto el resto de campos:curl -X PUT https://api.factuarea.com/v1/clients/01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01 \ -H "Authorization: Bearer fact_live_..." \ -H "Content-Type: application/json" \ -d '{ "external_id": "5e1c2a3b4f5d6e7f8a9b0c1d" }'
A partir de ahí los clientes importados se comportan igual que los que crea el
script: POST /v1/clients/find-by-external-id los resuelve por su ID de Holded,
y la migración de facturas puede referenciarlos sin tabla de correspondencias.
¿Quieres el external_id de una pasada? Entonces no uses el importador de
fichero. POST /v1/clients/bulk-create admite hasta 500 payloads de cliente
por lote, tiene el mismo flag dry_run y el mismo contrato de resultado por
fila, y acepta external_id en cada payload — lee tú el export y envíalo como
JSON. Mira Operaciones masivas.
Checklist de migración
- Inventario: número de contactos, productos, facturas históricas, webhooks activos.
- Mapea con
external_id(recomendado): escribe cada ID de Holded en elexternal_iddel recurso de Factuarea correspondiente al crearlo. Así no necesitas una tabla intermediaholded_id ↔ factuarea_id— para resolver una relación (factura → cliente) o para reejecutar la migración con seguridad, busca el registro conPOST /v1/{recurso}/find-by-external-id(body{ "external_id": "<holded_id>" }). Esto es lo que hace idempotente la migración. - Migración por fases:
- Catálogos: impuestos, series, productos → primero.
- Maestros: clientes, proveedores → segundo.
- Documentos históricos: facturas, presupuestos, etc. → tercero.
- Doble escritura temporal: durante 1–2 semanas, escribe en ambas plataformas. Reconcilia las diferencias a diario.
- Webhooks: configura los nuevos endpoints, despliega el handler con verificación HMAC y ejecútalo en paralelo.
- Cut-over: deja de escribir en Holded, deshabilita los webhooks allí.
- Soporte: contacta con
support@factuarea.comindicando elrequest_idante cualquier incidencia durante la migración.
Diferencias intencionadas
Algunos comportamientos de Holded no replicamos a propósito:
- Anular vs eliminar una factura: Holded permite eliminar facturas.
Factuarea no — emitir y luego eliminar es un anti-patrón frente a la
AEAT. Usa
POST /v1/invoices/{id}/annul(anular) o emite una factura rectificativa. - Editar una factura emitida: Holded permite reemitir un PDF distinto.
Factuarea bloquea los cambios después de
sentsalvomark-paid,annul,create-corrective. Es deliberado. - Calculadora de IVA en línea: Holded acepta el porcentaje de IVA en cada línea. Factuarea requiere una FK al catálogo de impuestos para garantizar la coherencia y los informes.
Son decisiones de producto, no limitaciones técnicas. Si encuentras un caso de uso real que no podamos cubrir, contacta con producto.