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()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.
Facturación FACe (B2G)
Envía facturas FacturaE 3.2.2 a FACe — códigos DIR3, XML firmado XAdES-EPES, estados de tramitación, anulación, simulación en sandbox y el scope facturae:write.
Obtener los detalles de la cuenta
Endpoint de cuenta estilo Stripe: devuelve la empresa autenticada junto con su plan, sus add-ons y los metadatos de la API key en uso (environment, scopes). Úsalo para introspeccionar qué puede hacer la clave actual.