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 importa contactos normalizados.
Mapeo de recursos
| Holded | Factuarea | Notas |
|---|---|---|
contacts | contacts | Una identidad fiscal con roles acumulables customer, supplier y lead. Conserva ambos roles si un contacto compra y vende. |
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_test_...oX-API-Key: fact_test_.... 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 del contacto canónico. Resuélvelo con POST /v1/contacts/find-by-external-id, limitado a la empresa autenticada. Reutiliza el id del contacto en client_id o supplier_id de los documentos según su rol activo.
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/contacts?roles=customer |
| Crear cliente | POST /invoicing/v1/contacts (con type=client) | POST /v1/contacts |
| 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.
Migrar contactos normalizados (Python)
Prepara un archivo JSON desde el export de Holded con kind explícito, roles acumulables e identidad fiscal válida. El script envía payloads canónicos, conserva external_id, mantiene una idempotency key entre reintentos y muestra los conflictos para revisión manual. No adivina si es persona o empresa ni omite proveedores en silencio. Empieza con una clave fact_test_.
import json
import os
import requests
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
key = os.environ['FACTUAREA_API_KEY']
if not key.startswith('fact_test_'):
raise ValueError('Run the migration in test mode first')
@retry(retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
stop=stop_after_attempt(5), wait=wait_exponential(max=10), reraise=True)
def create_contact(contact):
response = requests.post(
'https://api.factuarea.com/v1/contacts',
headers={
'Authorization': f'Bearer {key}',
'Idempotency-Key': f"holded-contact-{contact['external_id']}",
},
json=contact,
timeout=30,
)
response.raise_for_status()
return response.json()['data']
with open('holded-contacts.json', encoding='utf-8') as source:
contacts = json.load(source)
for contact in contacts:
result = create_contact(contact)
print(contact['external_id'], result['id'])[
{
"external_id": "holded-42",
"name": "Distribuciones Ejemplo SL",
"kind": "company",
"roles": ["customer", "supplier"],
"tax_id": "B12345674",
"address": {"line_1": "Calle Mayor", "country_code": "ES"}
}
]Importación masiva de contactos desde el export de Holded
Usa POST /v1/contacts/import/preview antes de POST /v1/contacts/import. Ambos requieren contacts:write, Idempotency-Key y multipart/form-data. El importador admite archivos CSV, TXT, XLSX o XLS de hasta 10 MB.
El preset de mapeo
mapping asocia campo destino → cabecera de la columna de origen, por ejemplo mapping[name]=Name. Omítelo si las cabeceras ya usan los nombres canónicos. Los destinos desconocidos se rechazan; no se descartan en silencio. Incluye name, una identidad fiscal válida y al menos un rol, indicado en el archivo o mediante target_roles.
Campos destino
El importador canónico admite external_id, kind, roles, tags, dirección (address_line_1, country_code), identidad fiscal alternativa, billing_emails, bank_accounts, metadata, códigos DIR3 y valores por rol (customer_*, supplier_*). Mapea solo las columnas que quieras escribir. Conserva ambos roles si un contacto compra y vende; nunca dividas su identidad fiscal en registros duplicados.
Paso 1 — previsualizar
curl -X POST https://api.factuarea.com/v1/contacts/import/preview \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@contacts.csv" \
-F "mapping[name]=Name" \
-F "mapping[tax_id]=VAT number" \
-F "mapping[external_id]=Id" \
-F "target_roles[]=customer" \
-F "conflict_strategy=reject"La previsualización clasifica cada fila como create, update, add_role, merge_candidate, conflict o invalid. Revisa errors, warnings y target_uuid; resuelve las identidades ambiguas antes de importar. La respuesta incluye total, recuentos por acción, dry_run y queued, en lugar del envoltorio del importador antiguo de clientes.
Paso 2 — importar
Envía el archivo revisado y el mismo mapeo a /v1/contacts/import, con una idempotency key nueva. dry_run=true también valida sin escribir. El valor predeterminado conflict_strategy=reject protege los datos existentes; elige update o merge deliberadamente tras revisar la previsualización. Las importaciones grandes pueden devolver 202 con queued=true: la aceptación no demuestra que todas las filas hayan terminado.
Paso 3 — conciliar el ID de Holded
Guarda el ID de Holded en external_id del contacto canónico. Resuélvelo con POST /v1/contacts/find-by-external-id, limitado a la empresa autenticada. Reutiliza el id del contacto en client_id o supplier_id de los documentos según su rol activo.
Checklist de migración
- Inventario: número de contactos, productos, facturas históricas, webhooks activos.
- Guarda el ID de Holded en
external_iddel contacto canónico. Resuélvelo conPOST /v1/contacts/find-by-external-id, limitado a la empresa autenticada. Reutiliza eliddel contacto enclient_idosupplier_idde los documentos según su rol activo. - Migración por fases:
- Catálogos: impuestos, series, productos y después
presentaciones/variantes/ofertas de proveedor y tarifas → primero. Conserva
el ID origen en cada
external_idcompatible; no inventes campos de Holded que no estén documentados. - Maestros: contactos canónicos con sus roles → segundo.
- Documentos históricos: facturas, presupuestos, etc. → tercero.
- Catálogos: impuestos, series, productos y después
presentaciones/variantes/ofertas de proveedor y tarifas → primero. Conserva
el ID origen en cada
- 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.