Factuarea API

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

HoldedFactuareaNotas
contactsclients + suppliersHolded los mezcla en contacts con un campo type. Factuarea los separa en dos endpoints distintos.
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_live_... o X-API-Key: fact_live_.... 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 — 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 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/clients
Crear clientePOST /invoicing/v1/contacts (con type=client)POST /v1/clients
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.

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:

  • name y tax_id son destinos obligatorios. Un mapeo sin ambos se rechaza con 422 antes 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 Id de 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.

DestinoObligatorioSe valida como
nameno vacío
tax_idNIF/CIF/NIE español
commercial_namenotexto libre
vat_idnotexto libre
emailnodirección de correo
phonenoteléfono
mobilenoteléfono
faxnotexto libre
websitenotexto libre
addressnotexto libre
address_line2notexto libre
address_numbernotexto libre
address_floornotexto libre
address_doornotexto libre
address_staircasenotexto libre
citynotexto libre
postal_codenotexto libre
provincenotexto libre
countrynotexto libre
bank_ibannotexto libre — pasa a ser la cuenta bancaria por defecto del cliente
default_vat_ratenonumérico
default_retention_ratenonumérico
default_discountnonumérico
payment_methodnotexto libre
payment_terms_daysnonumérico
contact_personnotexto libre
notesnotexto 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:

  1. 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 200 con el cliente, o 404 client_not_found si esa fila fue una de las que falló en el paso 2.

  2. Escríbele el ID de Holded. El PUT de un cliente es una actualización parcial, así que enviar solo external_id deja 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

  1. Inventario: número de contactos, productos, facturas históricas, webhooks activos.
  2. Mapea con external_id (recomendado): escribe cada ID de Holded en el external_id del recurso de Factuarea correspondiente al crearlo. Así no necesitas una tabla intermedia holded_id ↔ factuarea_id — para resolver una relación (factura → cliente) o para reejecutar la migración con seguridad, busca el registro con POST /v1/{recurso}/find-by-external-id (body { "external_id": "<holded_id>" }). Esto es lo que hace idempotente la migración.
  3. Migración por fases:
    • Catálogos: impuestos, series, productos → primero.
    • Maestros: clientes, proveedores → 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