Modular Python client and CLI for the complete Holded API v2
pyholded is a Python toolkit to talk to the Holded business-management API (v2). Every endpoint across all modules — sales/purchase documents, contacts, products, CRM, projects and team — is described in a single declarative registry from which both the typed client and the CLI are generated. Results print as rich tables, JSON, or TOON.
| Feature | Description |
|---|---|
| Registry-driven | Every endpoint is data; client and CLI share one source of truth |
| Full v2 surface | Documents, contacts, products, CRM, projects, team — plus a raw escape hatch |
| Bearer auth | Token from --token, environment variable, or TOML config file |
| Cursor pagination | --all (CLI) / paginate=True (library) merges every page |
| Three outputs | Rich tables, JSON, and TOON (token-efficient for LLMs) |
| CLI + Library | Use as a command-line tool or a typed Python package (py.typed) |
| Strict quality gate | ruff, black, mypy (strict), bandit, vulture, pip-audit — zero suppressions |
Records rich tables, JSON, TOON
Pagination cursor-based ({items, cursor, has_more}); --all merges pages
Auth Authorization: Bearer <PAT> via env var or config file
Binary PDF download for any document type (invoices, credit-notes, ...)
pip install pyholdedgit clone https://github.com/seifreed/pyholded.git
cd pyholded
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -e .pip install "pyholded[dev]" # ruff, black, mypy, bandit, vulture, pip-audit, pytestHolded API v2 uses a Personal Access Token (pat_…) sent as Authorization: Bearer.
Generate one in Holded: Settings → Developers → Credentials → Add API Token.
The token is resolved in order of precedence:
- An explicit value (
--token, orHoldedClient(token=...)). - The
HOLDED_TOKENenvironment variable (HOLDED_API_KEYalso accepted). - A TOML config file (
--config,HOLDED_CONFIG, or~/.config/pyholded/config.toml).
# ~/.config/pyholded/config.toml
[holded]
token = "pat_xxx_yyy"
# base_url = "https://api.holded.com/api/v2/" # optional overrideConfigure several Holded accounts and query one or all of them.
Environment variables — HOLDED_TOKEN is the default account; HOLDED_TOKEN_<NAME>
adds a named account:
export HOLDED_TOKEN="pat_default"
export HOLDED_TOKEN_ACME="pat_acme"
export HOLDED_TOKEN_PERSONAL="pat_personal"Config file — per-account tables (env overrides the file for the same name):
# ~/.config/pyholded/config.toml
default_account = "acme" # optional; picks the account when none is given
[accounts.acme]
token = "pat_acme"
# base_url = "..." # optional, per account
[accounts.personal]
token = "pat_personal"Select with --account <name> (CLI) / HoldedClient(account="acme") (library), or fan
out to every account with --all-accounts / MultiClient. When several accounts are
configured and none is selected, set default_account or pass one explicitly.
# List every resource and its operations
holded resources
# List records (pretty table by default)
holded contacts list --limit 5
# TOON output, ideal for LLM contexts
holded taxes list -o toon# List a page, or follow the cursor and fetch all pages
holded invoices list --limit 50
holded expenses_accounts list --all -o json
# Get one record, in JSON
holded contacts get --id 0123456789abcdef01234567 -o json
# Download a document PDF (binary)
holded invoices get-pdf --id 89abcdef0123456789abcdef > invoice.pdf
# Create from inline JSON, a file, or key=value fields
holded contacts create --data '{"name": "ACME SL"}'
holded contacts create --data @contact.json
holded contacts create --field name=ACME --field code=B12345678
# Multiple accounts
holded accounts # list configured accounts
holded --account acme contacts list # one named account
holded --all-accounts contacts list -o json # fan out -> {account: result}
# Call any endpoint directly
holded raw GET taxes -o toon| Command | Description |
|---|---|
holded resources |
List all resources and their operations |
holded accounts |
List configured accounts (names + base URLs; tokens never shown) |
holded <resource> list |
List records (cursor-paginated; --all fetches every page) |
holded <resource> get --id <id> |
Get a single record |
holded <resource> create --data <json> |
Create a record |
holded <resource> update --id <id> --data <json> |
Update a record |
holded <resource> delete --id <id> |
Delete a record |
holded invoices get-pdf --id <id> |
Download a document PDF (also send) |
holded raw <METHOD> <PATH> |
Call an arbitrary endpoint |
| Option | Description |
|---|---|
-o, --output {rich,json,toon} |
Output format (global default or per-command override) |
-a, --account <name> |
Use a named account (env/config) |
--all-accounts |
Run the command on every configured account |
--all |
Follow the cursor and fetch every page (GET) |
--limit, --cursor |
Manual pagination controls |
--data <json|@file>, --field k=v |
Request body for create/update |
--token, --config, --base-url, --timeout |
Connection options |
| Group | Resources |
|---|---|
Documents (CRUD + get-pdf, send) |
invoices, credit_notes, sales_orders, estimates, proformas, waybills, sales_receipts, purchases, purchase_orders |
| Masters (CRUD) | contacts, contact_groups, products, services, warehouses, payments, sales_channels, expenses_accounts, taxes, payment_methods |
| CRM | funnels, leads (+ create-note, create-task), events, bookings, booking_locations |
| Projects / Team | projects, tasks, employees |
from pyholded import HoldedClient
with HoldedClient() as client: # token from env or config file
page = client.invoices.list(params={"limit": 50})
everyone = client.contacts.list(paginate=True) # all pages, merged items list
contact = client.contacts.get(id="0123456789abcdef01234567")
pdf = client.invoices.getPdf(id="89abcdef0123456789abcdef") # raw bytes
new = client.contacts.create(data={"name": "ACME SL", "code": "B12345678"})
# Any endpoint, even one not modelled, is reachable directly:
raw = client.request("GET", "taxes", params={"limit": 5})Resources are attributes; operations are methods. Path parameters (id) are keyword
arguments, query parameters go in params=, and the request body in data=.
from pyholded import HoldedClient, MultiClient
# one named account
with HoldedClient(account="acme") as client:
invoices = client.invoices.list()
# every configured account at once -> {account: result}
with MultiClient.from_accounts() as multi: # or from_accounts(["acme", "personal"])
per_account = multi.contacts.list(params={"limit": 5})
# {"acme": {"items": [...]}, "personal": {"items": [...]}}A failure on one account is captured as {"error": "..."} for that account, so the
others still return their data.
from pyholded import OutputFormat, render, to_json, to_toon
render(page, OutputFormat.TOON) # print in TOON
print(to_json(page)) # canonical JSON string
print(to_toon(page)) # TOON stringA CycloneDX 1.6 SBOM (sbom.cdx.json) is generated from real data —
package SHA-256 hashes come from a uv-compiled hashed lockfile
(requirements.lock), licenses and suppliers from installed
package metadata, a full dependency graph, and an ECDSA P-256 (ES256) signature
embedded as a CycloneDX JSF block (pure-Python, offline, no external tools). CI
regenerates, scores and verifies it on every push.
make sbom # generate + sign sbom.cdx.json
make sbom-score # generate + score with sbomqs (fails below 9.0)
make sbom-verify # verify the embedded signature
make lock # refresh the hashed lockfile (uv)Current sbomqs score: 9.3 / 10 (Grade A) —
Identification, Provenance, Integrity, Licensing, Vulnerability and Structural all at A.
Completeness (D) is capped by sbomqs's CycloneDX dependency-graph detection, not by
missing data (the dependencies and compositions are present and valid). A perfect
10/A is not attainable for a PyPI project (it also requires per-component CPEs, which
Python packages do not have).
The signing private key is never committed; the public key travels inside the SBOM
(signature.publicKey JWK), so scripts/verify_sbom.py verifies it with no extra files.
- Python 3.14+
- See pyproject.toml for dependencies and extras
Contributions are welcome.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
All changes must keep the full quality gate green (ruff, black, mypy --strict,
bandit, vulture, pip-audit, pytest) with zero in-line suppressions.
If this project is useful in your workflows, you can support development:
This project is licensed under the MIT license. See LICENSE.
Attribution
- Author: Marc Rivero López | @seifreed
- Repository: github.com/seifreed/pyholded
Built for practical Holded automation and business-data workflows