Factuarea API

Migració des de Holded

Mapeig de recursos Holded → Factuarea, nomenclatura, endpoints equivalents i script en Python.

Aquesta guia documenta la migració des de l'API de Holded (un dels principals competidors en el sector del SaaS de facturació espanyol) a la Public API v1 de Factuarea. Cobreix el mapeig de recursos, les diferències de nomenclatura, els endpoints equivalents i un script d'exemple en Python que migra una empresa sencera.

Mapeig de recursos

HoldedFactuareaNotes
contactsclients + suppliersHolded els barreja a contacts amb un camp type. Factuarea els separa en dos endpoints diferents.
productsproductsNomenclatura idèntica.
documents/invoiceinvoicesEndpoint dedicat.
documents/estimatequotesCanvi de nom: Holded fa servir "estimate", Factuarea "quote".
documents/proformproformasReanomenat a "proforma" sense abreujar.
documents/waybilldelivery_notesNomenclatura canònica espanyola/legal.
documents/purchasepurchase_invoices
documents/recurringrecurring_invoices
taxestaxesMateix concepte.
numerationsseriesHolded "numeration", Factuarea "series". El format de Holded es mapeja a number_format, una màscara de numeració configurable (padding + token d'any + separador), p. ex. {code}-{YYYY}-{000}.
tagstagsEtiquetes de classificació lliure en un document (slugs en minúscula, ≤ 40 caràcters, ≤ 30 per document).
custom fieldscustom_fieldsMetadades d'integració tipades [{field, value}] en un document (≤ 50 entrades).
webhookswebhook_endpoints (+ anidat deliveries)Factuarea separa la configuració de l'endpoint de la traçabilitat de lliuraments (GET /v1/webhook_endpoints/{id}/deliveries).

Diferències clau

1. Autenticació

  • Holded: header key: <api_key>.
  • Factuarea: Authorization: Bearer fact_live_... o X-API-Key: fact_live_.... OpenAPI estàndard.

2. Identificadors

  • Holded: IDs opacs de tipus string-numèric.
  • Factuarea: cada recurs té una key id el valor de la qual és un UUID v7 (01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b) — codifica un timestamp i és ordenable lexicogràficament. Les foreign keys fan servir *_id (p. ex. client_id).

Desa l'ID de Holded a external_id — és l'estratègia de migració recomanada. Cada recurs de Factuarea accepta un external_id (una clau d'integració externa, ≤ 100 caràcters, única per empresa, diferent del tax_id fiscal). Escriu l'ID original de Holded a dins en cada creació. Això fa la migració nativament idempotent: no cal mantenir una taula de mapeig holded_id ↔ factuarea_id — per trobar el registre de Factuarea d'un ID de Holded, crida POST /v1/{recurs}/find-by-external-id amb el body { "external_id": "<holded_id>" }. Disponible a clients, suppliers, products, invoices, quotes, proformas, delivery_notes, purchase_invoices i recurring_invoices. Consulta external_id al glossari.

3. Paginació

  • Holded: ?starttmp=...&endtmp=... (timestamps a la URL).
  • Factuarea: paginació per cursor (starting_after, ending_before) pel id del recurs. Consulta Paginació.

4. Errors

  • Holded: status code + array errors o string error.
  • Factuarea: embolcall { error: { type, code, message, request_id, doc_url } }. Consulta Errors.

5. Webhooks

  • Holded: payload sense signar (validació basada en IP).
  • Factuarea: signatura HMAC SHA256 obligatòria, tolerància de ±5min, reintents exponencials fins a 8 intents. Consulta Webhooks.

6. Idempotència

  • Holded: no suportada.
  • Factuarea: header Idempotency-Key amb TTL de 24h. Consulta Idempotència.

Endpoints equivalents (operacions més comunes)

OperacióHoldedFactuarea
Llistar facturesGET /invoicing/v1/documents/invoiceGET /v1/invoices
Crear facturaPOST /invoicing/v1/documents/invoicePOST /v1/invoices
Marcar factura com a pagadaPOST /invoicing/v1/documents/invoice/{id}/payPOST /v1/invoices/{id}/mark-paid
Enviar factura per emailPOST /invoicing/v1/documents/invoice/{id}/sendPOST /v1/invoices/{id}/send
Descarregar PDFGET /invoicing/v1/documents/invoice/{id}/pdfGET /v1/invoices/{id}/pdf
Llistar clientsGET /invoicing/v1/contacts?type=clientGET /v1/clients
Crear clientPOST /invoicing/v1/contacts (amb type=client)POST /v1/clients
Convertir pressupost en facturaPOST /invoicing/v1/documents/estimate/{id}/convertPOST /v1/quotes/{id}/convert
Crear webhookPOST /invoicing/v1/webhooksPOST /v1/webhook_endpoints

Diferències 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"
    }
  ]
}

Canvis:

  • contactIdclient_id (FK explícita; el valor és un UUID v7).
  • date (timestamp) → issued_on (YYYY-MM-DD), amb due_on obligatori.
  • items[].subtotal (import) → lines[].unit_price (preu unitari; l'API calcula els totals).
  • items[].tax (percentatge en línia) → lines[].tax_rate_id (FK al catàleg d'impostos).
  • series_id obligatori — Factuarea exigeix configurar la sèrie abans d'emetre (coherència amb l'AEAT).

Webhooks: signatura

Holded no signa. Factuarea sí (HMAC SHA256). Després de migrar has de validar la signatura al teu handler. Consulta Webhooks.

Script mínim de migració (Python)

Aquest script és il·lustratiu, no llest per a producció. Prova'l en staging i valida les dades migrades manualment abans d'executar-lo contra producció.

"""
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ó

  1. Inventari: nombre de contactes, productes, factures històriques, webhooks actius.
  2. Mapeja amb external_id (recomanat): escriu cada ID de Holded a l'external_id del recurs de Factuarea corresponent en crear-lo. Així no et cal una taula intermèdia holded_id ↔ factuarea_id — per resoldre una relació (factura → client) o per reexecutar la migració amb seguretat, cerca el registre amb POST /v1/{recurs}/find-by-external-id (body { "external_id": "<holded_id>" }). Això és el que fa idempotent la migració.
  3. Migració per fases:
    • Catàlegs: impostos, sèries, productes → primer.
    • Mestres: clients, proveïdors → segon.
    • Documents històrics: factures, pressupostos, etc. → tercer.
  4. Doble escriptura temporal: durant 1–2 setmanes, escriu a totes dues plataformes. Reconcilia les diferències diàriament.
  5. Webhooks: configura els nous endpoints, desplega el handler amb verificació HMAC i executa'l en paral·lel.
  6. Cut-over: deixa d'escriure a Holded, deshabilita els webhooks allà.
  7. Suport: contacta amb support@factuarea.com indicant el request_id davant de qualsevol incidència durant la migració.

Diferències intencionades

Alguns comportaments de Holded no repliquem a propòsit:

  • Anul·lar vs eliminar una factura: Holded permet eliminar factures. Factuarea no — emetre i després eliminar és un anti-patró davant de l'AEAT. Fes servir POST /v1/invoices/{id}/annul (anul·lar) o emet una factura rectificativa.
  • Editar una factura emesa: Holded permet reemetre un PDF diferent. Factuarea bloqueja els canvis després de sent excepte mark-paid, annul, create-corrective. És deliberat.
  • Calculadora d'IVA en línia: Holded accepta el percentatge d'IVA a cada línia. Factuarea requereix una FK al catàleg d'impostos per garantir la coherència i els informes.

Són decisions de producte, no limitacions tècniques. Si trobes un cas d'ús real que no puguem cobrir, contacta amb producte.

En aquesta pàgina