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

Importació massiva de clients des de l'export de Holded

L'script de dalt crea els clients d'un en un amb POST /v1/clients. Si prefereixes ficar-hi directament el fitxer d'export de contactes de Holded, fes servir POST /v1/clients/import (scope clients:write, multipart/form-data) amb el preset de mapeig de sota. Rep un file (CSV, XLSX, XLS, ODS o TXT, fins a 10 MB), un objecte mapping i un flag dry_run.

El preset de mapeig

Al mapping, la clau és la capçalera de columna tal com apareix al teu fitxer i el valor és el camp destí. Holded tradueix les capçaleres de l'export a l'idioma del compte, així que obre la primera línia del teu fitxer i ajusta les claus — els valors de la dreta no canvien mai:

{
  "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 regles que l'API imposa sobre el mapeig mateix:

  • name i tax_id són destins obligatoris. Un mapeig sense tots dos es rebutja amb 422 abans de llegir ni una sola fila.
  • Cap destí dues vegades. Dues capçaleres apuntant al mateix camp és un error, no un «guanya l'última» silenciós.
  • Les columnes sense mapejar s'ignoren. La columna Id de Holded n'és una — mira el pas 3.

Camps destí

Aquests són els camps que accepta l'importador de clients. Un destí fora d'aquesta taula s'ignora en silenci: ni s'escriu ni es reporta com a error, exactament igual que si la columna no s'hagués mapejat. Revisa el teu mapping contra aquesta taula abans de l'execució real — una errada com "e-mail" et costa la columna sencera a totes les files, i la importació continua responent 200.

DestíObligatoriEs valida com
nameno buit
tax_idNIF/CIF/NIE espanyol
commercial_namenotext lliure
vat_idnotext lliure
emailnoadreça de correu
phonenotelèfon
mobilenotelèfon
faxnotext lliure
websitenotext lliure
addressnotext lliure
address_line2notext lliure
address_numbernotext lliure
address_floornotext lliure
address_doornotext lliure
address_staircasenotext lliure
citynotext lliure
postal_codenotext lliure
provincenotext lliure
countrynotext lliure
bank_ibannotext lliure — passa a ser el compte bancari per defecte del client
default_vat_ratenonumèric
default_retention_ratenonumèric
default_discountnonumèric
payment_methodnotext lliure
payment_terms_daysnonumèric
contact_personnotext lliure
notesnotext lliure

Els decimals admeten tant . com , de separador. El tax_id es desa en majúscules, així que busca'l en majúscules després. Els camps que l'importador no cobreix — external_id, billing_emails, alternative_id, metadata, els codis DIR3 i el flag de recàrrec d'equivalència — només es poden fixar amb POST /v1/clients, POST /v1/clients/bulk-create o PUT /v1/clients/{id}.

Pas 1 — execució en sec

Comença sempre amb dry_run: true. No s'escriu res, no es consumeix quota mensual de files, i obtens el veredicte fila a fila:

curl -X POST https://api.factuarea.com/v1/clients/import \
  -H "Authorization: Bearer fact_live_..." \
  -F "file=@holded-contactes.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 és el número de línia del teu fitxer — la capçalera és la línia 1, així que la primera fila de dades és la 2. Corregeix al fitxer d'origen cada fila amb status: "error" i repeteix l'execució en sec fins que totes surtin valid.

L'execució en sec previsualitza només les 50 primeres files. total_rows compta el fitxer sencer, però rows[] es talla a 50 — una execució en sec neta sobre un fitxer de 400 files no vol dir que de la 51 endavant estigui neta. Si l'export és gran, parteix-lo i fes l'execució en sec de cada tros.

Pas 2 — la importació real

La mateixa crida amb dry_run=false (o sense el flag). S'apliquen dos límits:

  • Menys de 200 files per petició. Un fitxer amb 200 files o més es rebutja amb 422 client_import_too_large — la importació és síncrona per poder retornar el resultat per fila a la mateixa resposta. Parteix l'export.
  • Una quota mensual de files per pla: 100 files a Emprendedor, 1.000 a Empresario, sense límit a Enterprise. Compta les files realment importades, sumant totes les importacions del mes natural.

La importació és best-effort per fila: cada fila és la seva pròpia transacció, així que una fila que falla no reverteix les que ja s'han creat. La resposta et diu exactament quines 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 des de 0 sobre les files de dades; row és la línia del fitxer (index + 2). Compte: successful compta les files creades més les omeses, perquè l'importador deduplica per tax_id —tant contra els clients que ja existeixen a la teva empresa com contra files repetides dins del mateix fitxer— i una fila omesa és un èxit, no una fallada. Això és el que fa segur repetir una importació, però també vol dir que successful no és el nombre de clients creats. A results[] només es detallen les files que van fallar.

Pas 3 — reconciliar amb l'ID de Holded

external_id no és un camp destí de la importació. L'importador de fitxer escriu els camps de la taula de dalt i cap més, així que la columna Id de Holded no hi pot viatjar. Mapejar "Id": "external_id" no es rebutja: s'ignora en silenci, i la importació respon 200 com si hagués funcionat. Estampa l'id en una segona passada, com s'explica a sota.

La correspondència que necessites —ID de Holded ↔ NIF— ja és al fitxer d'export que acabes de pujar. Estampa l'external_id després, amb una crida per client:

  1. Resol el client pel NIF d'aquella 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" }'

    Retorna 200 amb el client, o 404 client_not_found si aquella fila va ser una de les que van fallar al pas 2.

  2. Escriu-hi l'ID de Holded. El PUT d'un client és una actualització parcial, així que enviar només external_id deixa intacta la resta de camps:

    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 d'aquí els clients importats es comporten igual que els que crea l'script: POST /v1/clients/find-by-external-id els resol pel seu ID de Holded, i la migració de factures els pot referenciar sense taula de correspondències.

Vols l'external_id d'una passada? Doncs no facis servir l'importador de fitxer. POST /v1/clients/bulk-create admet fins a 500 payloads de client per lot, té el mateix flag dry_run i el mateix contracte de resultat per fila, i accepta external_id a cada payload — llegeix tu l'export i envia'l com a JSON. Mira Operacions massives.

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