# Python SDK reference

This page lists the options and behaviour of each part of the [Python SDK](/docs/sdk/python). Start with the SDK page to install it and moderate your first content.

## Configuration

`CalmLensServerClient(...)` takes these keyword arguments:

| Argument             | Default                          | Meaning                                                                                                                                                                              |
| -------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `api_key`            | `CALMLENS_API_KEY`               | Project API key.                                                                                                                                                                     |
| `project_id`         | `CALMLENS_PROJECT_ID`            | Project ID.                                                                                                                                                                          |
| `base_url`           | `https://api.calmlens.com/v1`    | API endpoint.                                                                                                                                                                        |
| `cdn_base_url`       | `https://cdn.calmlens.com/v1`    | CDN endpoint.                                                                                                                                                                        |
| `timeout`            | `120`                            | Seconds a connection may stay silent before `sdk/timeout`. It limits inactivity, not the whole transfer, so large uploads and downloads are not cut off.                             |
| `upload_timeout`     | `None`                           | The `timeout` of binary uploads (file content and multipart parts); `None` uses `timeout`. Text uploads always use `timeout`.                                                        |
| `connect_timeout`    | `10`                             | Seconds to open a connection, including the proxy `CONNECT` and the TLS handshake.                                                                                                   |
| `max_retries`        | `2`                              | Retries for requests that are safe to resend (see [Retries and idempotency](#retries-and-idempotency)); `0` disables them.                                                           |
| `max_response_bytes` | `33554432` (32 MiB)              | The largest API response body the client reads. A larger body, or a larger declared `Content-Length` before reading, raises `sdk/response-too-large`. CDN downloads are not limited. |
| `transport`          | Pooled `http.client` connections | A `transport(request, timeout)` callable returning a context-managed response with `status`, `headers` and `read()`.                                                                 |

Invalid options raise `CalmLensValidationError` with `sdk/invalid-config` before the client creates any state. `client.with_options(timeout=..., upload_timeout=..., max_retries=..., max_response_bytes=...)` returns a client that shares the connections and lens cache with other settings, such as `client.with_options(timeout=300).cdn.serve(...)`; options you leave out keep the original client's values.

### Client lifecycle

Reuse one client: it keeps HTTP connections open between calls and is thread-safe. Close its connection pool with `client.close()`, or use it as a context manager (`with CalmLensServerClient() as client:`). A custom `transport` stays yours to close. After `os.fork()`, as in gunicorn, Celery or `multiprocessing` workers, the child opens its own connections instead of sharing the parent's sockets.

Redirects are not followed, so the API key is only sent to the configured endpoint: a `3xx` response raises `sdk/http-error` with its status and request ID, and is not retried, including for `cdn.serve`. `repr(client)` never includes the API key.

API requests send `Accept: application/json`, and every request sends `User-Agent: calmlens-python/<version> (<implementation>/<Python version>)`, such as `calmlens-python/0.18.0 (cpython/3.12.4)`. The SDK version is `calmlens.__version__`.

### Proxies

The default transport follows `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY`, including `user:password@` credentials in the proxy URL. HTTPS requests tunnel through the proxy with `CONNECT`, so TLS runs end to end to CalmLens. Point `HTTPS_PROXY` at the proxy's `http://` address: an `https://` proxy URL is not supported for HTTPS requests and raises `sdk/invalid-config`.

## Arguments and responses

Each method takes snake\_case keyword arguments, or one options dict with the API's camelCase keys, but not both:

```python
client.assets.retrieve(asset_id=asset_id)
client.assets.retrieve({"assetId": asset_id})
```

With keyword arguments, the keys of nested dicts are converted to camelCase too, except inside `metadata` values and `transform` options, which are sent as written. An options dict is sent as given, so write its nested keys in camelCase.

Responses are plain dicts with the API's camelCase field names, typed as `TypedDict`s in `calmlens.types`. Fields added in a newer API version stay in the dict, and enum values such as `classification.moderation.status`, `classification.status`, `type` and `visibility` are plain strings, so a value this SDK version doesn't list never fails. Treat a moderation status you don't recognise as not allowed.

## Errors

Every failure raises `CalmLensError` or a subclass, with a stable `code`. The constants in `calmlens.error_codes` are generated from the API's error codes.

| Exception                  | Raised when                                                                                                         | `code`                                                                                                                                                                                                                                                                                      | `status`    |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `CalmLensAPIError`         | The API answers with a non-2xx response.                                                                            | The API's code, such as [`404 asset/not-found`](/docs/api/assets#upload-asset-part-error-asset-not-found); `sdk/http-error` without an error body.                                                                                                                                          | HTTP status |
| `CalmLensTimeoutError`     | Connecting or waiting for data timed out.                                                                           | `sdk/timeout`                                                                                                                                                                                                                                                                               | `None`      |
| `CalmLensTransportError`   | Any other network or protocol failure: refused, reset, DNS, TLS or a truncated response.                            | `sdk/transport-error`                                                                                                                                                                                                                                                                       | `None`      |
| `CalmLensValidationError`  | Arguments or configuration are invalid, before anything is sent. It is also a `ValueError`.                         | `sdk/invalid-argument`, `sdk/invalid-config`, or the API's code for the same problem, such as [`400 upload/invalid-payload`](/docs/api/assets#create-asset-error-upload-invalid-payload) or [`400 upload/invalid-mime-type`](/docs/api/assets#create-asset-error-upload-invalid-mime-type). | `None`      |
| `CalmLensError`            | A 2xx response isn't the JSON the operation returns, or is larger than `max_response_bytes`.                        | `sdk/invalid-response`, `sdk/response-too-large`                                                                                                                                                                                                                                            | HTTP status |
| `CalmLensError`            | An iterator received a page that says more follow, with a cursor it had already sent; `details["cursor"]` holds it. | `sdk/invalid-response`                                                                                                                                                                                                                                                                      | `None`      |
| `CalmLensError`            | `assets.wait_for_decision` saw no decision within its `timeout`.                                                    | `sdk/timeout`                                                                                                                                                                                                                                                                               | `None`      |
| `WebhookVerificationError` | A webhook delivery fails verification or isn't a CalmLens event.                                                    | `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload`                                                                                                                                                                                                                              | `None`      |

Each error also has:

| Attribute      | Value                                                                                                                                                                                                                                                                                             |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message`      | A readable description.                                                                                                                                                                                                                                                                           |
| `details`      | The API error body's `details` object, such as validation issues; empty for transport errors.                                                                                                                                                                                                     |
| `request_id`   | The response's `X-Request-Id`, or the error body's `requestId` without one. Quote it to support.                                                                                                                                                                                                  |
| `headers`      | The response headers, with lower-case names.                                                                                                                                                                                                                                                      |
| `retry_after`  | Whole seconds from `Retry-After`, sent in seconds or as an HTTP date.                                                                                                                                                                                                                             |
| `is_not_found` | Whether the resource doesn't exist.                                                                                                                                                                                                                                                               |
| `is_retryable` | Depends only on `code` and `status`, so every error class agrees: `True` for `sdk/transport-error`, `sdk/timeout` (including `wait_for_decision`'s), [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress) and HTTP 429, 502, 503 and 504. |

Transport errors keep the native exception, such as `ConnectionResetError`, `ssl.SSLError`, `http.client.IncompleteRead` or `TimeoutError`, as `__cause__`.

## Retries and idempotency

The client retries a request only when sending it again can't duplicate work: `GET` requests, and uploads (`assets.create`, `assets.classify`, `text.classify` and `assets.upload_part`), which always carry an `Idempotency-Key`. It retries network failures, timeouts, `429`, `502`, `503`, `504` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress), up to `max_retries` times. Retry `n`, counting from 0, waits between half and all of `min(8, 0.5 × 2ⁿ)` seconds, or the `Retry-After` delay when the response sends one. `Retry-After: 0` retries at once, and a `Retry-After` over 60 seconds raises the error instead.

Other writes are never retried: `update`, `move`, `retry`, `explain`, `decide`, `batch`, deletes, folder and hash list changes, signed URLs and lens definitions. `cdn.serve` retries network failures, timeouts and `429`, `502`, `503` and `504` the same way, and returns the last response once retries run out.

Each upload call generates one `Idempotency-Key` and reuses it for its own retries. The API replays the saved response for 24 hours instead of creating a second asset. To retry from another process, pass `idempotency_key=`, such as a key derived from your own record ID. Reusing a key for a different request raises [`409 idempotency/key-reused`](/docs/api/assets#create-asset-error-idempotency-key-reused). A file body is rewound for a retry when it can seek; uploads from streams that can't seek are not retried. See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely).

## Assets

```python
page = client.assets.list(page_size=20, ordering=[{"field": "createdAt", "direction": "desc"}])
for asset in client.assets.iter(filter="classification.moderation.status==allowed"):  # requests pages lazily
    print(asset["id"])
children = client.assets.list_children(asset_id=asset_id)
client.assets.update(asset_id=asset_id, description=None, tags=["reviewed"])
client.assets.move(asset_id=asset_id, folder_id=folder_id)
client.assets.move(asset_id=asset_id, folder_id=None)  # project root
client.assets.retry(asset_id=failed_asset_id)  # a failed asset whose content is still held
client.assets.delete_content(asset_id=asset_id)  # keeps the record and report
client.assets.delete(asset_id=asset_id)  # returns None
client.assets.batch(asset_ids=[first_id, second_id], operation="remove-content")  # returns {"queued": True}
```

- List pagination starts with no `cursor`; see [Pagination](/docs/sdk/python#pagination).
- `assets.decide(asset_id=..., decision="blocked", note="Spam")` records a reviewer's decision. Purging also needs `confirm_purge=True`.
- `assets.explain(asset_id=..., rerun=True)` moderates the asset again under the current policy; without `rerun` it returns the policy evidence.
- `assets.batch` takes up to 500 asset IDs and the operation `"delete"` or `"remove-content"`, and runs in the background once queued.
- `assets.create_signed_url(asset_id=..., ...)` signs the `rendered` resource for 900 seconds unless you pass `resource` and `expires_in_seconds` (1 to 86,400). It takes the same `lens`, `transform` and `viewer_claims` as the [CDN methods](#cdn).

### Waiting for a decision

`assets.wait_for_decision(asset_id=..., timeout=60)` retrieves the asset after 0.5 seconds, then waits 1.5 times longer each time, up to 5 seconds between checks and never past `timeout`. It returns once `classification.moderation.status` is no longer `pending` or `classification.status` is `failed`. Without a decision in time it raises `CalmLensError` with `sdk/timeout` and `details` `{"assetId": ..., "classificationStatus": ..., "moderationStatus": ...}`, the last statuses it saw. It takes no cancellation argument and blocks the calling thread for at most `timeout` seconds plus one request, so pass a shorter `timeout` to stop sooner. Webhooks report decisions without polling.

### Asset fields

Asset methods return the asset as a dict typed `calmlens.types.Asset`, such as `asset["externalId"]` and `asset["classification"]["moderation"]["status"]`. `id`, `projectId`, `name`, `type`, `createdAt`, `upload`, `classification` and `content` are always present. Fields that don't apply inside `classification` and `content` are `None`. `description`, `externalId`, `externalUrl`, `folderId`, `parentId`, `metadata` and `tags` may be missing or `None`, so read them with `.get()`.

See [Reading classification results](/docs/guides/classification-results) for the report and [Asset lifecycle](/docs/guides/asset-lifecycle) for upload, processing and content statuses, failure reasons and removal reasons.

## Uploads

`assets.create` and `assets.classify` take exactly one of `content`, `url` or `part`. `text.classify(text=...)` sends UTF-8 `text/plain` content. A `content` or `part` payload has exactly one of:

| Key                        | Value                                                                                                                   |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `plainText` (`plain_text`) | A string, sent as UTF-8.                                                                                                |
| `base64`                   | Base64 or a data URL.                                                                                                   |
| `data`                     | `bytes`, `bytearray`, `memoryview`, or a binary file object, such as `open(path, "rb")`, `io.BytesIO` or a socket file. |

File objects are streamed in chunks, never read into memory. The size is measured for bytes and for files that can seek, from the current position. A stream that can't seek, such as a pipe, socket or generator wrapped in a reader, is sent to its end with chunked transfer encoding unless you pass `sizeInBytes`; multipart parts from such streams need `sizeInBytes`. Exactly `sizeInBytes` bytes are sent, and a stream that ends early raises [`400 upload/invalid-payload`](/docs/api/assets#create-asset-error-upload-invalid-payload). Files opened in text mode are rejected. Give a concrete MIME `type`, such as `image/jpeg`, on the first request.

Raw uploads carry the asset's other fields and processing options in the query string; uploads from a `url` send JSON.

### Multipart uploads

```python
upload = client.assets.create(
    name="large.png",
    total_size_in_bytes=12 * 1024 * 1024,  # optional: checked before any part is stored
    part={"type": "image/png", "data": first_part},  # at least 5 MiB
)
completed = client.assets.upload_part(
    asset_id=upload["id"],
    part={"data": final_part, "partNumber": 2, "isFinal": True},
)
```

Send parts in order; every part but the last is at least 5 MiB. Multipart image uploads total 10 to 20 MiB; smaller images go in one request. Split the bytes before base64-encoding each part. The API saves whether the upload started with `create` (queued) or `classify` (wait for the decision) and applies it to the final part, from any client or process; pass `queue=` to `upload_part` only to override it. With `total_size_in_bytes`, parts that don't add up to the declared size raise [`400 upload/size-mismatch`](/docs/api/assets#upload-asset-part-error-upload-size-mismatch). See [Multipart uploads](/docs/guides/multipart-uploads).

### Replies in a conversation

For a reply, pass who wrote it and the earlier posts in the conversation, oldest first. CalmLens moderates only the reply, and uses the earlier posts to understand who it addresses and what it refers to. `author_id` is your own label for a person; CalmLens only compares it with the author IDs of the earlier posts.

```python
asset = client.text.classify(
    text="Same to you",
    author_id="user-9",
    context={"items": [
        {"text": "You're an idiot", "authorId": "user-1"},
        {"text": "Calm down, both of you"},
    ]},
)
```

`assets.create` and `assets.classify` take the same options. The SDK sends only the posts CalmLens uses: the 5 most recent that fit within 2,000 characters, always including the most recent one. `select_moderation_context_items(items)` applies the same rule. The asset's `authorId` and `context` hold what was used, and `context` becomes `None` once the content is removed. See [Threaded moderation](/docs/guides/threaded-moderation).

## Upload tokens

```python
signed = client.upload_tokens.sign(
    upload_preset_id="preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
    external_id="post-42",  # optional: the uploaded asset gets this external ID
    expires_in_seconds=600,  # optional: 1 to 3600, 600 by default
)
# signed["token"], signed["expiresAt"] (milliseconds since the Unix epoch)
```

Signing happens locally with your API key and makes no request. See [Upload presets](/docs/guides/upload-presets).

## Folders

`folders.list()` returns a cursor page with `items`, the visible `ancestors`, `nextCursor`, `hasMore`, `pageSize` and `ordering`. Set `parent_id` to open a branch, or omit it for the project root. `page_size` is 1 to 100 (default 50), and `ordering` takes one `{"field": "name" | "createdAt", "direction": "asc" | "desc"}` entry (by name, ascending, by default). `search` keeps the children whose names contain it. `folders.move(folder_id=..., parent_id=None)` moves a folder to the root. See [Organising assets](/docs/guides/organising-assets).

## Hash lists

A `"flag"` list reports image uploads that match its entries, and a `"block"` list also blocks close matches. Add a PDQ hash, or the hash of an image asset in the project:

```python
removed = client.hash_lists.create(name="Removed images", action="block")
client.hash_lists.add_entry(hash_list_id=removed["id"], asset_id=asset_id, label="Report 1042")
entries = client.hash_lists.list_entries(hash_list_id=removed["id"], page_size=50)  # entries["nextCursor"]
```

`hash_lists.list(cursor=..., page_size=...)` returns one page, newest first. `hash_lists.delete` and `hash_lists.remove_entry` return `None`. See [Hash lists](/docs/guides/hash-lists).

## Lenses

```python
client.ensure_lens(identifier="product-thumbnail", name="Product thumbnail", type="image",
                   active=True, version=1, transforms=[{"type": "imageTransform", "options": {"width": 128}}])
signed = client.assets.create_signed_url(asset_id=asset_id, resource="rendered", lens="product-thumbnail")
```

`ensure_lens` and `ensure_lenses` cache each definition by its full content and version, and concurrent calls for the same definition share one request. A new version replaces the cached definition for that identifier. Version conflicts from the API, such as [`409 lens/version-conflict`](/docs/api/lenses#ensure-lenses-error-lens-version-conflict), are raised. See [Lenses and transforms](/docs/guides/lenses).

## CDN

```python
url = client.cdn.get_asset_url(asset_identifier=asset_id, lens="thumbnail")
with client.cdn.serve(asset_identifier=asset_id) as response:
    if response.ok:
        with open("copy.jpg", "wb") as out:
            for chunk in response.iter_bytes():  # streamed
                out.write(chunk)
```

`resource` is `rendered` (the default), `original` or `placeholder`. Only `rendered` takes a `lens`, a `transform` or both; the lens applies first. A `transform` is a selector string or one or more structured steps in the shape of a lens's `transforms`, and both forms also work for `assets.create_signed_url`:

```python
client.cdn.get_asset_url(asset_identifier=asset_id, transform="format=png,width=1024")
client.cdn.get_asset_url(asset_identifier=asset_id,
                         transform={"type": "imageTransform", "options": {"format": "png", "width": 1024}})
```

A selector has one path segment per step, such as `fit=cover,width=640/format=webp`, and a lens and a transform are joined with `/` (`/rendered/@thumbnail/width=320`). The SDK sorts structured options, writes nested options dotted (`border.color`), drops the `#` from hex colours and percent-encodes values, so both calls above return the same URL. The `redact` option (`{"text": True, "personalInformation": True}`, or `redact.text=true` in a selector) blurs the text regions OCR found when the image was processed; see [Text regions and redaction](/docs/guides/ocr#text-regions-and-redaction).

`cdn.serve` returns a `CDNResponse` even for non-2xx responses, after retrying the retryable ones. It has `status`, `ok`, `headers`, `content_type`, `request_id`, `read()`, `iter_bytes()` and `close()`; check `response.ok` and close it, or use it as a context manager. Use [signed URLs](/docs/guides/delivering-assets#signed-urls) for private content in browsers.

### Viewer claims

The CDN methods and `assets.create_signed_url` take `viewer_claims` with `region` and `ageConfirmed`. An asset whose moderation status is `age-restricted` is served only with `viewer_claims={"ageConfirmed": True}`, even with a signed URL or your API key; otherwise the CDN answers `403` with [`403 viewer/age-confirmation-required`](/docs/api/cdn#error-viewer-age-confirmation-required). Set it only where your app has already decided the viewer may see age-restricted content:

```python
client.cdn.get_asset_url(asset_identifier=asset_id, lens="thumbnail", viewer_claims={"ageConfirmed": True})
```

`get_asset_url` adds the claims to the URL as a JSON `viewerClaims` query parameter, and a signed URL includes the claims it was created with, so forward it unchanged. See [Viewer claims](/docs/api/cdn#viewer-claims) and [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets).

## Webhooks

`parse_webhook_event` and `verify_webhook_signature` accept only the raw `str` or `bytes` body and raise `TypeError` for anything else. A body that was parsed and serialised again rarely has the same bytes (Python writes `1e-07` where the sender wrote `1e-7`), so its signature would not match.

| Argument                 | Meaning                                                                                                                                                                                                    |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `headers`                | The request headers: a mapping, an `email.message.Message` or `http.client.HTTPMessage`, or a framework's header object. Names match in any case, and a header repeated in the request is joined with `,`. |
| `signature`, `timestamp` | The `x-calmlens-signature` and `x-calmlens-timestamp` values, in place of `headers`.                                                                                                                       |
| `secret`                 | The connector's signing secret.                                                                                                                                                                            |
| `tolerance_seconds`      | How far the timestamp may be from `now`: `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS` (300) by default.                                                                                                           |
| `now`                    | The current time in Unix seconds, for tests; the system clock by default.                                                                                                                                  |

A signature list may hold several comma-separated `v1=` entries, and any matching entry is accepted. `parse_webhook_event` returns a `WebhookEventPayload` dict, the event exactly as `events.list` returns it. It raises `WebhookVerificationError` with `sdk/invalid-webhook-signature` for a missing, wrong or stale signature, and with `sdk/invalid-webhook-payload` when the verified body is not a JSON object with string `id`, `type` and `projectId`, a numeric `createdAt`, and the API's types for its other known fields (each fact needs a string `key` and a `value`). Unknown fields, event types and fact keys are kept.

The header names are exported as constants:

| Constant                   | Header                 | Value                                                                                       |
| -------------------------- | ---------------------- | ------------------------------------------------------------------------------------------- |
| `WEBHOOK_SIGNATURE_HEADER` | `x-calmlens-signature` | `v1=` and the hex HMAC-SHA256 of `<timestamp>.<raw body>`, possibly a comma-separated list. |
| `WEBHOOK_TIMESTAMP_HEADER` | `x-calmlens-timestamp` | Unix seconds used in the signature.                                                         |
| `WEBHOOK_DELIVERY_HEADER`  | `x-calmlens-delivery`  | The delivery ID, the same across a delivery's retries.                                      |

To sign deliveries in your own tests, use `create_webhook_headers(payload, delivery_id, secret, timestamp=None)` or `create_webhook_signature_header(payload, timestamp, secret)`:

```python
from calmlens import create_webhook_headers, verify_webhook_signature

body = b'{"id":"evt_1","type":"asset.allowed"}'
headers = create_webhook_headers(body, "delivery-1", webhook_secret)
assert verify_webhook_signature(body, headers=headers, secret=webhook_secret)
```

Moderation decisions arrive as [`asset.allowed`](/docs/api/events#event-asset-allowed), [`asset.limited`](/docs/api/events#event-asset-limited), [`asset.age-restricted`](/docs/api/events#event-asset-age-restricted), [`asset.blocked`](/docs/api/events#event-asset-blocked) and [`asset.purged`](/docs/api/events#event-asset-purged) events, and [`asset.classified`](/docs/api/events#event-asset-classified) reports each finished classification. The event's `asset.classification.moderation.status` holds the status. See [Webhooks](/docs/guides/webhooks).

## Events feed

`events.list` reads the project's event feed, the same events webhooks deliver. It returns an `EventsPage` dict, `{"items": [...], "nextCursor": str, "hasMore": bool}`. Each event has `id`, `projectId`, `type`, `severity`, `title`, `description`, `assetId`, `asset`, `facts` (a list of `{key, value, url?}`) and `createdAt`; `description`, `assetId` and `asset` are `None` when the event has none. `to_webhook_event_payload(event)` builds the webhook body for an event.

- Without `cursor`, it returns no events and a `nextCursor` for now. Save that cursor: the feed returns only events created after the cursor you pass.
- With the previous `nextCursor` as `cursor`, it returns the next events, oldest first, at most `page_size` (1 to 100, default 50). `nextCursor` is never `None`: with no new events, the same cursor comes back.
- `hasMore` is `True` when more events are already waiting. An invalid cursor raises [`400 validation/invalid-cursor`](/docs/api/assets#list-assets-error-validation-invalid-cursor).

`events.iter_pages(cursor=...)` follows `nextCursor` until a page reports `hasMore` `False`, and `events.iter(cursor=...)` yields the events of those pages. To keep polling, save the last cursor and call them again:

```python
import time

cursor = client.events.list()["nextCursor"]  # start from now; no events
while True:
    for page in client.events.iter_pages(cursor=cursor, page_size=50):
        for event in page["items"]:
            handle(event)
        cursor = page["nextCursor"]  # persist to resume after a restart
    time.sleep(5)
```

See [Events](/docs/api/events) for event types and facts.
