Metadata-Version: 2.4
Name: timbro-invoices
Version: 0.19.0
Summary: Python SDK for Timbro Invoices: issue, retrieve, deliver, and download Invoices, respond to received documents, and verify signed webhooks
Author: Timbro contributors
License-Expression: Apache-2.0
Project-URL: Homepage, https://timbro.do
Project-URL: Source, https://github.com/indexa-labs/checkout/tree/main/python/timbro-invoices
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: timbro-protocol<0.20,>=0.19
Requires-Dist: httpx<1,>=0.27
Provides-Extra: validation
Requires-Dist: jsonschema<5,>=4.22; extra == "validation"
Requires-Dist: regex>=2024.11.6; extra == "validation"
Provides-Extra: integration
Requires-Dist: timbro-protocol[integration]<0.20,>=0.19; extra == "integration"
Dynamic: license-file

# 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.

```sh
pip install timbro-invoices
```

## Issue an Invoice

```python
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 `TypedDict`s 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`:

```sh
pip install "timbro-invoices[integration]"
```

```python
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. `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.

```python
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` |

```python
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

```python
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`:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```sh
pip install "timbro-invoices[validation]"
```

```python
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`:

```python
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.
