Factuarea APIDevelopers

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

HoldedFactuareaNotas
contactscontactsUna identidad fiscal con roles acumulables customer, supplier y lead. Conserva ambos roles si un contacto compra y vende.
productsproductsNomenclatura idéntica.
documents/invoiceinvoicesEndpoint dedicado.
documents/estimatequotesCambio de nombre: Holded usa "estimate", Factuarea "quote".
documents/proformproformasRenombrado a "proforma" sin abreviar.
documents/waybilldelivery_notesNomenclatura canónica española/legal.
documents/purchasepurchase_invoices
documents/recurringrecurring_invoices
taxestaxesMismo concepto.
numerationsseriesHolded "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}.
tagstagsEtiquetas de clasificación libre en un documento (slugs en minúscula, ≤ 40 caracteres, ≤ 30 por documento).
custom fieldscustom_fieldsMetadatos de integración tipados [{field, value}] en un documento (≤ 50 entradas).
webhookswebhook_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_... o X-API-Key: fact_test_.... OpenAPI estándar.

2. Identificadores

  • Holded: IDs opacos de tipo string-numérico.
  • Factuarea: cada recurso tiene una key id cuyo 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 el id del recurso. Consulta Paginación.

4. Errores

  • Holded: status code + array errors o string error.
  • 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-Key con TTL de 24h. Consulta Idempotencia.

Endpoints equivalentes (operaciones más comunes)

OperaciónHoldedFactuarea
Listar facturasGET /invoicing/v1/documents/invoiceGET /v1/invoices
Crear facturaPOST /invoicing/v1/documents/invoicePOST /v1/invoices
Marcar factura como pagadaPOST /invoicing/v1/documents/invoice/{id}/payPOST /v1/invoices/{id}/mark-paid
Enviar factura por emailPOST /invoicing/v1/documents/invoice/{id}/sendPOST /v1/invoices/{id}/send
Descargar PDFGET /invoicing/v1/documents/invoice/{id}/pdfGET /v1/invoices/{id}/pdf
Listar clientesGET /invoicing/v1/contacts?type=clientGET /v1/contacts?roles=customer
Crear clientePOST /invoicing/v1/contacts (con type=client)POST /v1/contacts
Convertir presupuesto en facturaPOST /invoicing/v1/documents/estimate/{id}/convertPOST /v1/quotes/{id}/convert
Crear webhookPOST /invoicing/v1/webhooksPOST /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:

  • contactIdclient_id (FK explícita; el valor es un UUID v7).
  • date (timestamp) → issued_on (YYYY-MM-DD), con due_on obligatorio.
  • 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_id obligatorio — 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

  1. Inventario: número de contactos, productos, facturas históricas, webhooks activos.
  2. 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. 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_id compatible; 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.
  4. Doble escritura temporal: durante 1–2 semanas, escribe en ambas plataformas. Reconcilia las diferencias a diario.
  5. Webhooks: configura los nuevos endpoints, despliega el handler con verificación HMAC y ejecútalo en paralelo.
  6. Cut-over: deja de escribir en Holded, deshabilita los webhooks allí.
  7. Soporte: contacta con support@factuarea.com indicando el request_id ante 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 sent salvo mark-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.

En esta página

¿Te echamos una mano?Contactar con soporte