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()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.
FACe invoicing (B2G)
Submit FacturaE 3.2.2 invoices to FACe — DIR3 codes, signed XAdES-EPES XML, processing states, cancellation, sandbox simulation and the facturae:write scope.
Retrieve account details
Stripe-like account endpoint: returns the authenticated company together with its plan, add-ons, and the metadata of the API key in use (environment, scopes). Use it to introspect what the current key can do.