Metadata-Version: 2.4
Name: timbro-invoices
Version: 0.18.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.19,>=0.18
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.19,>=0.18; 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 the scopes, so every token acts in one mode: `invoices:test` in test mode, and `invoices:live` with `sequences:live` in live mode, which a business approves a second time once it is live. `business.record_sequence` takes the live credential. The other `business` writes, `api_keys`, and `webhook_endpoints` 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.
