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()

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:

  • name and tax_id are mandatory destinations. A mapping without both is rejected with 422 before 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 Id column 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.

DestinationRequiredValidated as
nameyesnon-empty
tax_idyesSpanish NIF/CIF/NIE
commercial_namenofree text
vat_idnofree text
emailnoemail address
phonenophone number
mobilenophone number
faxnofree text
websitenofree text
addressnofree text
address_line2nofree text
address_numbernofree text
address_floornofree text
address_doornofree text
address_staircasenofree text
citynofree text
postal_codenofree text
provincenofree text
countrynofree text
bank_ibannofree text — becomes the client's default bank account
default_vat_ratenonumeric
default_retention_ratenonumeric
default_discountnonumeric
payment_methodnofree text
payment_terms_daysnonumeric
contact_personnofree text
notesnofree 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:

  1. 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 200 with the client, or 404 client_not_found if that row was one of the failures from step 2.

  2. Write the Holded ID into it. PUT on a client is a partial update, so sending only external_id leaves 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

  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