Factuarea APIDevelopers

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

HoldedFactuareaNotes
contactscontactsOne fiscal identity with cumulative customer, supplier and lead roles. Preserve both roles when a contact buys and sells.
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_test_... or X-API-Key: fact_test_.... 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 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 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/contacts?roles=customer
Create clientPOST /invoicing/v1/contacts (with type=client)POST /v1/contacts
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.

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

  1. Inventory: number of contacts, products, historical invoices, active webhooks.
  2. 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. 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.
  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

Need a hand?Contact support