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()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.
Facturació FACe (B2G)
Envia factures FacturaE 3.2.2 a FACe — codis DIR3, XML signat XAdES-EPES, estats de tramitació, anul·lació, simulació en sandbox i l'scope facturae:write.
Obtenir els detalls del compte
Endpoint de compte estil Stripe: retorna l'empresa autenticada juntament amb el seu pla, els seus add-ons i les metadades de l'API key en ús (environment, scopes). Fes-lo servir per introspeccionar què pot fer la clau actual.