Metadata-Version: 2.5
Name: invoicestudio
Version: 1.0.0
Summary: Official Python SDK for the InvoiceStudio API.
Project-URL: Homepage, https://invoicestudio.co/developers
Project-URL: Documentation, https://invoicestudio.co/developers
Project-URL: Source, https://github.com/claudiuwork/invoice-generator
Project-URL: Issues, https://invoicestudio.co/contact
License: Proprietary
Keywords: api,invoice,invoicestudio,pdf,ubl
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Accounting
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: pytest; extra == 'dev'
Description-Content-Type: text/markdown

# invoicestudio

Official Python SDK for the [InvoiceStudio API](https://invoicestudio.co/developers).

Zero dependencies (standard library only), Python 3.9+.

## Install

```bash
pip install invoicestudio
```

## Quickstart

```python
import os
from invoicestudio import InvoiceStudio

client = InvoiceStudio(os.environ["INVOICESTUDIO_API_KEY"])

invoice = client.invoices.create({
    "version": 1,
    "type": "invoice",
    "number": "INV-1001",
    "issueDate": "2026-09-01",
    "dueDate": "2026-09-15",
    "currency": "EUR",
    "from": "Acme GmbH\nHauptstrasse 1\n10115 Berlin",
    "to": "Globex Ltd\n5 King Street\nLondon",
    "items": [{"name": "Consulting", "quantity": 10, "unitCostCents": 12000}],
    "tax": {"title": "VAT", "kind": "percent", "value": 19},
})

with open("invoice.pdf", "wb") as fh:
    fh.write(client.invoices.pdf(invoice["id"]))

shared = client.invoices.share(invoice["id"])
print(shared["share_url"])  # https://invoicestudio.co/s/…
```

`from` is a Python keyword, so the document body is a plain `dict` rather than
keyword arguments. Resources are returned as dicts too; `invoicestudio.types`
carries `TypedDict`s (`Invoice`, `InvoiceInput`, `Account`, …) for type checkers.

### Client options

```python
InvoiceStudio(
    api_key,
    base_url="https://invoicestudio.co/api/v1",  # default; a trailing slash is fine
    max_retries=2,                               # default
    timeout=30.0,                                # seconds, per attempt
    transport=None,                              # inject your own HTTP transport
    sleep=None,                                  # default: time.sleep
    random=None,                                 # default: random.uniform (backoff jitter)
)
```

`timeout` is applied to each attempt separately, so a request that is retried twice
can take up to `3 × timeout` in the worst case. Pass `timeout=0` or `timeout=None` to
disable it and block indefinitely.

A `transport` is `(method, url, headers, body, timeout) -> (status, headers, body)`,
with lower-cased response header keys — useful for tests, proxies, or plugging in
`requests`/`httpx`. Four-argument transports (without `timeout`) still work.

The client keeps no mutable state after construction, so one instance can be shared
across threads.

## API

| Method | Returns |
| --- | --- |
| `client.me()` | `Account` — tier and this month's document usage |
| `client.invoices.create(data, idempotency_key=None)` | `Invoice` |
| `client.invoices.retrieve(id)` | `Invoice` |
| `client.invoices.list(limit=20, starting_after=None)` | `List` page |
| `client.invoices.delete(id)` | `Deleted` |
| `client.invoices.pdf(id)` | `bytes` |
| `client.invoices.ubl(id)` | `str` (UBL 2.1 XML) |
| `client.invoices.share(id)` | `Invoice` with `share_url` set |
| `client.invoices.iterate(limit=100)` | iterator over every `Invoice` |
| `client.webhooks.create(url, events)` | `WebhookEndpoint` incl. `secret` — shown once |
| `client.webhooks.list()` | `List` page |
| `client.webhooks.retrieve(id)` | `WebhookEndpoint` |
| `client.webhooks.delete(id)` | `Deleted` |

## Error handling

Any non-2xx response (after retries) raises an `InvoiceStudioError` built from the API's
error envelope.

```python
from invoicestudio import InvoiceStudioError

try:
    client.invoices.create(data)
except InvoiceStudioError as err:
    err.status      # 400
    err.code        # 'invalid_request' — also: unauthorized, forbidden, not_found,
                    #   rate_limited, quota_exceeded, idempotency_conflict, internal
    err.type        # 'invalid_request_error'
    err.param       # 'items[0].quantity' when the error is field-specific
    err.request_id  # 'req_…' — quote this in support requests
    raise
```

A network failure that survives every retry raises the same error with
`status == 0` and `code == "internal"`.

## Pagination

`invoices.list()` is cursor-paged, newest first. `invoices.iterate()` walks every page.

```python
for invoice in client.invoices.iterate(limit=100):
    print(invoice["number"], invoice["total_cents"])
```

Or page manually:

```python
page = client.invoices.list(limit=50)
while page["has_more"]:
    page = client.invoices.list(limit=50, starting_after=page["next_cursor"])
```

## Retries and idempotency

* `invoices.create()` always sends an `Idempotency-Key` (a UUID v4 when you do not supply
  one), and reuses the same key across the SDK's own retries — a retried create can never
  produce a second invoice. Pass your own with `idempotency_key=` to make retries safe
  across process restarts too.
* Requests are retried on `429` and on `5xx` / network errors, up to `max_retries`
  (default 2). A `Retry-After` header is honoured on both `429` and `5xx` responses —
  delta-seconds or an HTTP-date, capped at 30 s. Without one, the delay backs off
  0.5 s × 2ⁿ with ±20% jitter so retrying clients do not resynchronize.
* Only genuine transport failures are retried; an exception raised by a custom
  `transport` for any other reason propagates unchanged.
* `webhooks.create()` has no idempotency key, so it is never retried after a network
  failure — only after a `429`, which provably never reached the handler.

## Webhook verification

Deliveries carry an `InvoiceStudio-Signature: t=<unix-seconds>,v1=<hex>` header, where
`v1` is HMAC-SHA256 of `"<t>.<raw body>"` keyed with the endpoint secret (`whsec_…`).
Verify against the **raw** body, before JSON parsing; the tolerance is 300 seconds.

### Flask

```python
import os
from flask import Flask, request
from invoicestudio import InvoiceStudioError, construct_event

app = Flask(__name__)

@app.post("/hooks/invoicestudio")
def hook():
    try:
        event = construct_event(
            os.environ["INVOICESTUDIO_WEBHOOK_SECRET"],
            request.headers.get("InvoiceStudio-Signature", ""),
            request.get_data(),  # raw bytes, before parsing
        )
    except InvoiceStudioError:
        return "bad signature", 400

    if event["type"] in ("invoice.created", "invoice.shared", "invoice.viewed", "invoice.deleted"):
        print(event["type"], event["data"]["object"]["id"])
    return "", 200  # any 2xx marks the delivery successful
```

### FastAPI

```python
import os
from fastapi import FastAPI, Header, Request, Response
from invoicestudio import InvoiceStudioError, construct_event

app = FastAPI()

@app.post("/hooks/invoicestudio")
async def hook(request: Request, invoicestudio_signature: str = Header(default="")):
    body = await request.body()  # raw bytes, before parsing
    try:
        event = construct_event(
            os.environ["INVOICESTUDIO_WEBHOOK_SECRET"], invoicestudio_signature, body
        )
    except InvoiceStudioError:
        return Response("bad signature", status_code=400)

    print(event["type"], event["data"]["object"]["id"])
    return Response(status_code=204)
```

`verify_signature(secret, header, body, tolerance=300)` returns a boolean if you would
rather parse the body yourself; it never raises, not even on a malformed header.

Prefer passing the body as **bytes** (`request.get_data()`, `await request.body()`).
A `str` is encoded as UTF-8 before hashing, which matches the API for well-formed
payloads, but decoding and re-encoding through your framework can normalize the bytes
and silently break the signature.

A payload over 256 KB arrives truncated (`event["data"]["truncated"] is True`) with only
`{"id", "object"}` in `data.object` — re-fetch the resource with `invoices.retrieve(id)`.
Failed deliveries are retried after 1m, 5m, 30m, 2h and 12h (6 attempts total), so handle
events idempotently by `event["id"]`.

## Development

```bash
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest -q
```

## Documentation

<https://invoicestudio.co/developers>
