Skip to main content

misterpato-holded-client

Application-agnostic Python client package for Holded Invoice API v1 and v2.

The package exposes a small synchronous SDK around Holded's v1 and v2 Invoice API. It is intentionally independent of Django, Celery, databases, or any app-specific idempotency layer.

Installation

pip install misterpato-holded-client

Pin an explicit version in production (for example misterpato-holded-client==0.3.0).

Runtime dependency: requests.

Supported Python: >=3.9.

Basic usage

Choose the Holded API version once at client construction. Resources are wired directly on the client instance for both versions.

v1

from misterpato_holded import HoldedClient, VERSION_1

client = HoldedClient(api_key="holded-api-key", version=VERSION_1)

HoldedClient wires v1 resources directly on the client instance:

  • Generic resources: client.documents, client.contacts, client.treasuries, client.payment_methods
  • Typed document resources: client.invoices, client.credit_notes, client.sales_receipts, client.sales_orders, client.proformas, client.waybills, client.estimates, client.purchases, client.purchase_orders, client.purchase_refunds

MVP document resources support the full essential document action set for every document type, especially client.invoices and client.credit_notes: list, iter_all, list_all, create, get, update, approve, pay, delete, get_pdf_base64, and get_pdf.

v2

from misterpato_holded import HoldedClient, VERSION_2

client = HoldedClient(api_key="holded-api-key", version=VERSION_2)

v2 currently wires:

  • client.contacts
  • client.invoices
  • client.credit_notes
  • client.proformas
  • client.products
  • client.sales_orders
  • client.taxes
  • client.treasuries
  • client.waybills
  • client.estimates
  • client.accounting_accounts
  • client.ledger_entries

Other v2 document types (purchases, purchase orders, purchase refunds, and sales receipts) are not implemented yet. Use v1 if you need those resources today.

v2 invoice actions implemented today: list, iter_all, list_all, create, get, update, approve, bulk_approve, pay, delete, get_pdf, and get_pdf_base64.

v2 credit note actions implemented today: list, iter_all, list_all, create, get, update, approve, pay, delete, get_pdf, and get_pdf_base64.

v2 proforma actions implemented today: list, iter_all, list_all, create, get, update, approve, delete, get_pdf, and get_pdf_base64 (no pay — proformas have no payments in the Holded v2 API).

v2 product actions implemented today: list, iter_all, list_all, create, get, update, update_stock, and delete.

v2 sales order and waybill actions implemented today: list, iter_all, list_all, create, get, update, approve, delete, get_pdf, and get_pdf_base64 (no pay — same shape as proformas).

v2 estimate actions implemented today: list, iter_all, list_all, get, delete, get_pdf, get_pdf_base64, attach_file, list_attachments, iter_all_attachments, list_all_attachments, and get_attachment (no create, update, approve, or pay — Holded's v2 estimates API is read/delete/PDF/attachments only).

v2 accounting actions implemented today: client.accounting_accounts supports create and list (optional filters); client.ledger_entries supports list, iter_all, and list_all with required start_date and end_date.

v2 contacts support list, iter_all, list_all, create, get, update, and delete.

v2 treasury account actions implemented today: list, iter_all, list_all, create, and get (Holded documents no update, delete, or archive endpoint for treasury accounts).

v2 tax actions implemented today: list only (Holded returns the full configured tax catalog in one response; no cursor pagination).

Configuration

client = HoldedClient(
    api_key="holded-api-key",
    version=VERSION_1,  # or VERSION_2
    base_url="https://api.holded.com/api",
    timeout=30,
    timezone="Europe/Madrid",  # v1 only; ignored for v2 wiring
)

Notes:

  • Blank API keys raise HoldedConfigError.
  • Unknown version values raise HoldedConfigError.
  • v1 auth: the client sends key: <api_key> automatically.
  • v2 auth: the client sends Authorization: Bearer <api_key> automatically and does not send the v1 key header.
  • URLs: both versions use the same default base_url (https://api.holded.com/api). v2 resource paths are /v2/... (for example /v2/invoices, /v2/contacts). You do not need a special v2 base_url for normal use.
  • v1 timezone: timezone controls how naive date and datetime values are serialized to Holded Unix timestamps. v2 date serialization uses ISO/date strings instead (see below).

Holded API v2

v2 is a separate surface under misterpato_holded.v2 with its own dataclasses, pagination, date formats, and invoice status vocabulary. v1 behavior is unchanged.

v2 models and .raw

Import v2 models from misterpato_holded.v2.models (or misterpato_holded.v2 for the public subset):

from misterpato_holded.v2.models import (
    CreateContactRequest,
    CreateInvoiceRequest,
    Estimate,
    EstimateAttachment,
    EstimateLine,
    Invoice,
    InvoiceStatus,
    LedgerEntry,
    PayInvoiceRequest,
    Product,
    SalesOrder,
    UpdateContactRequest,
    Waybill,
)

Or import resources and models from the package root:

from misterpato_holded.v2 import (
    Product,
    ProductsResource,
    SalesOrder,
    SalesOrdersResource,
    Waybill,
    WaybillsResource,
)

v2 response dataclasses are separate from v1 models. Every parsed response preserves the original decoded payload on .raw.

v2 invoice listing and InvoiceStatus

v2 invoice listing uses cursor pagination and a native status= filter. Supported values are validated locally before HTTP:

from misterpato_holded.v2.models import InvoiceStatus

pending = client.invoices.list(status=InvoiceStatus.PENDING)
# or: client.invoices.list(status="pending")

Allowed status values: pending, completed, partial, cancelled, failed, overdue.

v2 does not support v1's boolean paid= filter. Passing paid= raises TypeError because the argument is not part of the v2 API. Use explicit status= values instead.

v2 list methods also do not accept v1's page= argument. Use cursor= on list() or iter_all() / list_all() for multi-page reads.

v2 invoice creation

Create with an existing contact id:

from datetime import date

created = client.invoices.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Service", "units": 1, "subtotal": "120.00"}],
})

print(created.id, created.raw)

Or supply contact creation fields when you do not have a contact_id. The client creates the contact first, then creates the invoice with the returned id. It does not search existing contacts first:

created = client.invoices.create({
    "contactName": "Cliente Demo",
    "contactEmail": "cliente@example.com",
    "date": "2026-06-29",
    "items": [{"name": "Service", "units": 1, "subtotal": "120.00"}],
})

Recognized contact auto-creation aliases: contactName / contact_name and contactEmail / contact_email. The caller payload is copied internally; your original mapping is not mutated.

Contact auto-creation requires both contact-write and invoice-write permissions on the Holded API key. If contact creation succeeds but invoice creation fails, the new contact remains in Holded; callers that need idempotency must handle that at the application layer.

v2 invoice create does not emulate v1's approve=True. Passing approve=True raises HoldedRequestValidationError locally with no HTTP request. Approve invoices explicitly after creation:

created = client.invoices.create({...})
approved = client.invoices.approve(created.id)

Create responses usually return only {id}; call get() when you need the full invoice body.

v2 invoice actions

invoice = client.invoices.get("invoice-id")
updated = client.invoices.update("invoice-id", {"notes": "Updated notes"})
approved = client.invoices.approve("invoice-id")
paid = client.invoices.pay("invoice-id", {"date": "2026-06-29", "amount": "120.00"})
deleted = client.invoices.delete("invoice-id")
pdf_bytes = client.invoices.get_pdf("invoice-id")
pdf_base64 = client.invoices.get_pdf_base64("invoice-id")

update() is documented by Holded as full replacement. Send a complete payload or risk clearing omitted fields.

get_pdf() requests the binary PDF endpoint and returns raw bytes. get_pdf_base64() base64-encodes those bytes locally for compatibility with v1-style callers.

delete() handles Holded's 204 no-content response.

v2 invoice bulk approve

Known Holded-side issue (checked 2026-07-02): the live /v2/invoices/bulk/approve endpoint currently returns 422 {"message": "None of the invoices could be approved"} for every request, including freshly created, individually approvable draft invoices on an account with Verifactu disabled — reproduced with this client, with raw requests, and with curl. Until Holded fixes the endpoint, bulk_approve() will raise HoldedValidationError; approve invoices one by one with approve() instead. The method is kept because it matches the documented API contract and needs no client change once Holded resolves it.

bulk_approve() posts {"ids": [...]} to /v2/invoices/bulk/approve. Holded processes each invoice independently:

result = client.invoices.bulk_approve(["invoice-id-1", "invoice-id-2"])
print(result.status_code, result.message, result.raw)
  • 204: all invoices approved; message is empty and raw is {}.
  • 207: some invoices could not be approved; message carries Holded's explanation and raw preserves the response body.
  • 422 (none approved) and other errors raise the usual typed exceptions (HoldedValidationError, ...).

An empty sequence or blank ids raise HoldedRequestValidationError locally with no HTTP request.

v2 credit notes

client.credit_notes targets /v2/credit-notes (Holded's facturas rectificativas) with the same action set as invoices except bulk_approve:

created = client.credit_notes.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Refund", "units": 1, "price": "10.00"}],
})

credit_note = client.credit_notes.get(created.id)
approved = client.credit_notes.approve(created.id)
paid = client.credit_notes.pay(created.id, {"amount": "10.00", "date": "2026-06-29"})
pdf_bytes = client.credit_notes.get_pdf(created.id)
deleted = client.credit_notes.delete(created.id)

Differences from invoices:

  • No contact auto-creation. create() requires an existing contact_id; contactName / contactEmail aliases are not recognized, and a payload without contact_id raises HoldedRequestValidationError locally with no HTTP request.
  • Mapping payloads only. There are no typed request dataclasses for credit notes; pass plain mappings. Dates and money fields are serialized with the same ISO/Decimal rules as invoices.
  • List filters are limit, cursor, contact_id, status, start_date, end_date, sort, and approval_status. The invoice-only due_date_start, due_date_end, and accounting_account_num filters are not accepted (TypeError). Status values reuse the invoice vocabulary (pending, completed, partial, cancelled, failed, overdue).

Responses parse into CreditNote and the generic CreateDocumentResponse / UpdateDocumentResponse / ApproveDocumentResponse / PayDocumentResponse / DeleteDocumentResponse dataclasses, all preserving .raw.

v2 proformas

client.proformas targets /v2/proformas with the same action set as credit notes minus pay — proformas carry no payment fields in the Holded v2 API, so there is no pay method at all:

created = client.proformas.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

proforma = client.proformas.get(created.id)
approved = client.proformas.approve(created.id)
pdf_bytes = client.proformas.get_pdf(created.id)
deleted = client.proformas.delete(created.id)

Proforma create() supports the same contact auto-creation as invoices: when the payload has no contact_id but includes contactName / contact_name (optionally contactEmail / contact_email), the contact is created first via POST /v2/contacts, the returned id is used as contact_id, and the alias keys are stripped from the proforma payload. There is no contact search or deduplication — an existing contact with the same name is not reused. Without contact_id and without alias fields, create() raises HoldedRequestValidationError locally with no HTTP request:

created = client.proformas.create({
    "contactName": "New Client SL",
    "contactEmail": "billing@newclient.example",
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

List filters are limit, cursor, contact_id, status, start_date, end_date, and sort — no approval_status and no due-date filters (TypeError). Payloads are plain mappings, serialized with the same ISO/Decimal rules as invoices. Responses parse into Proforma (no payment fields) and the same generic document response dataclasses, all preserving .raw.

v2 products

client.products targets /v2/products. Products are a catalog resource (not a document): no contact_id, no line items, no PDF. Payloads are plain mappings sent verbatim — product price/cost/stock are decimal strings on the wire and are not run through invoice-style money serialization.

created = client.products.create({
    "name": "Widget",
    "kind": "simple",
    "has_stock": True,
    "for_sale": True,
    "for_purchase": True,
    "price": "99.95",
})

product = client.products.get(created.id)
print(product.price, product.stock)  # decimal strings — parse with Decimal in your app

client.products.update(created.id, {
    "name": "Widget v2",
    "for_sale": True,
    "for_purchase": False,
    "archived": False,
})

client.products.update_stock(created.id, {
    "warehouse_id": "warehouse-id",
    "stock_variation": -3,
    "description": "Inventory count",
})

for product in client.products.iter_all(limit=100):
    print(product.id, product.name, product.kind)

deleted = client.products.delete(created.id)

create() validates locally (no HTTP) that name, kind, has_stock, for_sale, and for_purchase are present (False counts as present). update() requires name, for_sale, for_purchase, and archived. update_stock() requires warehouse_id and stock_variation (0 is valid). List filters are cursor and limit only. Responses parse into Product with money/stock/weight kept as strings and .raw preserved; create()/update() return the generic CreateDocumentResponse / UpdateDocumentResponse; update_stock() returns UpdateStockResponse with the HTTP status code; delete() returns DeleteDocumentResponse.

Product kind is stored as the raw Holded string (for example simple, serialnumbers, or serial_numbers) — there is no enum normalization.

v2 sales orders

client.sales_orders targets /v2/sales-orders with the same action set as proformas (including inherited get_pdf / get_pdf_base64, no pay):

created = client.sales_orders.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

order = client.sales_orders.get(created.id)
approved = client.sales_orders.approve(created.id)
pdf_bytes = client.sales_orders.get_pdf(created.id)
deleted = client.sales_orders.delete(created.id)

create() supports the same contact auto-creation as proformas and invoices (contactName / contact_name, optionally contactEmail / contact_email). List filters are limit, cursor, contact_id, status, start_date, end_date, and sort — no approval_status and no due-date filters (TypeError). Payloads are plain mappings with ISO/Decimal serialization like invoices. Responses parse into SalesOrder (flat string fields, no payment trio) and the same generic document response dataclasses, all preserving .raw.

v2 waybills

client.waybills targets /v2/waybills and is structurally identical to client.sales_orders — same methods, list filters, contact auto-create, and response shape, but responses parse into Waybill:

created = client.waybills.create({
    "contactName": "Ship To SL",
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

waybill = client.waybills.get(created.id)
pdf_bytes = client.waybills.get_pdf(created.id)

There is no pay method on either resource.

v2 estimates

client.estimates targets /v2/estimates. Holded's v2 estimates API is read/delete/PDF/attachments only — there is no create, update, approve, or pay:

estimate = client.estimates.get("estimate-id")
pdf_bytes = client.estimates.get_pdf("estimate-id")
deleted = client.estimates.delete("estimate-id")

for estimate in client.estimates.iter_all(status="pending", approval_status="draft"):
    print(estimate.id, estimate.document_number, estimate.total)

List filters are limit, cursor, contact_id, status, start_date, end_date, sort, and approval_status — the same shape as credit notes, including approval_status and without due-date filters (TypeError). Responses parse into Estimate (with lines as EstimateLine objects) and the generic DeleteDocumentResponse, all preserving .raw.

Attachment helpers target /v2/estimates/{id}/attachments:

created = client.estimates.attach_file("estimate-id", file=open("quote.pdf", "rb"), filename="quote.pdf")

for attachment in client.estimates.iter_all_attachments("estimate-id"):
    print(attachment.id)

file_bytes = client.estimates.get_attachment("estimate-id", attachment.id)

attach_file() posts multipart form data and returns CreateDocumentResponse. Holded's docs describe a response with the new attachment id, but the live endpoint may return only {"status": "attached"} — the client accepts both shapes. When id is present, CreateDocumentResponse.id is set; when only status: "attached" is returned, id is empty and you can resolve the attachment via list_attachments() / iter_all_attachments() (or inspect .raw). list_attachments() / iter_all_attachments() parse attachment metadata into EstimateAttachment. get_attachment() returns raw bytes from the binary download endpoint.

v2 treasuries

client.treasuries targets /v2/treasury/accounts. Holded documents only create, list, and get — there is no update, delete, or archive endpoint, so the resource has no such methods:

created = client.treasuries.create({"name": "Main bank", "type": "bank", "currency": "EUR"})

account = client.treasuries.get(created.id)
print(account.balance)  # decimal string, e.g. "1250.55" — parse with Decimal in your app

banks = client.treasuries.list(type="bank", archived=False)
for account in client.treasuries.iter_all():
    print(account.id, account.name, account.type)

create() validates locally (no HTTP) that name is present and type is one of bank, card, gateway, or cash. List filters are cursor, limit, type (same allowed values), and archivedarchived=True returns only archived accounts, archived=False only active ones (sent as lowercase true/false on the wire), and omitting it returns both. Accounts parse into Treasury with balance kept as a decimal string (no float conversion) and .raw preserved; create() returns the generic CreateDocumentResponse.

v2 taxes

client.taxes targets /v2/taxes. Holded documents only list — there is no create, get, update, or delete endpoint, and the catalog is returned in a single response (no iter_all / list_all):

taxes = client.taxes.list()
for tax in taxes:
    print(tax.id, tax.name, tax.amount, tax.key)  # amount is a decimal string, e.g. "21"

Taxes parse into Tax with amount kept as a decimal string (no float conversion), sub-item keys on .items, and .raw preserved. Use tax.id or tax.key in product and document line taxes arrays when building payloads.

v2 accounting

client.accounting_accounts targets /v2/accounting-accounts. The resource supports create() and list() — there is no get, update, or delete endpoint, and Holded returns the chart in one {items:[...]} response (no package iter_all / list_all helpers):

created = client.accounting_accounts.create({
    "prefix": 7,
    "name": "Sales",
    "number": 70000013,
})

create() validates locally (no HTTP) that prefix and name are present. Responses return the generic CreateDocumentResponse with .raw preserved.

accounts = client.accounting_accounts.list(
    archived=False,
    include_empty=True,
    start_date="2026-01-01",
    end_date="2026-12-31",
)

Optional list filters are archived, start_date, end_date, and include_empty. When filtering by date, Holded requires both start_date and end_date, and they must be different dates (same-day ranges return 400: "End timestamp must be different than start."). Omitting only one of the two also returns 400. Accounts parse into AccountingAccount with debit / credit / balance kept as decimal strings and .raw preserved.

client.ledger_entries targets /v2/ledger-entries with cursor pagination. start_date and end_date are required on every list call:

from datetime import date

entries = client.ledger_entries.list(
    start_date=date(2026, 1, 1),
    end_date=date(2026, 1, 31),
    account=70000013,
)

for entry in client.ledger_entries.iter_all(
    start_date="2026-01-01",
    end_date="2026-01-31",
    limit=100,
):
    print(entry.entry_number, entry.account, entry.debit, entry.credit)

Optional list filters are cursor, limit, and account. Entries parse into LedgerEntry with debit/credit kept as decimal strings and .raw preserved.

v2 contacts

from misterpato_holded.v2.models import CreateContactRequest, UpdateContactRequest

contact = client.contacts.create(CreateContactRequest(
    name="Cliente Demo",
    email="cliente@example.com",
))

same_contact = client.contacts.get(contact.id)
updated = client.contacts.update(contact.id, UpdateContactRequest(name="Cliente Demo", email="cliente@example.com"))
deleted = client.contacts.delete(contact.id)

Raw mapping payloads are also supported. Contact update() is full replacement, same caveat as invoices.

Contact list() parses the v2 cursor envelope (items, has_more, cursor). iter_all() / list_all() follow cursors with the same semantics as invoices, preserving filters on every page:

for contact in client.contacts.iter_all(limit=100):
    print(contact.id, contact.name)

all_matching = client.contacts.list_all(email="cliente@example.com", max_pages=20)

v2 pagination

v2 invoice pagination is cursor-based, not page-number based:

first_page = client.invoices.list(status="pending", limit=50)

for invoice in client.invoices.iter_all(status="pending", start_date="2026-01-01"):
    print(invoice.id, invoice.document_number, invoice.raw)

all_pending = client.invoices.list_all(status="pending", max_pages=20)

iter_all() follows cursor values until has_more is false. Filters passed to the first call are preserved on later cursor requests. If max_pages is reached before pagination finishes, HoldedPaginationLimitError is raised instead of silently returning partial data.

For a single page, pass cursor= to list() directly.

v2 date and money serialization

v2 sends ISO date strings (for example 2026-06-29) rather than v1 Unix timestamp strings. List date filters reject Unix timestamp ints or numeric strings.

Money values still use Decimal(str(amount)), are quantized to 0.01 with ROUND_HALF_UP, then sent as JSON numbers.

client.invoices.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Service", "units": 1, "subtotal": "10.235"}],  # sends 10.24
})

Migrating from v1 to v2

Topic v1 v2
Client init version=VERSION_1 version=VERSION_2
Auth header key: <api_key> Authorization: Bearer <api_key>
Resources documents, contacts, treasuries, payment methods, all document types contacts, invoices, credit notes, proformas, products, sales orders, waybills, estimates, treasuries, taxes, accounting accounts, and ledger entries
Models misterpato_holded.v1.models misterpato_holded.v2.models
Invoice paid filter paid=True/False not supported; use status=
Pagination page=N, empty page stops cursor, has_more envelope
Dates on wire Unix timestamp strings ISO/date strings
Create + approve create(..., approve=True) create, then explicit approve()
Contact on create inline contact_name on document payload contact_id or auto-create via contact fields
PDF base64 JSON field and/or binary helper binary PDF bytes; base64 helper encodes locally

Webhooks

misterpato_holded.webhooks provides framework-agnostic helpers for receiving Holded webhooks: HMAC-SHA256 signature verification, header parsing, typed payload dataclasses, and event-name constants. There is no HTTP server, routing, or persistence — plug the helpers into whatever framework the app uses.

from misterpato_holded import webhooks
from misterpato_holded.exceptions import HoldedWebhookSignatureError

def receive(raw_body: bytes, request_headers: dict, secret: str):
    try:
        event = webhooks.parse_event(raw_body, request_headers, secret)
    except HoldedWebhookSignatureError:
        return 401  # reject: missing or invalid signature

    if event.event in webhooks.DOCUMENT_EVENTS:
        handle_document(event.payload, dedupe_key=event.idempotency_key)
    elif event.event in webhooks.EXTENDED_DOCUMENT_EVENTS:
        handle_extended_document(event.payload, dedupe_key=event.idempotency_key)
    elif event.event in webhooks.PRODUCT_EVENTS:
        handle_product(event.payload, dedupe_key=event.idempotency_key)
    elif event.event in webhooks.CONTACT_EVENTS:
        handle_contact(event.payload, dedupe_key=event.idempotency_key)
    # unknown events arrive with event.payload = None; ignore or log them
    return 204

Notes:

  • parse_event verifies the signature against the exact raw body bytes before parsing anything, and raises HoldedWebhookSignatureError on failure. Always pass the raw bytes as received, never re-serialized JSON.
  • Header lookup is case-insensitive; all x-holded-webhook-* metadata is exposed on the returned HoldedWebhookEvent (event, webhook_id, account_id, delivered_at, webhook_version).
  • Delivery is at-least-once: deduplicate on event.idempotency_key ((account_id, webhook_id)). Dedup storage, queuing, and retry handling are the consuming app's responsibility.
  • Event-name constants and frozensets group related events: DOCUMENT_EVENTS (invoice/credit note/proform), EXTENDED_DOCUMENT_EVENTS (salesorder.*, waybill.*, estimate.*), PRODUCT_EVENTS (product.*), and CONTACT_EVENTS.
  • Payload dataclasses map camelCase JSON to snake_case fields, keep nullable fields as None, keep money fields as strings (parse with Decimal), and preserve the original payload on .raw:
    • WebhookDocumentPayload — invoice/credit note/proform create/update
    • WebhookDeletedDocumentPayload — invoice/credit note/proform delete, and also salesorder.delete, waybill.delete, and estimate.delete
    • WebhookExtendedDocumentPayload — sales order/waybill/estimate create/update (includes tracking/shipping fields; arrays and currencyChange pass through as-is)
    • WebhookProductPayload — product create/update (price/cost/stock as strings; kind raw, no enum)
    • WebhookDeletedProductPayload — product delete
    • WebhookContactPayload / WebhookDeletedContactPayload — contact events
  • webhooks.verify_signature(raw_body, signature_header, secret) is available standalone when you only need the boolean check.

For Holded's upstream webhook contract (headers, payload fields, setup), see the Holded developer webhooks documentation.

Documents (v1)

Create an invoice with the typed resource:

from datetime import date
from misterpato_holded import HoldedClient, VERSION_1
from misterpato_holded.v1.models import CreateDocumentRequest, DocumentItem

client = HoldedClient(api_key="holded-api-key", version=VERSION_1)

invoice = client.invoices.create(CreateDocumentRequest(
    date=date(2026, 1, 15),
    contact_name="Cliente Demo",
    currency="EUR",
    items=[DocumentItem(name="Service", units=1, subtotal="120.00")],
))

print(invoice.id, invoice.doc_number, invoice.raw)

The same operation can be done through the generic documents resource:

from misterpato_holded.v1.documents import DocumentType

invoice = client.documents.create(
    DocumentType.INVOICE,
    CreateDocumentRequest(date=date.today(), contact_name="Cliente Demo"),
)

Pass approve=True to approve a document during creation, which sends Holded's approveDoc: true field. Pass custom_fields=[...] to send Holded's customFields array:

custom_fields = [{"field": "project", "value": "MisterPato"}]

proforma = client.proformas.create(
    CreateDocumentRequest(date=date.today(), contact_name="Cliente Demo"),
    approve=True,
    custom_fields=custom_fields,
)

Other document operations:

document = client.invoices.get("document-id")
updated = client.invoices.update("document-id", {"notes": "Updated notes"})
paid = client.invoices.pay("document-id", {"date": date.today(), "amount": "120.00"})
approved = client.invoices.approve("document-id")
pdf_base64 = client.invoices.get_pdf_base64("document-id")
pdf_bytes = client.invoices.get_pdf("document-id")
deleted = client.invoices.delete("document-id")

Raw mappings are accepted for pragmatic use. Known money and timestamp fields are serialized before sending, and locally known required fields are validated before the HTTP request.

Contacts (v1)

from misterpato_holded.v1.models import CreateContactRequest, UpdateContactRequest

contact = client.contacts.create(CreateContactRequest(
    name="Cliente Demo",
    email="cliente@example.com",
))

same_contact = client.contacts.get(contact.id)
updated_contact = client.contacts.update(contact.id, UpdateContactRequest(phone="+34 600 000 000"))
deleted = client.contacts.delete(contact.id)

Raw mapping payloads are also supported:

contact = client.contacts.create({"name": "Cliente Demo", "email": "cliente@example.com"})

Treasuries and payment methods (v1)

These resources are list-only in this v1 package:

treasuries = client.treasuries.list()
payment_methods = client.payment_methods.list()

Pagination (v1)

List resources support one-page list(page=N), lazy iter_all(...), and materialized list_all(...).

page_2 = client.invoices.list(page=2)

for invoice in client.invoices.iter_all(starttmp=date(2026, 1, 1), endtmp=date(2026, 1, 31)):
    print(invoice.id, invoice.doc_number)

all_contacts = client.contacts.list_all(max_pages=20)

iter_all() starts at page 1 by default and stops when Holded returns an empty list. If max_pages is reached before an empty page is observed, HoldedPaginationLimitError is raised instead of silently returning partial data.

Date and datetime serialization (v1)

Holded v1 expects string Unix timestamps for document/payment dates and starttmp/endtmp filters. This package accepts int, str, date, and datetime values and emits canonical decimal strings on the wire.

Rules:

  • Default timezone for naive date/datetime: Europe/Madrid.
  • Configure it with HoldedClient(..., timezone="UTC") or another IANA timezone name.
  • starttmp=date(...) uses local 00:00:00.
  • endtmp=date(...) uses local 23:59:59.
  • Naive datetime is interpreted in the client timezone.
  • Aware datetime preserves its instant.
from datetime import date, datetime, timezone

client = HoldedClient(api_key="holded-api-key", timezone="Europe/Madrid")

client.invoices.list(starttmp=date(2026, 1, 1), endtmp=date(2026, 1, 31))
client.invoices.create(CreateDocumentRequest(date=datetime(2026, 1, 15, 10, 30), contact_name="Demo"))
client.invoices.pay("document-id", {"date": datetime.now(timezone.utc), "amount": "120.00"})

Money serialization

Money values use Decimal(str(amount)), are quantized to 0.01 with ROUND_HALF_UP, then sent as JSON numbers.

DocumentItem(name="Service", units=1, subtotal="10.235")  # sends 10.24

Error handling

All package exceptions inherit from HoldedError.

from misterpato_holded.exceptions import (
    HoldedAuthError,
    HoldedConfigError,
    HoldedError,
    HoldedNotFoundError,
    HoldedPaginationLimitError,
    HoldedRequestValidationError,
    HoldedServerError,
    HoldedValidationError,
)

try:
    invoice = client.invoices.create(CreateDocumentRequest(contact_name="Missing date"))
except HoldedRequestValidationError:
    # Local validation failed before any HTTP request was sent.
    raise
except HoldedValidationError as exc:
    # Holded returned a remote 400/422 validation error.
    print(exc.status_code, exc.response_json)
except HoldedAuthError:
    print("Invalid or unauthorized Holded API key")
except HoldedNotFoundError:
    print("Document/contact not found")
except HoldedServerError as exc:
    if exc.retryable:
        print("Holded server error; safe for caller-controlled retry policy")
except HoldedPaginationLimitError:
    print("Pagination reached max_pages before an empty page")
except HoldedConfigError:
    print("Invalid local client configuration")
except HoldedError as exc:
    print(f"Holded client failure: {exc}")

Status mapping:

  • 400 and 422: HoldedValidationError
  • 401 and 403: HoldedAuthError
  • 404: HoldedNotFoundError
  • 409: HoldedConflictError
  • 5xx: HoldedServerError with retryable=True
  • Invalid successful JSON or unexpected response shape: HoldedResponseError

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

misterpato_holded_client-0.3.0.tar.gz (63.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

misterpato_holded_client-0.3.0-py3-none-any.whl (49.2 kB view details)

Uploaded Python 3

File details

Details for the file misterpato_holded_client-0.3.0.tar.gz.

File metadata

  • Download URL: misterpato_holded_client-0.3.0.tar.gz
  • Upload date:
  • Size: 63.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.12

File hashes

Hashes for misterpato_holded_client-0.3.0.tar.gz
Algorithm Hash digest
SHA256 4e2779e14250a7cd4802ec0114b9f066e4cd9b0b279460ecf193a4fda86df109
MD5 af436edb583d6b9674f5f14e5d4c40a4
BLAKE2b-256 60a2eacc77f46884452ace8c5cb476075615437e588179a97f8df4f1ee72cfe2

See more details on using hashes here.

File details

Details for the file misterpato_holded_client-0.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for misterpato_holded_client-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2fae5878babd4cc7d26a8d49781582769acfe7d5573460730e435b4ecfddaf2f
MD5 5a31fe3beea75e8d00536267f2c2faf9
BLAKE2b-256 c3ad51ba8cc9a1e0f6fe5fda9d441b1d62aafaba21be1577c35f70e90fb268fd

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

0.2.9

2 files

0.2.8

2 files

0.2.7

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page