Skip to main content

Application-agnostic Holded API client for MisterPato projects.

Project description

misterpato-holded-client

Application-agnostic Python client package for the Holded legacy Invoice API v1.

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

Installation

Install from a checkout in editable mode:

python3 -m pip install -e .

Install with test dependencies for development:

python3 -m pip install -e .[test]
python3 -m pytest -q

Runtime dependency: requests.

Supported Python: >=3.9.

Development environment

A local virtual environment can be created with:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e .[dev]
python -m pytest -q

This checkout includes .python-version set to 3.10.12 for pyenv-compatible tooling. The package itself supports Python >=3.9.

Publishing

Build and upload instructions are documented in PUBLISHING.md. Short version:

source .venv/bin/activate
python -m pytest -q
rm -rf dist build *.egg-info src/*.egg-info
python -m build
python -m twine check dist/*
python -m twine upload dist/*

Do not commit real PyPI/TestPyPI tokens. .pypirc.example contains only placeholders.

Basic usage

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.

VERSION_2 exists as a future-facing constant, but constructing a v2 client raises NotImplementedError until v2 is implemented.

Configuration

client = HoldedClient(
    api_key="holded-api-key",
    version=VERSION_1,
    base_url="https://api.holded.com/api",
    timeout=30,
    timezone="Europe/Madrid",
)

Notes:

  • The Holded v1 auth header is added automatically as key: <api_key>.
  • Blank API keys raise HoldedConfigError.
  • timezone controls how naive date and datetime values are serialized to Holded Unix timestamps.

Documents

Create an invoice with the typed resource:

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

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:

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

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

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

These resources are list-only in this v1 package:

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

Pagination

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

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

Development verification

python3 -m pytest -q
python3 -m compileall -q src tests
python3 - <<'PY'
from misterpato_holded import HoldedClient, VERSION_1, VERSION_2
client = HoldedClient(api_key="x", version=VERSION_1)
print(client.version, client.invoices.doc_type.value)
try:
    HoldedClient(api_key="x", version=VERSION_2)
except NotImplementedError as exc:
    print(type(exc).__name__, exc)
PY

Project details


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.2.1.tar.gz (20.2 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.2.1-py3-none-any.whl (19.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: misterpato_holded_client-0.2.1.tar.gz
  • Upload date:
  • Size: 20.2 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.2.1.tar.gz
Algorithm Hash digest
SHA256 3eaabcf2f68703264ba2ae325ec7067b2bcff3613f696b127ddab580953aae4d
MD5 31eeb14e21068768ccc20528cd77b292
BLAKE2b-256 5d3a2d7f83e1f305b6ea6b21da6110339f38a7d6703d1963734d6f435fd61fd0

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for misterpato_holded_client-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 1aa18b6a6ae749b4de25a9b328d1670d3c819d1ecbea9f78b8125c1101217f44
MD5 f4d7779a7b60d77e529b895f7c3dd20e
BLAKE2b-256 90ca948a5a05d353433444f28b75ef7b711028f9a448718206f066f9c540660e

See more details on using hashes here.

Supported by

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