# Python SDK

> Install workwae, call the API with typed sync and asyncio clients, and use its pagination, error and webhook helpers.

URL: https://docs.workwae.com/guides/python-sdk

`workwae` is a typed Python client for every `/v1` operation, built from the same contract as the API. It has a sync and an asyncio client, and ships helpers for cursors, errors and webhook signatures. The TypeScript client is in [TypeScript SDK](/guides/sdk).

## Install

The SDK needs Python 3.10 or later. Its runtime dependencies are `httpx` and `pydantic` (v2).

```bash tab="pip"
pip install workwae
```

```bash tab="uv"
uv add workwae
```

## Make a call

```python
import os

from workwae import Workwae

workwae = Workwae(api_key=os.environ["WORKWAE_API_KEY"])

account = workwae.accounts.get(account_id=account_id)
print(account.email, account.status)
```

Every answer is a pydantic model validated against the contract, with snake_case fields and date-time fields as `datetime`. Every contract type (`Account`, `Message`, `Event`, ...) is in `workwae.types`, under the name the API reference uses. `EVENT_TYPES` lists every event type at runtime.

## Calling conventions

Methods are named after the operation ID in the [API reference](/reference), with each dot segment in snake_case: `accounts.get` is `workwae.accounts.get`, `calendarEvents.list` is `workwae.calendar_events.list`, `suppressions.entries.add` is `workwae.suppressions.entries.add`.

Path, query and body fields are keyword arguments, also in snake_case. A field named `from` is spelled `from_`.

```python
workwae.messages.list(account_id=account_id, mailbox_id=mailbox_id, unread=True, limit=50)
```

Nested objects take a model or a plain dict with the same snake_case keys. Each model `X` has an `XParam` TypedDict in `workwae.types` for it. The keys of a free-form map, such as custom headers, are sent as written.

```python
queued = workwae.outbox.create(
    account_id=account_id,
    to=[{"name": "Ada Lovelace", "address": "ada@example.com"}],
    subject="Hello",
    text="Sent with Workwae.",
)
```

Four DELETEs that take query options are keyword arguments like the rest, with no `params` and `query` split: `accounts.delete`, `messages.delete`, `calendar_events.delete` and `suppressions.delete`.

```python
workwae.accounts.delete(account_id=account_id, purge=True)
```

### Omitted and None

An argument left out is not sent. `None` is sent as `null`, which clears a nullable field:

```python
workwae.accounts.update(account_id=account_id, external_ref=None)
```

The default of every optional argument is `NOT_GIVEN`, exported from `workwae`.

### Binary answers

An operation without a JSON answer returns `None`. A raw message, an attachment, a contact photo or an export returns a `BinaryResponse` with `content`, `content_type` and `filename`.

## Client options

`Workwae` takes these options:

| Option | Meaning |
| --- | --- |
| `api_key` | An API key (`wk_...`) or an [account token](/guides/api-keys#account-tokens) (`wat_...`), or a function that returns one per request. Defaults to the `WORKWAE_API_KEY` environment variable. Public operations need none: `Workwae()`. |
| `base_url` | API origin. Defaults to `https://api.workwae.com`. Set it only to route traffic through your own proxy or gateway. |
| `headers` | Extra headers sent on every request. |
| `timeout` | Seconds, or an `httpx.Timeout`. Defaults to 60. |
| `max_retries` | Attempts after the first. See [Retries](#retries). Defaults to 2. |
| `max_retry_delay` | Longest single wait, in seconds. Defaults to 60. |
| `http_client` | Replaces the `httpx.Client` the SDK makes, for proxies, transports and tests. |

### Async client

`AsyncWorkwae` takes the same options, and its methods are coroutines. Its `api_key` may be an async function and its `http_client` an `httpx.AsyncClient`. Both clients close their HTTP client with `close()` or as a context manager.

```python
from workwae import AsyncWorkwae

async with AsyncWorkwae() as workwae:
    account = await workwae.accounts.get(account_id=account_id)
```

## Per-call options

`request_options` is a dict with these optional keys:

| Key | Meaning |
| --- | --- |
| `idempotency_key` | The `Idempotency-Key` of a POST. See [Idempotency keys](#idempotency). |
| `timeout` | Seconds, or an `httpx.Timeout`, for this call. |
| `headers` | Extra headers for this call. |

## Authentication

API keys are created in the console under **API keys**, with the scopes the integration needs (`read`, `write`, `send`, `admin`). They reach every account of the tenant, so keep them on your server. See [API keys](/guides/api-keys).

To let a browser or mobile app read one account directly, mint a short-lived [account token](/guides/api-keys#account-tokens) on your server and hand it over. It is limited to that one account:

```python
token = workwae.accounts.mint_token(account_id=account_id, ttl_seconds=900)
print(token.token, token.expires_at)
```

## Pagination helpers

A listing answers `items` and `next_cursor`. Pass `next_cursor` back as `cursor` until it is `None`, or let a helper do it. `paginate`, `pages` and `changes` take the list method itself, so its arguments and item type stay checked. For an `AsyncWorkwae` method they return an async iterator.

| Helper | Does |
| --- | --- |
| `paginate(list, **args)` | Yields every item, following `next_cursor` until a page whose cursor is `None`. |
| `pages(list, **args)` | Yields each page, so you can store `next_cursor` between pages. |
| `changes(list, **args)` | Reads the change feed until an empty page (caught up). `feed.cursor` is the resume point. |

Pass the method's arguments by keyword.

```python
from workwae import changes, pages, paginate

for message in paginate(workwae.messages.list, account_id=account_id, unread=True):
    print(message.subject)

for page in pages(workwae.accounts.list, limit=200):
    print(len(page.items), page.next_cursor)

feed = changes(workwae.changes.list, cursor=load_cursor())
for event in feed:
    handle(event)
save_cursor(feed.cursor)
```

With `AsyncWorkwae`, iterate with `async for`:

```python
async for message in paginate(workwae.messages.list, account_id=account_id):
    print(message.subject)
```

> **Warning: Use changes for the change feed**
>
> `paginate` and `pages` never end on `GET /v1/changes`: its `next_cursor` is never `None`. Use `changes`, which stops at the first empty page.

`feed.cursor` moves past a page only after its last event was consumed. Saving it after processing gives at-least-once delivery: stopping mid-page replays that page next time. See [Pagination](/guides/pagination) and [Change feed](/guides/changes).

## Errors

A refusal raises an `APIError`:

| Attribute | Meaning |
| --- | --- |
| `code` | The stable reason, such as `account_not_found`. `None` when the answer's body is not an API error. |
| `status` | The HTTP status. |
| `message` | Text for people. |
| `data` | The reason's details, for reasons that carry any. |
| `defined` | True when the operation declares the reason. |
| `request_id` | The answer's `X-Request-Id`, for support. `None` when the answer had none. |

```python
from workwae import APIError

try:
    workwae.outbox.create(account_id=account_id, to=[], text="hi")
except APIError as error:
    if error.code == "invalid_input":
        print(error.data["issues"])
    else:
        print(error.code, error.status, error.request_id)
        raise
```

`workwae.ErrorReason` is the union of every reason. A network failure is raised by httpx as it is (`httpx.HTTPError`). An answer that does not match its model raises `pydantic.ValidationError`.

Every reason and its status is in [Errors](/guides/errors).

## Retries

The client retries an answer of 429 or 503, which the API sends before acting on a request, so a retry cannot apply it twice. Every attempt of a POST carries the same `Idempotency-Key`.

- The wait is the answer's `Retry-After` when it has one. Otherwise it is an exponential backoff with jitter.
- If `Retry-After` is longer than `max_retry_delay`, the client does not wait: it raises the error.
- Any other status, or the last attempt's answer, is returned or raised as is.

```python
workwae = Workwae(max_retries=4, max_retry_delay=30)
```

[Rate limits](/guides/rate-limits) lists the limits behind 429.

## Idempotency keys

Every POST is sent with an `Idempotency-Key`, generated per call and reused across the client's own retries, so a retried send never goes out twice. To make a call safe to repeat across process restarts, such as a job queue retrying a send, pass a key of your own derived from your data:

```python
workwae.outbox.create(
    account_id=account_id,
    to=[{"address": "ada@example.com"}],
    subject="Invoice 1042",
    text="...",
    request_options={"idempotency_key": "invoice-1042"},
)
```

How the API treats keys is in [Idempotency](/guides/idempotency).

## Webhooks

`parse_webhook(secret, headers, body)` checks the Standard Webhooks signature and returns the event, typed by `type`, with its date-times as `datetime`. Pass the raw request body exactly as received. `headers` is any mapping in any key case: a dict, or the request headers of Django, Flask, Starlette or httpx.

- A type this SDK knows narrows on `event.type`.
- A type added after this SDK version is an `UnknownEvent`, and its `data` is passed on unchecked.
- A failed check raises `WebhookVerificationError`. Its `failure` is `missing_headers`, `timestamp`, `signature` or `payload` (signed, but not a valid event).

```python
import os

from workwae import UnknownEvent, WebhookVerificationError, parse_webhook

@app.post("/webhooks/workwae")
async def receive(request: Request) -> Response:
    body = await request.body()
    try:
        event = parse_webhook(os.environ["WORKWAE_WEBHOOK_SECRET"], request.headers, body)
    except WebhookVerificationError:
        return Response(status_code=400)
    if isinstance(event, UnknownEvent):
        return Response(status_code=204)
    if event.type == "send.failed":
        print(event.data.outbox_id, event.data.error.code)
    return Response(status_code=204)
```

To check only the signature, `verify_webhook` returns a boolean and `check_webhook_signature` returns the failure, or `None` when the signature is valid. All three take `tolerance_seconds` (default `WEBHOOK_TOLERANCE_SECONDS`, 300). See [Webhook signatures](/guides/webhook-signatures).

## Types

Every model of the API is in `workwae.types`, under the name the OpenAPI document gives it:

```python
from workwae.types import Account, CalendarEvent, Event, EventType, Message, OutboxEntry
```

The SDK is versioned with semver. While it is 0.x, a minor version may carry breaking changes, which the [changelog](/changelog) lists.
