Skip to main content

timbro-invoices

The Python SDK for Timbro Invoices: issue, retrieve, deliver, and download Invoices, share accepted receipts, respond to received supplier documents, and verify signed webhooks. It needs Python 3.11 or newer and depends only on httpx and timbro-protocol; the integration extra adds cryptography for an integration's own key.

pip install timbro-invoices

Issue an Invoice

from timbro_invoices import TimbroInvoices

with TimbroInvoices(api_key=secret_key, application="my-erp/1.0") as timbro:
    preview = timbro.invoices.preview(invoice_request)
    invoice = timbro.invoices.create(invoice_request)
    invoice_snapshot = timbro.invoices.retrieve(invoice["id"])
    if invoice_snapshot["files"]["pdf"]["status"] == "ready":
        pdf = timbro.invoices.download_pdf(invoice_snapshot["id"])

api_key is a secret key with the invoices capability. base_url defaults to https://api.timbro.do; an independent installation passes its own origin and key. Its invoice and received-document operations match; its webhook registration differs from webhook_endpoints.create, so reach it with request. The constructor, timeouts, retries, and http_client work as in timbro-payments.

Requests and responses are plain dictionaries with the API's camelCase keys, typed as TypedDicts in timbro_invoices.types. invoice_request is the same JSON body HTTP accepts. Responses are not validated, so fields and enum values added by a later API version pass through unchanged.

invoiceReference identifies one issuance intent. Repeating a create with the same request returns the same Invoice, so a create whose answer was lost is safe to repeat, and invoices.list(invoice_reference=...) finds it. AsyncTimbroInvoices has the same namespaces: await each call and use async for with list_all.

Namespace Methods
invoices create, preview, retrieve, list, list_all, replace, deliver, retrieve_delivery, download_xml, download_pdf, share_receipt, retrieve_receipt, download_receipt_pdf
received_documents list, list_all, retrieve, respond, download_xml
webhook_endpoints create, retrieve, list, list_all, delete, suspend, reactivate, rotate_secret, list_deliveries, list_all_deliveries, replay
business retrieve, update, configure_invoicing, import_signing_certificate, record_sequence, issue_test_invoice, verify_rnc, certification.upload_test_set, certification.retrieve_test_set, certification.sign_portal_document
api_keys rotate
exchange_rates retrieve

webhook_endpoints, business, api_keys, and exchange_rates are the account controls timbro-payments exposes too, generated identically, so an invoices-only integration needs no other package. The business writes need an administrator key. The checkout policy, Merchant Accounts, and onboarding stay in timbro-payments.

Path identifiers are positional, the request body is the positional request, and filters and options are keyword-only. The names follow @timbro/invoices. Where Python differs:

  • retrieve, retrieve_delivery, retrieve_receipt, and received_documents.retrieve raise NotFoundError for a missing resource; the JS methods return undefined.
  • deliver(invoice_id, {"recipientEmail": ...}, idempotency_key=...) and retrieve_delivery(invoice_id, delivery_id) take arguments instead of one command object.
  • list_all and list_all_deliveries iterate every page; JS has no counterpart.
  • api_keys.rotate has no counterpart in @timbro/invoices; JS exposes it in @timbro/payments only.
  • File downloads return content rather than bytes, and the metadata headers under headers; a received document's digest is headers.artifact_sha256, not contentSha256.

Connect as an integration

An integration, such as an Odoo company, holds its own key instead of an API key. It registers a machine client with Timbro once, through the connection a business owner or administrator approves in Portal Timbro, and keeps the private key. Pass that client and key as a credential:

pip install "timbro-invoices[integration]"
from timbro_invoices import IntegrationCredential, TimbroInvoices

credential = IntegrationCredential(
    client_id=client_id,
    private_key=private_key_pem,
    key_id="odoo-company-1",
    mode="test",
)
with TimbroInvoices(credential=credential, application="odoo-l10n_do_timbro/20.0.2.0") as timbro:
    rate = timbro.exchange_rates.retrieve("USD")
    invoice = timbro.invoices.create(invoice_request)

private_key is the RSA key whose public half the client registered, as PEM text or bytes or as a JWK; a JWK's own kid serves when key_id is omitted. The client finds the token endpoint from base_url, signs a one-minute RFC 7523 assertion, and sends a ten-minute access token it replaces a minute before it expires. When Timbro refuses a token, for example after the business lowered its scopes, the call is repeated once with a new one. A refused credential raises AuthenticationError whose code is the OAuth error, such as invalid_client for a disconnected integration.

mode chooses test or live, and every token acts in that mode only. The token asks for every scope of the mode, and Timbro grants those the business approved, so one token covers invoices, payments, and refunds. In live mode it also covers sequences:live. A business approves live mode a second time, once it is live; a live credential before that approval raises AuthenticationError with code invalid_scope. Clients built from equal credentials in one process, including a TimbroPayments client, share the token and its discovery. Building a client per operation therefore costs no extra exchange. granted_families() returns the families the token holds, such as frozenset({"invoices", "payments", "refunds"}), so an integration subscribes its webhook endpoint only to events it may receive:

from timbro_invoices import IntegrationCredential, TimbroInvoices
from timbro_invoices.types import WebhookEventType

credential = IntegrationCredential(client_id=client_id, private_key=private_key_pem, mode="test")
with TimbroInvoices(credential=credential) as timbro:
    families = timbro.granted_families()
    events: list[WebhookEventType] = []
    if "invoices" in families:
        events += ["invoice.accepted", "invoice.rejected"]
    if "payments" in families:
        events += ["payment.succeeded", "payment.canceled"]
    endpoint = timbro.webhook_endpoints.create(
        {"url": receiver_url, "enabledEvents": events}, idempotency_key=f"{client_id}-events"
    )
``` `business.retrieve` reads the business that approved the integration in either mode, and `business.record_sequence` takes the live credential. `webhook_endpoints` manages the endpoints the integration creates, for the events its scopes cover. The other `business` reads and writes and `api_keys` still need an API key. `timbro.mode` reports the credential's mode.

## Upload a DGII certification workbook

An invoices-only integration can manage its business certification from this SDK with its
administrator key. Upload the original workbook bytes and filename, then read the retained review:

```python
import os
from pathlib import Path

administrator_key = os.environ["TIMBRO_ADMINISTRATOR_KEY"]
with TimbroInvoices(api_key=administrator_key) as timbro:
    timbro.business.certification.upload_test_set(
        {
            "kind": "ecf",
            "file": {"name": "DGII.xlsx", "bytes": Path("DGII.xlsx").read_bytes()},
        },
        idempotency_key="merchant-onboarding-123-dgii-ecf-workbook-v1",
    )
    test_set_snapshot = timbro.business.certification.retrieve_test_set()

Workbooks must be 1 byte to 3 MiB. Upload retains the file for review; it does not submit fiscal documents, execute cases, or advance certification. The review starts as review_required, and case_mapping_pending means the workbook cases still need mapping. Persist the key alongside the filename and bytes. After an unknown result, retry those exact inputs with the same key; use a new key for a corrected replacement. AsyncTimbroInvoices has the same methods, which you can await.

Deliveries and receipts

invoices.deliver emails the Invoice to its buyer. Its idempotency_key is required: derive it from your durable record of the delivery and send the same key on every retry, so a retry returns the same delivery.

delivery = timbro.invoices.deliver(
    invoice["id"],
    {"recipientEmail": "buyer@example.do"},
    idempotency_key=f"{invoice['invoiceReference']}:delivery:1",
)
delivery = timbro.invoices.retrieve_delivery(invoice["id"], delivery["id"])

invoices.share_receipt(invoice_id) returns an InvoiceReceiptAccess grant, {id, invoiceId, accessToken}, for an accepted Invoice. retrieve_receipt(access) and download_receipt_pdf(access) authenticate with that grant instead of your API key, and the token never appears in a URL. Keep the grant on your server and hand it to the receiving service over an authenticated channel; it reaches only the receipt and its PDF.

Answers are checked against what you asked for: an Invoice, delivery, receipt grant, receipt, received document, or webhook endpoint that names another identifier than the one requested raises UnexpectedResponseError with the code response_identity_mismatch.

Files

download_xml, download_pdf, download_receipt_pdf, and received_documents.download_xml return the file only after its bytes and metadata agree with the headers Timbro sends. Each result has the bytes as content, its media_type, and the checked metadata headers as headers:

Result Checked headers
InvoiceXmlDownload media type, SHA-256, Invoice identity, creation time, certificate digest, security code InvoiceXmlHeaders: file_sha256, invoice_id, file_created_at, certificate_sha256, security_code
InvoicePdfDownload the same file checks, the PDF digest, document type, eNCF, layout, and page count InvoicePdfHeaders: file_sha256, invoice_id, file_created_at, pdf_sha256, encf, invoice_type, pdf_layout, page_count, signed_source_sha256
ReceivedDocumentDownload media type and SHA-256 of the original bytes ReceivedDocumentHeaders: artifact_sha256
pdf = timbro.invoices.download_pdf(invoice["id"])
print(pdf.headers.encf, pdf.headers.page_count, len(pdf.content))

A mismatch raises FileVerificationError, an UnexpectedResponseError, with the code file_integrity_mismatch when the bytes, media type, or identity differ, or file_metadata_invalid when a header is missing or malformed. Any answer larger than 10 MiB is refused with the code response_too_large.

Received documents

for summary in timbro.received_documents.list_all(state="received", response="pending"):
    xml = timbro.received_documents.download_xml(summary["id"])
    archive(xml.content)
    timbro.received_documents.respond(summary["id"], {"decision": "accepted"})

A commercial response accepts a supplier document, or rejects it with {"decision": "rejected", "motive": ...}. It does not post a bill or issue an Invoice.

Errors

Errors share one hierarchy with timbro-payments, from timbro-protocol: TimbroError carries code, message, request_id, operation and its SDK method (such as invoices.create), retryable, outcome_unknown, and recovery and its recovery_method; branch on method to treat one package's calls differently, and APIError adds status, title, problems, retry_after_seconds, and the whole Problem as body. InvalidRequestError (400 or 422) lists each field at fault in problems, as JSON Pointers into your request. Problem members specific to Invoices are in body:

from timbro_invoices import APIError

try:
    invoice = timbro.invoices.create(invoice_request)
except APIError as error:
    if error.code == "fiscal_issuance_blocked":
        blockers = error.body.get("issuanceBlockers", [])
    invoice_id = error.body.get("invoiceId")  # present when the refusal retained an Invoice

When outcome_unknown is true, a write may have taken effect although the call failed. The error text says what to do next.

Routes outside the published contract

Prefer the typed methods. For a route the published contract does not include yet, such as a Stamp installation's own readiness read, request sends one call through the same client, with the same API key, user agent, redirect refusal, 10 MiB limit, and errors:

readiness = timbro.request("GET", "/v1/stamp/readiness")

path must be a /v1/ path; an absolute URL, a query or fragment in the path, and an empty or dot segment raise ValueError, and query= carries the filters. The answer is the decoded JSON object, or None for an empty one, and nothing checks its shape. A GET is retried when safe; any other method is retried only when you pass idempotency_key=. An error it raises has method "request", operation set to the method and path, such as "GET /v1/stamp/readiness", and no recovery. AsyncTimbroInvoices clients have the same method to await.

Webhooks

Register an endpoint once, and store its signing secret; Timbro shows it only in this answer:

endpoint = timbro.webhook_endpoints.create(
    {"url": "https://erp.example.do/timbro/webhooks", "enabledEvents": ["invoice.accepted"]},
    idempotency_key="erp-webhook-endpoint-1",
)
store_secret(endpoint["secret"])

Verification needs no API key and accepts exactly the deliveries timbro_payments.webhooks.verify accepts. The endpoint's signing secret authenticates the delivery:

from timbro_invoices import webhooks

event = webhooks.verify(body=raw_body, headers=request_headers, secret=webhook_secret)
inbox.store(event)  # Deduplicate by event["id"], then acknowledge.

if webhooks.is_known_event(event) and event["type"] == "invoice.accepted":
    invoice = timbro.invoices.retrieve(event["data"]["id"])

Events of a type this version does not know verify and return as UnknownWebhookEvent. A failure raises WebhookVerificationError, whose reason names the check. Invoice webhooks with API version 2026-09-08 are authenticated over the original body, then their retained sourceConnectionId is normalized to integrationId. Current Invoice events use 2026-10-14. The returned apiVersion still identifies the original body; the signature authenticates those original bytes. Mixed identity fields, incompatible versions, and malformed Invoice shapes are rejected. Unrecognized events and unrelated event families retain their existing behavior.

Receivers bound to several Integrations

A receiver that serves several Integrations from one endpoint, such as an ERP with one connection per company, can also confirm which one an event belongs to. verify_invoice_scope raises WebhookVerificationError with the reason scope_mismatch unless the event names the expected organization, fiscal environment, and Integration:

webhooks.verify_invoice_scope(
    event,
    organization_id=company.timbro_organization_id,
    environment=company.timbro_environment,
    integration_id=company.timbro_integration_id,
)

The organization and Integration IDs come from the Integration your integration recorded when it connected to Timbro; the Gateway API does not return them. A receiver of a single endpoint secret does not need this check.

Offline request validation

Timbro validates every request and answers an invalid one with InvalidRequestError. To check a request without sending it, such as requests retained before an upgrade, install the validation extra:

pip install "timbro-invoices[validation]"
from timbro_invoices.validation import InvalidInvoiceRequest, validate_invoice_request

try:
    validate_invoice_request(retained_request)
except InvalidInvoiceRequest as error:
    for problem in error.problems:
        print(problem["path"], problem["code"])  # "/items/0/quantity", "type"

The check is strict, against the InvoiceRequest schema this version bundles. Messages never contain the rejected value. The extra adds jsonschema and regex; the client never imports them.

Testing

timbro_invoices.testing builds schema-valid Invoices, received documents, and Invoice events, and signs webhook fixtures. invoice_xml_download, invoice_pdf_download, and received_document_download build the verified results the download methods return, with the digests of the content you pass, and api_error(status, code, method="invoices.create") builds the APIError subclass that method raises for that status. Fake the API by giving the client an httpx.MockTransport:

import json

import httpx
from timbro_invoices import TimbroInvoices, testing

def handler(request: httpx.Request) -> httpx.Response:
    return httpx.Response(200, json=testing.invoice(status="accepted"))

timbro = TimbroInvoices(
    api_key="sk_test_123",
    http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)

event = testing.invoice_event("invoice.accepted")
body = json.dumps(event).encode()
headers = testing.sign_webhook(body, "whsec_...", webhook_id=event["id"])

Migrating from 0.10

0.11 moves with timbro-payments and timbro-protocol; upgrade them together. Invoices carry fiscalIdentity.signature (null until signed, then signedAt, securityCode, and verificationUrl) for printing the e-CF, deliveries accept attachments and report them, and two events announce delivery outcomes: invoice.deliverySent and invoice.deliveryFailed, whose data adds deliveryId. The buyer email now leaves only after DGII accepts the Invoice.

Migrating from 0.9

0.10 moves timbro-invoices onto the shared timbro-protocol runtime, with namespaces generated from the Gateway contract. Packages that share a minor version form one compatible set, so upgrade timbro-payments and timbro-protocol to 0.10 with it.

0.9 0.10
Python 3.10, jsonschema, regex Python 3.11, httpx, timbro-protocol; jsonschema and regex only in the validation extra
TimbroInvoices(api_key, base_url=, transport=) TimbroInvoices(api_key, base_url=, timeout=, max_retries=, http_client=, application=); fake the API with httpx.MockTransport
create_invoice(request, idempotency_key=) invoices.create(request, idempotency_key=)
preview_invoice(request) invoices.preview(request)
retrieve_invoice(invoice_id) invoices.retrieve(invoice_id)
list_invoices(invoice_reference=, type=, cursor=, limit=) invoices.list(...) with the same keywords, or invoices.list_all(...)
replace_invoice(invoice_id, request) invoices.replace(invoice_id, request)
create_delivery(invoice_id, recipient_email=, idempotency_key=, account_claim_url=) invoices.deliver(invoice_id, {"recipientEmail": ..., "accountClaimUrl": ...}, idempotency_key=)
retrieve_delivery(...) returning None for fiscal_delivery_not_found invoices.retrieve_delivery(...) raising NotFoundError with that code
download_xml, download_pdf, download_receipt_pdf returning InvoiceFile with a metadata dict the same names on invoices, returning InvoiceXmlDownload or InvoicePdfDownload with typed headers
share_receipt, retrieve_receipt, download_receipt_pdf the same names on invoices, taking the same grant
Received documents through raw HTTP received_documents.list, list_all, retrieve, respond, download_xml
TimbroHttpError with status, code, request_id, path, problems, retryable APIError and its subclasses with the same members except path, which is problems[0]["path"]; status=0 is now APIConnectionError
artifact_integrity_mismatch, artifact_metadata_invalid FileVerificationError with file_integrity_mismatch, file_metadata_invalid
invalid_problem_response UnexpectedResponseError with unexpected_http_error; a failure body needs only a string code
invalid_invoice_response for a response outside the schema none: responses pass through; a response for another resource raises response_identity_mismatch
Methods raising InvalidInvoiceRequest(path="$.items[0].quantity") before sending Timbro answers InvalidRequestError with problems[].path such as /items/0/quantity
validate_contract_schema("InvoiceRequest", value), SchemaValidationError validation.validate_invoice_request(value), validation.InvalidInvoiceRequest (a ValueError) with problems
verify_webhook_signature(body, headers, secret, now=) webhooks.verify(body=, headers=, secret=, now=), which also returns the event and accepts rotated secrets
parse_invoice_webhook_event(body, message_id=, organization_id=, environment=, integration_id=) webhooks.verify(...), then webhooks.verify_invoice_scope(event, organization_id=, environment=, integration_id=)
is_known_invoice_webhook_event(event) webhooks.is_known_event(event)
InvoiceWebhookEvent, UnknownInvoiceWebhookEvent types.InvoiceWebhookEventEnvelope (InvoiceAcceptedEvent and its siblings), webhooks.UnknownWebhookEvent
InvalidWebhook WebhookVerificationError with a reason
decode_signing_secret(secret) none: webhooks.verify raises WebhookVerificationError with the reason invalid_secret, and accepts keys of any length

Behavior that changed besides the names:

  • Responses are not validated against the contract, so a missing or unknown member no longer raises.
  • Invoice webhook envelopes validate their version, UUIDs, date-times, environment, and sequence. Other known event families require their published members and tolerate added fields. verify_invoice_scope compares the organization and Integration as UUIDs.
  • The client no longer checks request bodies or the delivery key's format before sending; Timbro refuses an invalid request with InvalidRequestError.

License

No license has been granted for this package yet. The license will be declared before the first public release.

Metadata

Release files for timbro-invoices 0.19.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for timbro-invoices 0.19.1
File Size Uploaded
timbro_invoices-0.19.1.tar.gz 118.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for timbro-invoices 0.19.1
File Interpreter ABI Platform
timbro_invoices-0.19.1-py3-none-any.whl Python 3 none any Details

Total release size: 221.2 kB

Release files / timbro_invoices-0.19.1.tar.gz

Download URL timbro_invoices-0.19.1.tar.gz
Size 118.1 kB
Tags Source
SHA-256 checksum
How to use checksums
d407fe09e5eb3b6dc499c9361f1756161a583abc2da1e72d60101b99dd4fad7d
BLAKE2b-256 checksum
How to use checksums
bec46fa5eac62f94d3fa58cc1eeff55ce07fb2a45b43133c4cf4ff5e763ad3c9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.4

Release files / timbro_invoices-0.19.1-py3-none-any.whl

Download URL timbro_invoices-0.19.1-py3-none-any.whl
Size 103.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d20a72b84a8cbdf011577e9856e597cd1da547e27d1a2397564dcb2236888232
BLAKE2b-256 checksum
How to use checksums
29a5012e7043ce91726e517e6893743cfe0fa097593841946d78133ad08bf35e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.4
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page