Factuarea API

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

HoldedFactuareaNotes
contactsclients + suppliersHolded mixes them in contacts with a type field. Factuarea splits them into two distinct endpoints.
productsproductsIdentical naming.
documents/invoiceinvoicesDedicated endpoint.
documents/estimatequotesName change: Holded uses "estimate", Factuarea "quote".
documents/proformproformasRenamed to "proforma" without abbreviating.
documents/waybilldelivery_notesCanonical Spanish/legal naming.
documents/purchasepurchase_invoices
documents/recurringrecurring_invoices
taxestaxesSame concept.
numerationsseriesHolded "numeration", Factuarea "series". The Holded format maps to number_format, a configurable numbering mask (padding + year token + separator), e.g. {code}-{YYYY}-{000}.
tagstagsFree classification tags on a document (lowercase slugs, ≤ 40 chars, ≤ 30 per document).
custom fieldscustom_fieldsTyped [{field, value}] integration metadata on a document (≤ 50 entries).
webhookswebhook_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_... or X-API-Key: fact_live_.... Standard OpenAPI.

2. Identifiers

  • Holded: opaque string-numeric IDs.
  • Factuarea: every resource has an id key 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 resource id. See Pagination.

4. Errors

  • Holded: status code + errors array or error string.
  • 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-Key header with 24h TTL. See Idempotency.

Equivalent endpoints (most common operations)

OperationHoldedFactuarea
List invoicesGET /invoicing/v1/documents/invoiceGET /v1/invoices
Create invoicePOST /invoicing/v1/documents/invoicePOST /v1/invoices
Mark invoice paidPOST /invoicing/v1/documents/invoice/{id}/payPOST /v1/invoices/{id}/mark-paid
Send invoice by emailPOST /invoicing/v1/documents/invoice/{id}/sendPOST /v1/invoices/{id}/send
Download PDFGET /invoicing/v1/documents/invoice/{id}/pdfGET /v1/invoices/{id}/pdf
List clientsGET /invoicing/v1/contacts?type=clientGET /v1/clients
Create clientPOST /invoicing/v1/contacts (with type=client)POST /v1/clients
Convert quote to invoicePOST /invoicing/v1/documents/estimate/{id}/convertPOST /v1/quotes/{id}/convert
Create webhookPOST /invoicing/v1/webhooksPOST /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:

  • contactIdclient_id (explicit FK; value is a UUID v7).
  • date (timestamp) → issued_on (YYYY-MM-DD), with due_on required.
  • 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_id required — 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

  1. Inventory: number of contacts, products, historical invoices, active webhooks.
  2. Map via external_id (recommended): write each Holded ID into the external_id of the corresponding Factuarea resource on create. You then don't need an intermediate holded_id ↔ factuarea_id table — to resolve a relationship (invoice → client) or to re-run the migration safely, look the record up with POST /v1/{resource}/find-by-external-id (body { "external_id": "<holded_id>" }). This is what makes the migration idempotent.
  3. Phased migration:
    • Catalogs: taxes, series, products → first.
    • Masters: clients, suppliers → second.
    • Historical documents: invoices, quotes, etc. → third.
  4. Temporary dual-write: for 1–2 weeks, write to both platforms. Reconcile differences daily.
  5. Webhooks: configure the new endpoints, deploy the handler with HMAC verification and run in parallel.
  6. Cut-over: stop writing to Holded, disable webhooks there.
  7. Support: contact support@factuarea.com with the request_id for 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 sent except mark-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.

On this page