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
| Holded | Factuarea | Notes |
|---|---|---|
contacts | clients + suppliers | Holded els barreja a contacts amb un camp type. Factuarea els separa en dos endpoints diferents. |
products | products | Nomenclatura idèntica. |
documents/invoice | invoices | Endpoint dedicat. |
documents/estimate | quotes | Canvi de nom: Holded fa servir "estimate", Factuarea "quote". |
documents/proform | proformas | Reanomenat a "proforma" sense abreujar. |
documents/waybill | delivery_notes | Nomenclatura canònica espanyola/legal. |
documents/purchase | purchase_invoices | |
documents/recurring | recurring_invoices | |
taxes | taxes | Mateix concepte. |
numerations | series | Holded "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}. |
tags | tags | Etiquetes de classificació lliure en un document (slugs en minúscula, ≤ 40 caràcters, ≤ 30 per document). |
| custom fields | custom_fields | Metadades d'integració tipades [{field, value}] en un document (≤ 50 entrades). |
webhooks | webhook_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_...oX-API-Key: fact_live_.... OpenAPI estàndard.
2. Identificadors
- Holded: IDs opacs de tipus string-numèric.
- Factuarea: cada recurs té una key
idel 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) peliddel recurs. Consulta Paginació.
4. Errors
- Holded: status code + array
errorso stringerror. - 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-Keyamb TTL de 24h. Consulta Idempotència.
Endpoints equivalents (operacions més comunes)
| Operació | Holded | Factuarea |
|---|---|---|
| Llistar factures | GET /invoicing/v1/documents/invoice | GET /v1/invoices |
| Crear factura | POST /invoicing/v1/documents/invoice | POST /v1/invoices |
| Marcar factura com a pagada | POST /invoicing/v1/documents/invoice/{id}/pay | POST /v1/invoices/{id}/mark-paid |
| Enviar factura per email | POST /invoicing/v1/documents/invoice/{id}/send | POST /v1/invoices/{id}/send |
| Descarregar PDF | GET /invoicing/v1/documents/invoice/{id}/pdf | GET /v1/invoices/{id}/pdf |
| Llistar clients | GET /invoicing/v1/contacts?type=client | GET /v1/clients |
| Crear client | POST /invoicing/v1/contacts (amb type=client) | POST /v1/clients |
| Convertir pressupost en factura | POST /invoicing/v1/documents/estimate/{id}/convert | POST /v1/quotes/{id}/convert |
| Crear webhook | POST /invoicing/v1/webhooks | POST /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:
contactId→client_id(FK explícita; el valor és un UUID v7).date(timestamp) →issued_on(YYYY-MM-DD), ambdue_onobligatori.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_idobligatori — 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:
nameitax_idsón destins obligatoris. Un mapeig sense tots dos es rebutja amb422abans 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
Idde 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í | Obligatori | Es valida com |
|---|---|---|
name | sí | no buit |
tax_id | sí | NIF/CIF/NIE espanyol |
commercial_name | no | text lliure |
vat_id | no | text lliure |
email | no | adreça de correu |
phone | no | telèfon |
mobile | no | telèfon |
fax | no | text lliure |
website | no | text lliure |
address | no | text lliure |
address_line2 | no | text lliure |
address_number | no | text lliure |
address_floor | no | text lliure |
address_door | no | text lliure |
address_staircase | no | text lliure |
city | no | text lliure |
postal_code | no | text lliure |
province | no | text lliure |
country | no | text lliure |
bank_iban | no | text lliure — passa a ser el compte bancari per defecte del client |
default_vat_rate | no | numèric |
default_retention_rate | no | numèric |
default_discount | no | numèric |
payment_method | no | text lliure |
payment_terms_days | no | numèric |
contact_person | no | text lliure |
notes | no | text 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:
-
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
200amb el client, o404 client_not_foundsi aquella fila va ser una de les que van fallar al pas 2. -
Escriu-hi l'ID de Holded. El
PUTd'un client és una actualització parcial, així que enviar nomésexternal_iddeixa 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ó
- Inventari: nombre de contactes, productes, factures històriques, webhooks actius.
- Mapeja amb
external_id(recomanat): escriu cada ID de Holded a l'external_iddel recurs de Factuarea corresponent en crear-lo. Així no et cal una taula intermèdiaholded_id ↔ factuarea_id— per resoldre una relació (factura → client) o per reexecutar la migració amb seguretat, cerca el registre ambPOST /v1/{recurs}/find-by-external-id(body{ "external_id": "<holded_id>" }). Això és el que fa idempotent la migració. - Migració per fases:
- Catàlegs: impostos, sèries, productes → primer.
- Mestres: clients, proveïdors → segon.
- Documents històrics: factures, pressupostos, etc. → tercer.
- Doble escriptura temporal: durant 1–2 setmanes, escriu a totes dues plataformes. Reconcilia les diferències diàriament.
- Webhooks: configura els nous endpoints, desplega el handler amb verificació HMAC i executa'l en paral·lel.
- Cut-over: deixa d'escriure a Holded, deshabilita els webhooks allà.
- Suport: contacta amb
support@factuarea.comindicant elrequest_iddavant 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
sentexceptemark-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.