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()

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