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 imports normalized contacts.
Resource mapping
| Holded | Factuarea | Notes |
|---|---|---|
contacts | contacts | One fiscal identity with cumulative customer, supplier and lead roles. Preserve both roles when a contact buys and sells. |
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_test_...orX-API-Key: fact_test_.... 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 on the canonical contact. Resolve it with POST /v1/contacts/find-by-external-id, scoped to the authenticated company. Reuse that contact’s id in document client_id or supplier_id fields according to its active role.
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/contacts?roles=customer |
| Create client | POST /invoicing/v1/contacts (with type=client) | POST /v1/contacts |
| 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.
Migrate normalized contacts (Python)
Prepare a JSON file from your Holded export with explicit kind, cumulative roles and valid fiscal identity. The script submits canonical payloads, preserves external_id, keeps one idempotency key across retries and reports conflicts for manual review. It does not guess person/company types or silently skip suppliers. Start with a fact_test_ key.
import json
import os
import requests
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
key = os.environ['FACTUAREA_API_KEY']
if not key.startswith('fact_test_'):
raise ValueError('Run the migration in test mode first')
@retry(retry=retry_if_exception_type((requests.ConnectionError, requests.Timeout)),
stop=stop_after_attempt(5), wait=wait_exponential(max=10), reraise=True)
def create_contact(contact):
response = requests.post(
'https://api.factuarea.com/v1/contacts',
headers={
'Authorization': f'Bearer {key}',
'Idempotency-Key': f"holded-contact-{contact['external_id']}",
},
json=contact,
timeout=30,
)
response.raise_for_status()
return response.json()['data']
with open('holded-contacts.json', encoding='utf-8') as source:
contacts = json.load(source)
for contact in contacts:
result = create_contact(contact)
print(contact['external_id'], result['id'])[
{
"external_id": "holded-42",
"name": "Distribuciones Ejemplo SL",
"kind": "company",
"roles": ["customer", "supplier"],
"tax_id": "B12345674",
"address": {"line_1": "Calle Mayor", "country_code": "ES"}
}
]Bulk contact import from the Holded export
Use POST /v1/contacts/import/preview before POST /v1/contacts/import. Both require contacts:write, Idempotency-Key and multipart/form-data. The importer accepts CSV, TXT, XLSX or XLS files up to 10 MB.
The mapping preset
mapping maps destination field → source column header, for example mapping[name]=Name. Omit it when the headers already use canonical field names. Unknown destinations are rejected; they are not silently discarded. Include name, a valid fiscal identity and at least one role, supplied in the file or through target_roles.
Destination fields
The canonical importer supports external_id, kind, roles, tags, address fields (address_line_1, country_code), alternative fiscal identity, billing_emails, bank_accounts, metadata, DIR3 codes and directional defaults (customer_*, supplier_*). Map only columns you intend to write. Keep both roles on a contact that buys and sells; never split its fiscal identity into duplicate records.
Step 1 — preview
curl -X POST https://api.factuarea.com/v1/contacts/import/preview \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@contacts.csv" \
-F "mapping[name]=Name" \
-F "mapping[tax_id]=VAT number" \
-F "mapping[external_id]=Id" \
-F "target_roles[]=customer" \
-F "conflict_strategy=reject"The preview classifies each row as create, update, add_role, merge_candidate, conflict or invalid. Inspect errors, warnings and target_uuid; resolve ambiguous identities before importing. The response includes total, counts by action, dry_run and queued, rather than the legacy client import envelope.
Step 2 — import
Send the reviewed file and the same mapping to /v1/contacts/import, with a new idempotency key. dry_run=true also validates without writing. The default conflict_strategy=reject protects existing data; select update or merge deliberately after reviewing the preview. Large imports may return 202 with queued=true: acceptance is not proof that every row has completed.
Step 3 — reconcile the Holded ID
Store the Holded ID in external_id on the canonical contact. Resolve it with POST /v1/contacts/find-by-external-id, scoped to the authenticated company. Reuse that contact’s id in document client_id or supplier_id fields according to its active role.
Migration checklist
- Inventory: number of contacts, products, historical invoices, active webhooks.
- Store the Holded ID in
external_idon the canonical contact. Resolve it withPOST /v1/contacts/find-by-external-id, scoped to the authenticated company. Reuse that contact’sidin documentclient_idorsupplier_idfields according to its active role. - Phased migration:
- Catalogs: taxes, series, products, then presentations/variants/supplier
offers and price lists → first. Preserve the source ID in each supported
external_id; do not invent undocumented Holded field names. - Masters: canonical contacts with their roles → second.
- Historical documents: invoices, quotes, etc. → third.
- Catalogs: taxes, series, products, then presentations/variants/supplier
offers and price lists → first. Preserve the source ID in each supported
- 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.