Migrate from Holded
Holded → Factuarea resource mapping, naming, equivalent endpoints and Python script.
This guide documents migration from the Holded API (one of the main competitors in the Spanish invoicing SaaS space) to the Factuarea Public API v1. It covers resource mapping, naming differences, equivalent endpoints and a Python sample script that migrates a whole company.
Resource mapping
| Holded | Factuarea | Notes |
|---|---|---|
contacts | clients + suppliers | Holded mixes them in contacts with a type field. Factuarea splits them into two distinct endpoints. |
products | products | Identical naming. |
documents/invoice | invoices | Dedicated endpoint. |
documents/estimate | quotes | Name change: Holded uses "estimate", Factuarea "quote". |
documents/proform | proformas | Renamed to "proforma" without abbreviating. |
documents/waybill | delivery_notes | Canonical Spanish/legal naming. |
documents/purchase | purchase_invoices | |
documents/recurring | recurring_invoices | |
taxes | taxes | Same concept. |
numerations | series | Holded "numeration", Factuarea "series". The Holded format maps to number_format, a configurable numbering mask (padding + year token + separator), e.g. {code}-{YYYY}-{000}. |
tags | tags | Free classification tags on a document (lowercase slugs, ≤ 40 chars, ≤ 30 per document). |
| custom fields | custom_fields | Typed [{field, value}] integration metadata on a document (≤ 50 entries). |
webhooks | webhook_endpoints (+ nested deliveries) | Factuarea separates endpoint configuration from delivery traceability (GET /v1/webhook_endpoints/{id}/deliveries). |
Key differences
1. Authentication
- Holded:
key: <api_key>header. - Factuarea:
Authorization: Bearer fact_live_...orX-API-Key: fact_live_.... Standard OpenAPI.
2. Identifiers
- Holded: opaque string-numeric IDs.
- Factuarea: every resource has an
idkey whose value is a UUID v7 (01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b) — it encodes a timestamp and is lexicographically sortable. Foreign keys use*_id(e.g.client_id).
Store the Holded ID in external_id — this is the recommended migration
strategy. Every Factuarea resource accepts an external_id (an external
integration key, ≤ 100 chars, unique per company, distinct from the fiscal
tax_id). Write the original Holded ID into it on every create. That makes
the migration natively idempotent: you don't need to keep a
holded_id ↔ factuarea_id mapping table — to find the Factuarea record for a
Holded ID, call POST /v1/{resource}/find-by-external-id with body
{ "external_id": "<holded_id>" }. Available on clients, suppliers,
products, invoices, quotes, proformas, delivery_notes,
purchase_invoices and recurring_invoices. See
external_id in the glossary.
3. Pagination
- Holded:
?starttmp=...&endtmp=...(timestamps in the URL). - Factuarea: cursor pagination (
starting_after,ending_before) by resourceid. See Pagination.
4. Errors
- Holded: status code +
errorsarray orerrorstring. - Factuarea:
{ error: { type, code, message, request_id, doc_url } }envelope. See Errors.
5. Webhooks
- Holded: unsigned payload (IP-based validation).
- Factuarea: HMAC SHA256 signature required, ±5min tolerance, exponential retries up to 8 attempts. See Webhooks.
6. Idempotency
- Holded: not supported.
- Factuarea:
Idempotency-Keyheader with 24h TTL. See Idempotency.
Equivalent endpoints (most common operations)
| Operation | Holded | Factuarea |
|---|---|---|
| List invoices | GET /invoicing/v1/documents/invoice | GET /v1/invoices |
| Create invoice | POST /invoicing/v1/documents/invoice | POST /v1/invoices |
| Mark invoice paid | POST /invoicing/v1/documents/invoice/{id}/pay | POST /v1/invoices/{id}/mark-paid |
| Send invoice by email | POST /invoicing/v1/documents/invoice/{id}/send | POST /v1/invoices/{id}/send |
| Download PDF | GET /invoicing/v1/documents/invoice/{id}/pdf | GET /v1/invoices/{id}/pdf |
| List clients | GET /invoicing/v1/contacts?type=client | GET /v1/clients |
| Create client | POST /invoicing/v1/contacts (with type=client) | POST /v1/clients |
| Convert quote to invoice | POST /invoicing/v1/documents/estimate/{id}/convert | POST /v1/quotes/{id}/convert |
| Create webhook | POST /invoicing/v1/webhooks | POST /v1/webhook_endpoints |
Payload differences
Create invoice
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"
}
]
}Changes:
contactId→client_id(explicit FK; value is a UUID v7).date(timestamp) →issued_on(YYYY-MM-DD), withdue_onrequired.items[].subtotal(amount) →lines[].unit_price(unit price; the API calculates totals).items[].tax(inline percentage) →lines[].tax_rate_id(FK to the tax catalog).series_idrequired — Factuarea enforces configuring series before issuing (consistency with the Spanish tax agency).
Webhooks: signature
Holded doesn't sign. Factuarea does (HMAC SHA256). After migrating you must validate the signature in your handler. See Webhooks.
Minimal migration script (Python)
This script is illustrative, not production-ready. Test it in staging and validate the migrated data manually before running it against production.
"""
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()Bulk client import from the Holded export
The script above creates clients one at a time through POST /v1/clients.
If you'd rather feed Holded's contact export file straight in, use
POST /v1/clients/import (scope clients:write, multipart/form-data) with
the mapping preset below. It takes a file (CSV, XLSX, XLS, ODS or TXT, up to
10 MB), a mapping object and a dry_run flag.
The mapping preset
In mapping, the key is the column header exactly as it appears in your
file and the value is the destination field. Holded localises the export
headers to the account language, so open the first line of your file and adjust
the keys — the values on the right never change:
{
"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"
}Three rules the API enforces on the mapping itself:
nameandtax_idare mandatory destinations. A mapping without both is rejected with422before a single row is read.- No destination twice. Two headers pointing at the same field is an error, not a silent last-one-wins.
- Unmapped columns are ignored. Holded's
Idcolumn is one of them — see step 3.
Destination fields
These are the fields the client importer accepts. A destination outside this
table is ignored in silence: it is neither written nor reported as an error,
exactly as if the column had not been mapped at all. Check your mapping
against this table before the real run — a typo like "e-mail" costs you the
whole column on every row, and the import still answers 200.
| Destination | Required | Validated as |
|---|---|---|
name | yes | non-empty |
tax_id | yes | Spanish NIF/CIF/NIE |
commercial_name | no | free text |
vat_id | no | free text |
email | no | email address |
phone | no | phone number |
mobile | no | phone number |
fax | no | free text |
website | no | free text |
address | no | free text |
address_line2 | no | free text |
address_number | no | free text |
address_floor | no | free text |
address_door | no | free text |
address_staircase | no | free text |
city | no | free text |
postal_code | no | free text |
province | no | free text |
country | no | free text |
bank_iban | no | free text — becomes the client's default bank account |
default_vat_rate | no | numeric |
default_retention_rate | no | numeric |
default_discount | no | numeric |
payment_method | no | free text |
payment_terms_days | no | numeric |
contact_person | no | free text |
notes | no | free text |
Decimals accept both . and , as separator. tax_id is stored upper-cased,
so search for it in upper case later. Fields the importer does not cover —
external_id, billing_emails, alternative_id, metadata, the DIR3 codes
and the equivalent-surcharge flag — can only be set through
POST /v1/clients, POST /v1/clients/bulk-create or
PUT /v1/clients/{id}.
Step 1 — dry run
Always start with dry_run: true. Nothing is written, no monthly row quota is
consumed, and you get the per-row verdict:
curl -X POST https://api.factuarea.com/v1/clients/import \
-H "Authorization: Bearer fact_live_..." \
-F "file=@holded-contacts.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 is the line number in your file — the header is line 1, so the first data
row is 2. Fix every status: "error" row in the source file and re-run the
dry run until they're all valid.
The dry run previews the first 50 rows only. total_rows counts the whole
file, but rows[] stops at 50 — a clean dry run on a 400-row file does not
mean rows 51 onwards are clean. For a large export, split it and dry-run each
chunk.
Step 2 — the real import
Same call with dry_run=false (or the flag omitted). Two limits apply:
- Under 200 rows per request. A file with 200 rows or more is rejected with
422 client_import_too_large— the import runs synchronously so it can return the per-row result in the same response. Split the export into chunks. - A monthly row quota per plan: 100 rows on Emprendedor, 1,000 on Empresario, unlimited on Enterprise. It counts rows actually imported, across every import of the calendar month.
The import is best-effort per row: each row is its own transaction, so a failing row does not roll back the rows already created. The response tells you exactly which ones to resend:
{
"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 is 0-based over the data rows; row is the line in the file
(index + 2). Note that successful counts created plus skipped rows: the
importer deduplicates by tax_id, both against clients that already exist in
your company and against repeated rows inside the same file, and a skipped row
is a success, not a failure. That's what makes re-running an import safe — but
it also means successful is not the number of clients created. Only the failed
rows are itemised in results[].
Step 3 — reconcile with the Holded ID
external_id is not a destination field of the import. The CSV importer
writes the fields listed above and nothing else, so the Holded Id column
cannot travel through it. Mapping "Id": "external_id" is not rejected —
it is ignored in silence, and the import answers 200 as if it had worked.
Stamp the id in a second pass, as below.
The pairing you need — Holded ID ↔ Spanish tax ID — is already in the export
file you just uploaded. Stamp external_id afterwards, one call per client:
-
Resolve the client by the tax ID from that row:
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" }'It returns
200with the client, or404 client_not_foundif that row was one of the failures from step 2. -
Write the Holded ID into it.
PUTon a client is a partial update, so sending onlyexternal_idleaves every other field untouched: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" }'
From then on the imported clients behave like the ones the script creates:
POST /v1/clients/find-by-external-id resolves them by their Holded ID, and the
invoice migration can reference them without a mapping table.
Want external_id in a single pass? Then don't use the file importer.
POST /v1/clients/bulk-create takes up to 500 client payloads per batch, has
the same dry_run flag and the same per-row result contract, and accepts
external_id on every payload — read the export yourself and post it as JSON.
See Bulk operations.
Migration checklist
- Inventory: number of contacts, products, historical invoices, active webhooks.
- Map via
external_id(recommended): write each Holded ID into theexternal_idof the corresponding Factuarea resource on create. You then don't need an intermediateholded_id ↔ factuarea_idtable — to resolve a relationship (invoice → client) or to re-run the migration safely, look the record up withPOST /v1/{resource}/find-by-external-id(body{ "external_id": "<holded_id>" }). This is what makes the migration idempotent. - Phased migration:
- Catalogs: taxes, series, products → first.
- Masters: clients, suppliers → second.
- Historical documents: invoices, quotes, etc. → third.
- Temporary dual-write: for 1–2 weeks, write to both platforms. Reconcile differences daily.
- Webhooks: configure the new endpoints, deploy the handler with HMAC verification and run in parallel.
- Cut-over: stop writing to Holded, disable webhooks there.
- Support: contact
support@factuarea.comwith therequest_idfor any issue during the migration.
Intentional differences
Some Holded behaviors we don't replicate on purpose:
- Void vs delete an invoice: Holded lets you delete invoices.
Factuarea doesn't — issuing then deleting is an anti-pattern against
the Spanish tax agency. Use
POST /v1/invoices/{id}/annul(void) or issue a corrective invoice. - Edit an issued invoice: Holded lets you reissue a different PDF.
Factuarea blocks changes after
sentexceptmark-paid,annul,create-corrective. This is deliberate. - Inline VAT calculator: Holded accepts the VAT percentage on each line. Factuarea requires an FK to the tax catalog to guarantee consistency and reporting.
These are product decisions, not technical limitations. If you find a real use case we can't cover, contact product.