Metadata-Version: 2.4
Name: timbro-invoices
Version: 0.10.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.11,>=0.10
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"
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`.

```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)
    current = timbro.invoices.retrieve(invoice["id"])
    if current["files"]["pdf"]["status"] == "ready":
        pdf = timbro.invoices.download_pdf(current["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`, `issue_test_invoice`, `issue_invoice_key` |
| `api_keys` | `rotate` |

`webhook_endpoints`, `business`, and `api_keys` 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`.

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

### Receivers bound to several Source connections

A receiver that serves several Source connections 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 Source connection:

```python
webhooks.verify_invoice_scope(
    event,
    organization_id=company.timbro_organization_id,
    environment=company.timbro_environment,
    source_connection_id=company.timbro_source_connection_id,
)
```

The organization and Source connection IDs come from the Source connection 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.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=, source_connection_id=)` | `webhooks.verify(...)`, then `webhooks.verify_invoice_scope(event, organization_id=, environment=, source_connection_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.
- Webhook envelopes are checked for the members each known type requires, not for UUID, date-time, or environment formats; `verify_invoice_scope` still compares the organization and Source connection 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.
