---
title: "openemail.webhooks"
description: "Every method in this namespace: its signature, its parameters, what it returns and an example."
url: "https://openemail.uk/docs/python/reference/webhooks"
area: "Python"
category: "Reference"
---

# openemail.webhooks

Every method in this namespace: its signature, its parameters, what it returns and an example.

## Methods

Endpoints that receive signed mailbox events, their secrets and their delivery log.

### `webhooks.list()`

List the webhook endpoints in the workspace

```python
def list(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[WebhookResource]
```

Returns one page of the webhook endpoints registered on the key's workspace, newest first. `list_all` collects every page and `iterate` walks them lazily.

Signing secrets are never part of a read. Only `create` and `rotate_secret` return `secret`, so a lost secret cannot be recovered from here. An endpoint subscribed to every event, which is what an endpoint created without `eventTypes` is, reports `eventTypes` as `['*']` rather than an empty list.

Read `enabled`, `disabledReason` and `consecutiveFailures` as the health summary. An endpoint the server switched off after 100 consecutive failed deliveries shows `enabled` as `False` with `disabledAt` and a reason, while one you disabled yourself through `update` has both of those set to `None`.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size, from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` of the previous page. Leave it out for the first page.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[WebhookResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `url`, `description`, `eventTypes`, `enabled`, `disabledAt`, `disabledReason`, `consecutiveFailures`, `addressAllowlist`, `domainAllowlist`, `lastDeliveryAt` and `createdAt`.

**Example**

```python
from openemail import openemail

page = openemail.webhooks.list(limit=50)

for hook in page['items']:
    if not hook['enabled'] or hook['consecutiveFailures'] > 0:
        print(hook['url'], hook['consecutiveFailures'], hook['disabledReason'])

print('More to read:', page['hasMore'])
```

**Notes**

- A narrowed key reads every endpoint, and may write one whose own `addressAllowlist` and `domainAllowlist` sit inside what the key holds. A write that would take an endpoint wider than the key is 422 `capability_unsupported` on `addressAllowlist`.
- `lastDeliveryAt` moves on failed attempts as well as successful ones, so it shows the endpoint is being called, not that it is healthy.
- The cursor is opaque and holds where the last row sat in this order, so a row deleted or edited between pages never breaks the walk: the next page starts at the first row that sorts after it. A cursor this list did not hand out is a 400 `invalid_cursor`.

Also available in: API [`GET /webhooks`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks); TypeScript [`webhooks.list()`](https://openemail.uk/docs/sdk/reference/webhooks#list); Ruby [`webhooks.list`](https://openemail.uk/docs/ruby/reference/webhooks#list); CLI [`openemail webhooks list`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-list).

### `webhooks.list_all()`

Collect every webhook endpoint into one list

```python
def list_all(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[WebhookResource]
```

Walks every page of `list` and returns all webhook endpoints in one list, newest first. One request per page.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size for each request, from 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk after this cursor instead of the first page.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[WebhookResource]` holding every webhook endpoint.

**Example**

```python
from openemail import openemail

hooks = openemail.webhooks.list_all(limit=100)

unhealthy = [hook for hook in hooks if not hook['enabled'] or hook['consecutiveFailures']]

print(len(hooks), 'endpoints,', len(unhealthy), 'need attention')
```

**Notes**

- If any page fails, the call raises and the webhook endpoints already fetched are discarded.

Also available in: API [`GET /webhooks`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks); TypeScript [`webhooks.listAll()`](https://openemail.uk/docs/sdk/reference/webhooks#listAll); Ruby [`webhooks.list_all`](https://openemail.uk/docs/ruby/reference/webhooks#listAll).

### `webhooks.iterate()`

Stream the webhook endpoints one at a time

```python
def iterate(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[WebhookResource]
```

Returns a generator that yields webhook endpoints individually, newest first, and requests the next page only once the current one is drained. Nothing is fetched until you loop over it, and breaking out of the loop stops the requests.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size for each request, from 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk after this cursor instead of the first page.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[WebhookResource]`, a generator yielding one webhook endpoint per step.

**Example**

```python
from openemail import openemail

for hook in openemail.webhooks.iterate():
    print(hook['id'], hook['url'], hook['eventTypes'])
```

**Notes**

- The generator is lazy, so an abandoned loop costs only the pages you consumed.

Also available in: API [`GET /webhooks`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks); TypeScript [`webhooks.iterate()`](https://openemail.uk/docs/sdk/reference/webhooks#iterate); Ruby [`webhooks.iterate`](https://openemail.uk/docs/ruby/reference/webhooks#iterate).

### `webhooks.get()`

Read one webhook endpoint by id

```python
def get(
    id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookResource
```

Returns a single endpoint with its URL, subscription list, enabled state and delivery health. The signing secret is never part of a read. It appears only in the responses of `create` and `rotate_secret`, and a lost one is replaced with `rotate_secret` rather than read back.

An id from another workspace answers exactly like one that never existed, with 404 `resource_not_found`, so a 404 does not tell you whether the endpoint was deleted or was never yours.

`consecutiveFailures` resets to 0 on any successful delivery and when `update` sets `enabled` to `True`. When it reaches 100 the server disables the endpoint, fills in `disabledAt` and `disabledReason`, and emails the workspace owner and every member whose role can read webhooks.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookResource` with `id`, `url`, `description`, `eventTypes`, `enabled`, `disabledAt`, `disabledReason`, `consecutiveFailures`, `addressAllowlist`, `domainAllowlist`, `lastDeliveryAt` and `createdAt`. No secret.

**Example**

```python
from openemail import openemail

hook = openemail.webhooks.get('whe_3f9c2a7b1e4d8f60a5c7b92d')

if not hook['enabled'] and hook['disabledReason']:
    print(hook['disabledAt'], hook['disabledReason'])
```

**Notes**

- `eventTypes` of `['*']` means the endpoint named none, so it receives the default `email.*` set. `email.replied`, `domain.*`, `suppression.*`, `file.*` and `form.*` sit outside that set and have to be named explicitly.
- A GET is retried automatically on network failure, on 408 and 5xx responses, and on a 429 that carries `Retry-After`, up to the client's `max_retries`.

Also available in: API [`GET /webhooks/{id}`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id); TypeScript [`webhooks.get()`](https://openemail.uk/docs/sdk/reference/webhooks#get); Ruby [`webhooks.get`](https://openemail.uk/docs/ruby/reference/webhooks#get); CLI [`openemail webhooks get`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-get).

### `webhooks.create()`

Register an HTTPS endpoint for mailbox events

```python
def create(
    body: WebhookCreate,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> CreatedWebhookResource
```

Registers a receiver URL and subscribes it to events on the key's workspace. The endpoint starts enabled, so the next matching event is delivered to it straight away. Leave `eventTypes` out, or pass an empty list, and the endpoint receives the default set, which is the `email.*` events other than `email.replied`. Every family outside it has to be named: `email.replied`, `domain.*`, `suppression.*`, `file.*` and `form.*`. Reads report an unnamed subscription as `['*']`.

By default an endpoint hears about every address the workspace owns. `addressAllowlist` and `domainAllowlist` narrow it, exactly as the same two lists narrow an API key: name whole domains, single addresses, or both. An event reaches the endpoint when the address or domain it concerns is covered. Events that name no address at all, such as `suppression.removed`, reach every endpoint whatever its lists say. `form.*` is the exception: a sign-up belongs to the whole workspace, so an endpoint limited to some addresses or domains never receives `form.submitted` or `form.confirmed`.

This response is the only place the full `secret` ever appears. Every later read omits it, so store it before doing anything else, and if it is lost call `rotate_secret`. The secret is `whsec_` followed by 43 base64url characters, and the HMAC key is the whole string including the prefix, so pass it to `verify_webhook_signature` exactly as returned.

`url` must be https. `localhost`, hosts ending `.localhost`, `.internal` or `.local`, and IP literals in loopback, private, link local, carrier grade NAT, multicast or unique local ranges are refused with 422 `invalid_webhook_url`. The host is resolved again on every delivery, and an attempt to a name that resolves into one of those ranges is recorded as failed without being sent. Deliveries never follow redirects, so register the final address.

Scopes: `webhooks:write`.

**Parameters**

- `body['url']` (`str`, required): The https receiver URL. Another scheme or a blocked host is 422 `invalid_webhook_url`. Stored in normalised form, so the `url` read back can differ cosmetically.
- `body['eventTypes']` (`list[WebhookEvent]`): Events to subscribe to. Omit it, or send `[]`, for the default `email.*` set: `email.received`, `email.sent`, `email.failed`, `email.cancelled`, `email.scheduled`, `email.queued`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.suppressed`, `email.opened`, `email.clicked` and `email.downloaded`. `email.replied`, `domain.verified`, `domain.sending_changed`, `domain.deleted`, `suppression.added`, `suppression.removed`, `file.uploaded`, `file.deleted`, `form.submitted` and `form.confirmed` are outside that set and have to be named.
- `body['description']` (`str`): Free text note, at most 200 characters.
- `body['addressAllowlist']` (`list[str]`): Single addresses this endpoint hears about. An event is delivered when the address it concerns is on this list, or when its domain is in `domainAllowlist`. Leave both empty and the endpoint hears about every address the workspace owns. At most 50, and an address this workspace does not own is 422 `invalid_parameter`.
- `body['domainAllowlist']` (`list[str]`): Whole domains this endpoint hears about, including addresses added to them later. A domain also carries its own `domain.*` events. At most 25. An address whose domain is already listed here is dropped from `addressAllowlist` when the endpoint is saved, so the two lists never overlap.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`CreatedWebhookResource`, a `WebhookResource` plus the plaintext `secret`. Alongside it you get `id`, `url`, `description`, `eventTypes`, `enabled`, `disabledAt`, `disabledReason`, `consecutiveFailures`, `addressAllowlist`, `domainAllowlist`, `lastDeliveryAt` and `createdAt`.

**Example**

```python
from openemail import openemail

hook = openemail.webhooks.create(
    {
        'url': 'https://hooks.acme.com/openemail',
        'eventTypes': ['email.received', 'email.bounced', 'email.complained'],
        'description': 'Support desk sync',
    }
)

print('Store this now, it is never shown again:', hook['secret'])
print(hook['id'], hook['eventTypes'])
```

**Notes**

- Verify each delivery with `verify_webhook_signature(payload=..., headers=..., secret=...)` from `openemail`, passing the raw request body as bytes or text. It checks the `X-OpenEmail-Signature` HMAC-SHA256 over the timestamp, a dot and the raw body in constant time, rejects a timestamp more than 300 seconds off unless `tolerance_seconds=` says otherwise, raises `WebhookVerificationError` on failure and returns the event as a dict of `id`, `type`, `createdAt` and `data`.
- Each attempt is one POST with a 5 second timeout. A failure worth repeating (no answer, 408, 425, 429 or a 5xx) is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, up to 8 attempts over about 27 and a half hours, each wait varied by up to 10% and stretched when your server's `Retry-After` asks for longer, up to 6 hours. A 410 Gone switches the endpoint off. `list_deliveries` shows every attempt with its number, and `replay_delivery` sends one of them again, one event at a time, once your receiver is fixed.
- A workspace holds 10 endpoints by default, and support can raise that for a workspace that needs more. The next one past the limit is 422 `workspace_limit_reached`. A narrowed key may create an endpoint, but only one whose own lists sit inside what the key holds. Anything wider is 422 `capability_unsupported` on `addressAllowlist`.
- Not retried automatically, so a network failure can leave an endpoint created with a secret you never saw. Check `list` before creating it again.

Also available in: API [`POST /webhooks`](https://openemail.uk/docs/api/reference/webhooks#post-webhooks); TypeScript [`webhooks.create()`](https://openemail.uk/docs/sdk/reference/webhooks#create); Ruby [`webhooks.create`](https://openemail.uk/docs/ruby/reference/webhooks#create); CLI [`openemail webhooks create`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-create).

### `webhooks.update()`

Change an endpoint URL, events or enabled state

```python
def update(
    id: str,
    patch: WebhookPatch,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookResource
```

Patches the URL, subscription list, description or enabled flag of one endpoint. Every field is optional, and an empty patch is accepted and changes nothing you can read back. A new `url` goes through the same https and host checks as `create`, and a `description` of `None` clears the note.

`eventTypes` replaces the subscription set wholesale, so send the complete list you want rather than a delta. An empty list does not unsubscribe: it puts the endpoint back on the default `email.*` set. To stop deliveries, set `enabled` to `False` instead.

`addressAllowlist` and `domainAllowlist` replace the endpoint's scope the same way, wholesale rather than as a delta. Send both empty to widen it back to every address the workspace owns. Each list is checked against the domains and addresses this workspace actually owns, and an unknown one is 422 `invalid_parameter`.

Writing `enabled` clears `disabledAt` and `disabledReason` either way, so an endpoint you switch off yourself reports both as `None`. Setting it back to `True` also resets `consecutiveFailures` to 0, which is how an endpoint the server disabled after 100 failures is brought back. Events that fire while an endpoint is disabled are never delivered to it later, but a delivery that failed before it was switched off can be sent again with `replay_delivery` once it is back on, one event at a time.

Scopes: `webhooks:write`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `patch['url']` (`str`): Replacement https URL, checked against the same host rules as `create`.
- `patch['eventTypes']` (`list[WebhookEvent]`): Complete replacement subscription set. `[]` means the default `email.*` set.
- `patch['addressAllowlist']` (`list[str]`): Complete replacement list of single addresses this endpoint hears about. At most 50. Send `[]` on both lists to hear about every address again.
- `patch['domainAllowlist']` (`list[str]`): Complete replacement list of whole domains this endpoint hears about, including addresses added to them later. At most 25.
- `patch['description']` (`str | None`): Replacement note of at most 200 characters, or `None` to clear it.
- `patch['enabled']` (`bool`): `False` stops deliveries. `True` resumes them and resets `consecutiveFailures`.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookResource` as saved. The signing secret is untouched and not included.

**Example**

```python
from openemail import openemail

hook = openemail.webhooks.update(
    'whe_3f9c2a7b1e4d8f60a5c7b92d',
    {'eventTypes': ['email.sent', 'email.bounced', 'email.complained'], 'enabled': True},
)

print(hook['eventTypes'], hook['consecutiveFailures'])
```

**Notes**

- Read the current `eventTypes` first if you mean to add one, since a partial list silently unsubscribes the rest. The same applies to the two allowlists. Do not send back `['*']` as read: it is a 422 `invalid_parameter`, and `[]` is how to ask for the default set.
- This route never touches the signing secret. Use `rotate_secret` for that.
- Retried automatically on network failure and retryable statuses, since the same patch applied twice lands on the same row.

Also available in: API [`PATCH /webhooks/{id}`](https://openemail.uk/docs/api/reference/webhooks#patch-webhooks-id); TypeScript [`webhooks.update()`](https://openemail.uk/docs/sdk/reference/webhooks#update); Ruby [`webhooks.update`](https://openemail.uk/docs/ruby/reference/webhooks#update); CLI [`openemail webhooks update`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-update).

### `webhooks.delete()`

Delete an endpoint and its delivery log

```python
def delete(
    id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> DeletedWebhookResource
```

Removes the endpoint for good. Deliveries stop immediately, the signing secret is gone, and the delivery log for the endpoint is deleted with it, so read it with `list_all_deliveries` first if you need it for an audit trail.

There is no undo and no soft delete. If the aim is only to pause deliveries, call `update` with `{'enabled': False}` and keep the endpoint, its secret and its log.

The response is a tombstone rather than an empty body, so a log line can name what went. Deleting frees a slot against the workspace's endpoint limit straight away.

Scopes: `webhooks:write`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`DeletedWebhookResource` with `object` set to `webhook`, the `id` and `deleted` set to `True`.

**Example**

```python
import json
from pathlib import Path

from openemail import openemail

log = openemail.webhooks.list_all_deliveries('whe_3f9c2a7b1e4d8f60a5c7b92d')
Path('whe_3f9c2a7b1e4d8f60a5c7b92d.json').write_text(json.dumps(log))

removed = openemail.webhooks.delete('whe_3f9c2a7b1e4d8f60a5c7b92d')

print(removed['id'], removed['deleted'], len(log), 'attempts kept')
```

**Notes**

- Not idempotent: a second call on the same id is 404 `resource_not_found`, and the SDK does not retry it after a network failure.
- A narrowed key may delete only an endpoint whose own allowlists sit inside what the key holds. Any other is 422 `capability_unsupported` on `addressAllowlist`.

Also available in: API [`DELETE /webhooks/{id}`](https://openemail.uk/docs/api/reference/webhooks#delete-webhooks-id); TypeScript [`webhooks.delete()`](https://openemail.uk/docs/sdk/reference/webhooks#delete); Ruby [`webhooks.delete`](https://openemail.uk/docs/ruby/reference/webhooks#delete); CLI [`openemail webhooks delete`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-delete).

### `webhooks.rotate_secret()`

Issue a new signing secret for an endpoint

```python
def rotate_secret(
    id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> CreatedWebhookResource
```

Generates a new signing secret and returns the endpoint plus the new plaintext `secret`. As with `create`, this response is the only place that secret appears, so store it before anything else.

There is no overlap window. Every delivery is signed at send time with the current secret, so the old one stops verifying the moment this call commits, and any event that fires before your receiver has the new secret fails verification on your side.

The safe order is to deploy a receiver that tries both the old secret and a new one read from config, call this, write the returned secret to config, then drop the old one. `test` confirms the new secret verifies before you remove the fallback.

Scopes: `webhooks:write`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`CreatedWebhookResource`: the full `WebhookResource` plus the new `secret`, formatted `whsec_` followed by 43 base64url characters.

**Example**

```python
from openemail import openemail

rotated = openemail.webhooks.rotate_secret('whe_3f9c2a7b1e4d8f60a5c7b92d')

print('New secret:', rotated['secret'])

delivery = openemail.webhooks.test(rotated['id'])['delivery']

print(delivery['status'] if delivery else 'nothing recorded')
```

**Notes**

- There is no request body and the success status is 200, not 201.
- Not retried automatically. A lost response means a secret you never saw is already live, so rotate again rather than waiting.
- Subscriptions, enabled state and `consecutiveFailures` are left as they were.

Also available in: API [`POST /webhooks/{id}/rotate-secret`](https://openemail.uk/docs/api/reference/webhooks#post-webhooks-id-rotate-secret); TypeScript [`webhooks.rotateSecret()`](https://openemail.uk/docs/sdk/reference/webhooks#rotateSecret); Ruby [`webhooks.rotate_secret`](https://openemail.uk/docs/ruby/reference/webhooks#rotateSecret); CLI [`openemail webhooks rotate-secret`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-rotate-secret).

### `webhooks.test()`

Send a synthetic event and report how delivery went

```python
def test(
    id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookTestResource
```

Posts a signed synthetic `email.sent` event to the endpoint and waits for the attempt to finish before returning. The payload `data` is a dict with `test` set to `True` and a `note`, and no mail is sent, so it is safe against a production receiver. It proves the URL is reachable and that your signature check accepts the current secret before real mail depends on it.

The outcome comes back as `delivery`, read from the newest row of the delivery log. `status` is `delivered` for any 2xx answer and `failed` otherwise, `responseCode` is the HTTP status or `None` when no response arrived, such as a DNS failure or the 5 second timeout, and `error` explains a failure. A 3xx counts as failed because redirects are never followed.

The event goes out whatever the endpoint subscribes to, and even when it is disabled. It is recorded like any delivery, so it appears in `list_deliveries` and can be sent again with `replay_delivery`, but it is tried once and leaves `consecutiveFailures` and `lastDeliveryAt` alone, so a failing test never counts toward the 100 that disable an endpoint. A 410 Gone answer does switch the endpoint off, as it would for a real event.

Scopes: `webhooks:write`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookTestResource` with `object` set to `webhook_test`, the endpoint `id`, and `delivery` holding `status`, `responseCode`, `durationMs` and `error`, or `None` when no delivery row could be read back.

**Example**

```python
from openemail import openemail

result = openemail.webhooks.test('whe_3f9c2a7b1e4d8f60a5c7b92d')
delivery = result['delivery']

if delivery is None:
    print('No delivery was recorded')
elif delivery['status'] != 'delivered':
    print(delivery['responseCode'], delivery['error'])
```

**Notes**

- The call returns normally, with a 200, when your receiver fails. Branch on the `status` in `delivery`, not on whether the call raised.
- A 4xx from your receiver is a useful answer: the URL is reachable and the rejection came from your own handler, often its signature check.
- Not retried automatically, since every call sends another request to your receiver.
- If a real event reaches the same endpoint at the same moment, `delivery` can describe that attempt instead, because it reads the newest log row.

Also available in: API [`POST /webhooks/{id}/test`](https://openemail.uk/docs/api/reference/webhooks#post-webhooks-id-test); TypeScript [`webhooks.test()`](https://openemail.uk/docs/sdk/reference/webhooks#test); Ruby [`webhooks.test`](https://openemail.uk/docs/ruby/reference/webhooks#test); CLI [`openemail webhooks test`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-test).

### `webhooks.list_deliveries()`

List one page of delivery attempts for one endpoint

```python
def list_deliveries(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    status: WebhookDeliveryStatus | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[WebhookDeliveryResource]
```

Returns one page of an endpoint's delivery log, newest first. Nothing is dropped from the log, so following `nextCursor` while `hasMore` is `True` reaches the endpoint's very first delivery, and `list_all_deliveries` and `iterate_deliveries` do that walk for you. `status=`, `since=` and `until=` narrow it the way the Deliveries tab of the app does: `status='failed'` is its "only failed" switch.

Each attempt is one POST with a 5 second timeout, and one event can appear several times: when the failure is worth repeating, a delivery is tried up to 8 times, as it happens and then after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, about 27 and a half hours in all, and each `replay_delivery` adds a row of its own. `attempt` and `maxAttempts` say which try a row is, and `eventId` is the same across all of them, so this log distinguishes a retry from a new event. `nextAttemptAt` is when the automatic retry that follows a row is due, and `None` when none is waiting, so a failed row with a time in it is not the final word. `status` is `delivered` for a 2xx answer and `failed` for anything else, including a 3xx, since redirects are not followed.

A `responseCode` of `None` means no response arrived, such as a DNS or TLS failure or the timeout, which is a different fact from a receiver that answered. `durationMs` is `None` only when the attempt was never made because the server could not read the signing secret, and `error` then says so. The body that was sent and your server's answer are not part of this response, and `get_delivery` returns both. `list_workspace_deliveries` reads every endpoint at once.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `limit` (`int`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` from the previous page, passed back unchanged. Never build one yourself.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `status` (`WebhookDeliveryStatus`): `failed` keeps only the attempts that did not get a 2xx, the "only failed" view of the app. `delivered` keeps the rest.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[WebhookDeliveryResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `endpointId`, `eventType`, `eventId`, `status`, `responseCode`, `durationMs`, `attempt`, `maxAttempts`, `error`, `createdAt` and `nextAttemptAt`.

**Example**

```python
from datetime import datetime, timedelta, timezone

from openemail import openemail

page = openemail.webhooks.list_deliveries(
    'whe_3f9c2a7b1e4d8f60a5c7b92d',
    status='failed',
    since=datetime.now(timezone.utc) - timedelta(days=1),
    limit=50,
)

for delivery in page['items']:
    print(delivery['eventType'], delivery['responseCode'], delivery['error'])

print(page['hasMore'], page['nextCursor'])
```

**Notes**

- Synthetic events from `test` appear here too, recorded as `email.sent`.
- Each endpoint's copy of an event gets its own `evt_` id, sent in the `X-OpenEmail-Delivery` header and as the payload `id`, and every retry and replay of that copy keeps it. One event fanned out to two endpoints arrives with two different ids, and a repeat to one endpoint arrives with the same one.
- The cursor stays valid under every filter as long as each page sends the same `status=`, `since=` and `until=`, which `list_all_deliveries` and `iterate_deliveries` do for you.
- A 404 `resource_not_found` means the endpoint id is wrong or belongs to another workspace, not that the log is empty. A cursor that names no delivery of this endpoint is a 400 `invalid_cursor`, and a `since` or `until` that does not parse is a 400 `invalid_parameter`.

Also available in: API [`GET /webhooks/{id}/deliveries`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-deliveries); TypeScript [`webhooks.listDeliveries()`](https://openemail.uk/docs/sdk/reference/webhooks#listDeliveries); Ruby [`webhooks.list_deliveries`](https://openemail.uk/docs/ruby/reference/webhooks#listDeliveries); CLI [`openemail webhooks list-deliveries`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-list-deliveries).

### `webhooks.list_all_deliveries()`

Collect an endpoint's whole delivery log into one list

```python
def list_all_deliveries(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    status: WebhookDeliveryStatus | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[WebhookDeliveryResource]
```

Walks every page of an endpoint's delivery log and returns all of its attempts in one list, newest first, under the same `status=`, `since=` and `until=` filters as `list_deliveries`. The log is never pruned, so an endpoint that has been busy for a long time can hold a great many rows. Narrow it with a window, or prefer `iterate_deliveries` when you can stop early.

Pages are keyset on `createdAt` and `id`, walking backwards in time. Attempts recorded after the walk starts are newer than its first page and are not included.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `limit` (`int`): Page size for each request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `status` (`WebhookDeliveryStatus`): `failed` keeps only the attempts that did not get a 2xx, the "only failed" view of the app. `delivered` keeps the rest.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[WebhookDeliveryResource]` holding every matching attempt on the endpoint, newest first.

**Example**

```python
from openemail import openemail

failures = openemail.webhooks.list_all_deliveries(
    'whe_3f9c2a7b1e4d8f60a5c7b92d', status='failed', limit=100
)

failed_events = {delivery['eventId'] for delivery in failures}

print(len(failed_events), 'events had a failed attempt')
```

**Notes**

- If any page fails, the call raises and the attempts already fetched are discarded.
- One request per page, so a large log takes many requests. Read it with `iterate_deliveries` to stop as soon as you have what you need.

Also available in: API [`GET /webhooks/{id}/deliveries`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-deliveries); TypeScript [`webhooks.listAllDeliveries()`](https://openemail.uk/docs/sdk/reference/webhooks#listAllDeliveries); Ruby [`webhooks.list_all_deliveries`](https://openemail.uk/docs/ruby/reference/webhooks#listAllDeliveries).

### `webhooks.iterate_deliveries()`

Stream an endpoint's delivery attempts one at a time, newest first

```python
def iterate_deliveries(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    status: WebhookDeliveryStatus | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[WebhookDeliveryResource]
```

Returns a generator over an endpoint's delivery log that yields attempts individually and fetches the next page only when the current one is drained, under the same `status=`, `since=` and `until=` filters as `list_deliveries`. Nothing is requested until you loop over it, and breaking out of the loop stops further requests, which makes it the right way to find the latest attempt of some kind without reading the whole history.

The walk ends when `hasMore` is `False`, when a page comes back empty, or when the server repeats a cursor. Attempts recorded after the walk starts are newer than its cursor and are not yielded.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `limit` (`int`): Page size per request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `status` (`WebhookDeliveryStatus`): `failed` keeps only the attempts that did not get a 2xx, the "only failed" view of the app. `delivered` keeps the rest.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[WebhookDeliveryResource]`, a generator yielding one attempt per step.

**Example**

```python
from openemail import openemail

for delivery in openemail.webhooks.iterate_deliveries(
    'whe_3f9c2a7b1e4d8f60a5c7b92d', status='failed'
):
    if delivery['nextAttemptAt'] is None:
        print('Gave up:', delivery['createdAt'], delivery['eventType'], delivery['error'])
        break
```

**Notes**

- `timeout=` bounds each page request on its own rather than the whole walk, and a page that fails raises out of the `for` loop.

Also available in: API [`GET /webhooks/{id}/deliveries`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-deliveries); TypeScript [`webhooks.iterateDeliveries()`](https://openemail.uk/docs/sdk/reference/webhooks#iterateDeliveries); Ruby [`webhooks.iterate_deliveries`](https://openemail.uk/docs/ruby/reference/webhooks#iterateDeliveries).

### `webhooks.get_delivery()`

Read one delivery attempt in full, with the body that was sent

```python
def get_delivery(
    id: str,
    delivery_id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookDeliveryDetailResource
```

Returns one attempt from an endpoint's delivery log with what `list_deliveries` leaves out. `payload` is the exact JSON body that was POSTed, a dict of `id`, `type`, `createdAt` and `data`, and `responseBody` is the first 2,000 characters your server answered, or `None` when nothing came back or the answer was a redirect.

`attempts` lists every try of the same event on this endpoint, oldest first: the first attempt, the automatic retries and any replays, each with its own `id`, `attempt`, `status`, `responseCode`, `error` and `createdAt`. They share `eventId`, which is the payload `id` your receiver saw. `nextAttemptAt` is when the next automatic retry of the event is due, whichever attempt it follows, and `None` when none is waiting.

`replayRefusal` says whether `replay_delivery` would accept this attempt before you call it. It is `None` when it would, and otherwise holds the `code` and `message` the replay would be refused with, such as `webhook_disabled` while the endpoint is switched off, or `retry_in_progress` while an automatic retry of the same event is being sent.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `delivery_id` (`str`, required): Delivery id, `whd_` followed by 24 hex characters, as `list_deliveries` returns it.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookDeliveryDetailResource`: the `WebhookDeliveryResource` fields (`id`, `eventType`, `eventId`, `status`, `responseCode`, `durationMs`, `attempt`, `maxAttempts`, `error`, `createdAt`, `nextAttemptAt`) plus `endpointId`, `payload`, `responseBody`, `attempts` and `replayRefusal`.

**Example**

```python
from openemail import openemail

detail = openemail.webhooks.get_delivery(
    'whe_3f9c2a7b1e4d8f60a5c7b92d', 'whd_8c1e4a7f2b9d3e6a0c5f1b28'
)

print(detail['eventType'], detail['responseCode'], detail['responseBody'])

for attempt in detail['attempts']:
    print(attempt['attempt'], attempt['status'], attempt['responseCode'])

refusal = detail['replayRefusal']

if refusal is not None:
    print('A replay would be refused:', refusal['code'], refusal['message'])
```

**Notes**

- A delivery id that belongs to another endpoint, even one in the same workspace, is 404 `resource_not_found`, exactly like one that never existed.
- Your server's answer is cut at 2,000 characters when it is recorded, so a longer one reads back truncated.
- A GET is retried automatically on network failure, on 408 and 5xx responses, and on a 429 that carries `Retry-After`, up to the client's `max_retries`.
- A narrowed key may read a delivery only on an endpoint whose own allowlists sit inside what the key holds, where holding one address never covers its whole domain, because the body names the addresses the event is about. Any other is 422 `capability_unsupported` on `addressAllowlist`. Over OAuth only the workspace owner, or a member whose role holds `addresses:all`, may read one, and any other member's token is 403 `owner_only`. That member's token is held to the domains the workspace has now, so it reads only a delivery of an endpoint with allowlists, and one with none is 422 `capability_unsupported` for it too.

Also available in: API [`GET /webhooks/{id}/deliveries/{deliveryId}`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-deliveries-deliveryid); TypeScript [`webhooks.getDelivery()`](https://openemail.uk/docs/sdk/reference/webhooks#getDelivery); Ruby [`webhooks.get_delivery`](https://openemail.uk/docs/ruby/reference/webhooks#getDelivery); CLI [`openemail webhooks get-delivery`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-get-delivery).

### `webhooks.replay_delivery()`

Send one stored event to the endpoint again, now

```python
def replay_delivery(
    id: str,
    delivery_id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookReplayResource
```

Posts the event behind a recorded attempt to the endpoint once more, straight away, and returns when that attempt has finished. The body is the one stored with the original, with the same `id`, `type`, `createdAt` and `data`, so a receiver that drops ids it has already handled treats the replay as the event it already knows. Only `X-OpenEmail-Signature` is new, because every POST is signed with the current secret at the moment it goes out, so a replay verifies after a `rotate_secret` too.

It accepts a delivered attempt as well as a failed one. Replaying a success is how a receiver that lost its copy, or handled it wrongly, is brought back in step. Any attempt of the event will do, since they all carry the same event.

The replay is recorded in the log as a new delivery, attempt 1 of 1, and is never retried automatically. Before it goes out, the automatic retries of the same event that have not started are paused, so the receiver never gets two copies at once. When the replay is delivered they stay cancelled, and when it fails they resume on their schedule. If an automatic retry of the event is being sent at that very moment, the replay sends nothing and is refused with 409 `retry_in_progress`, and if another replay of the same event is still being sent, it is refused with 409 `replay_in_progress`, so two copies never go out at once. The call returns normally, with a 200, whatever your server answered, so branch on the `status` in `delivery` rather than on whether the call raised.

Scopes: `webhooks:write`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `delivery_id` (`str`, required): Any attempt of the event to send again, `whd_` followed by 24 hex characters.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookReplayResource` with `object` set to `webhook_replay`, `id` (the new delivery), `endpointId`, `replayOf` (the attempt you named), `eventId`, `eventType`, and `delivery` holding `status`, `responseCode`, `durationMs` and `error`.

**Example**

```python
from openemail import OpenEmailApiError, openemail

try:
    replay = openemail.webhooks.replay_delivery(
        'whe_3f9c2a7b1e4d8f60a5c7b92d', 'whd_8c1e4a7f2b9d3e6a0c5f1b28'
    )
except OpenEmailApiError as error:
    if not error.is_conflict:
        raise

    print('Not replayed:', error.code)
else:
    print(replay['id'], replay['delivery']['status'], replay['delivery']['responseCode'])
```

**Notes**

- Refused with 409 when nobody who wants the event would receive it: `webhook_disabled` while the endpoint is switched off, `event_not_subscribed` when it no longer listens for the event type, `event_out_of_scope` when its allowlists no longer cover the address the event is about, `delivery_not_replayable` when the attempt has no stored event, `retry_in_progress` while an automatic retry of the same event is being sent, and `replay_in_progress` while another replay of it is. `is_conflict` on the error is `True` for all of them. Wait a few seconds and check `get_delivery` before replaying again, since that retry or replay may deliver it. `get_delivery` reports the same answer in advance as `replayRefusal`. A synthetic event from `test` replays whatever the endpoint subscribes to.
- Not retried automatically, because a retry after a lost response would send the event again. A receiver that drops repeated ids handles that safely, but the SDK does not assume yours does.
- A delivered replay resets `consecutiveFailures`, and a failed one does not add to it. A 410 Gone switches the endpoint off, as it does for an automatic delivery.
- A narrowed key may replay only on an endpoint whose own allowlists sit inside what the key holds. Any other is 422 `capability_unsupported` on `addressAllowlist`.

Also available in: API [`POST /webhooks/{id}/deliveries/{deliveryId}/replay`](https://openemail.uk/docs/api/reference/webhooks#post-webhooks-id-deliveries-deliveryid-replay); TypeScript [`webhooks.replayDelivery()`](https://openemail.uk/docs/sdk/reference/webhooks#replayDelivery); Ruby [`webhooks.replay_delivery`](https://openemail.uk/docs/ruby/reference/webhooks#replayDelivery); CLI [`openemail webhooks replay-delivery`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-replay-delivery).

### `webhooks.list_workspace_deliveries()`

List one page of delivery attempts across every endpoint

```python
def list_workspace_deliveries(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    status: WebhookDeliveryStatus | None = None,
    endpoint_ids: Sequence[str] | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[WebhookDeliveryResource]
```

Returns one page of the whole workspace's delivery log, newest first: the all-endpoints Deliveries tab of the app. Each row carries `endpointId`, so the endpoint an attempt went to is never lost. `endpoint_ids=` narrows it to some endpoints, and `status=`, `since=` and `until=` work exactly as they do on `list_deliveries`.

Nothing is pruned, so following `nextCursor` while `hasMore` is `True` reaches the workspace's first delivery, and `list_all_workspace_deliveries` and `iterate_workspace_deliveries` do that walk. The body that was sent is not here: read it with `get_delivery`, passing the row's `endpointId` and `id`.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` from the previous page, passed back unchanged. Never build one yourself.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `status` (`WebhookDeliveryStatus`): `failed` keeps only the attempts that did not get a 2xx, the "only failed" view of the app. `delivered` keeps the rest.
- `endpoint_ids` (`Sequence[str]`): Endpoint ids to read, at most 50, sent comma-separated. Left out, every endpoint in the workspace.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[WebhookDeliveryResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `endpointId`, `eventType`, `eventId`, `status`, `responseCode`, `durationMs`, `attempt`, `maxAttempts`, `error`, `createdAt` and `nextAttemptAt`.

**Example**

```python
from openemail import openemail

page = openemail.webhooks.list_workspace_deliveries(status='failed', limit=50)

for delivery in page['items']:
    print(delivery['endpointId'], delivery['eventType'], delivery['responseCode'])
```

**Notes**

- An endpoint id in `endpoint_ids=` that belongs to no endpoint here simply matches nothing. A removed endpoint takes its deliveries with it.
- The cursor stays valid under every filter as long as each page sends the same `endpoint_ids=`, `status=`, `since=` and `until=`, which `list_all_workspace_deliveries` and `iterate_workspace_deliveries` do for you.
- The list carries no payloads, so a key narrowed to some addresses may read it. Opening a delivery with `get_delivery` is where the narrowing applies.

Also available in: API [`GET /webhooks/deliveries`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-deliveries); TypeScript [`webhooks.listWorkspaceDeliveries()`](https://openemail.uk/docs/sdk/reference/webhooks#listWorkspaceDeliveries); Ruby [`webhooks.list_workspace_deliveries`](https://openemail.uk/docs/ruby/reference/webhooks#listWorkspaceDeliveries); CLI [`openemail webhooks list-workspace-deliveries`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-list-workspace-deliveries).

### `webhooks.list_all_workspace_deliveries()`

Collect the workspace's whole delivery log into one list

```python
def list_all_workspace_deliveries(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    status: WebhookDeliveryStatus | None = None,
    endpoint_ids: Sequence[str] | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[WebhookDeliveryResource]
```

Walks every page of `list_workspace_deliveries` under the same filters and returns every matching attempt in one list, newest first. The log is never pruned, so give it a `since=` or `endpoint_ids=` unless you mean to read all of it, or use `iterate_workspace_deliveries` to stop early.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size for each request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `status` (`WebhookDeliveryStatus`): `failed` keeps only the attempts that did not get a 2xx, the "only failed" view of the app. `delivered` keeps the rest.
- `endpoint_ids` (`Sequence[str]`): Endpoint ids to read, at most 50, sent comma-separated. Left out, every endpoint in the workspace.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[WebhookDeliveryResource]` holding every matching attempt, newest first.

**Example**

```python
from datetime import datetime, timedelta, timezone

from openemail import openemail

last_day = openemail.webhooks.list_all_workspace_deliveries(
    status='failed', since=datetime.now(timezone.utc) - timedelta(days=1), limit=100
)

print(len(last_day), 'failed attempts in the last day')
```

**Notes**

- If any page fails, the call raises and the attempts already fetched are discarded.

Also available in: API [`GET /webhooks/deliveries`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-deliveries); TypeScript [`webhooks.listAllWorkspaceDeliveries()`](https://openemail.uk/docs/sdk/reference/webhooks#listAllWorkspaceDeliveries); Ruby [`webhooks.list_all_workspace_deliveries`](https://openemail.uk/docs/ruby/reference/webhooks#listAllWorkspaceDeliveries).

### `webhooks.iterate_workspace_deliveries()`

Stream the workspace's delivery attempts one at a time, newest first

```python
def iterate_workspace_deliveries(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    status: WebhookDeliveryStatus | None = None,
    endpoint_ids: Sequence[str] | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[WebhookDeliveryResource]
```

A generator over `list_workspace_deliveries` under the same filters. It fetches a page only when the one before is drained and stops requesting when you break out of the loop.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size per request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `status` (`WebhookDeliveryStatus`): `failed` keeps only the attempts that did not get a 2xx, the "only failed" view of the app. `delivered` keeps the rest.
- `endpoint_ids` (`Sequence[str]`): Endpoint ids to read, at most 50, sent comma-separated. Left out, every endpoint in the workspace.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[WebhookDeliveryResource]`, a generator yielding one attempt per step.

**Example**

```python
from datetime import datetime, timedelta, timezone

from openemail import openemail

since = datetime.now(timezone.utc) - timedelta(hours=1)
failing = {
    delivery['endpointId']
    for delivery in openemail.webhooks.iterate_workspace_deliveries(
        status='failed', since=since
    )
}

print('Endpoints failing in the last hour:', sorted(failing))
```

**Notes**

- `timeout=` bounds each page request on its own rather than the whole walk, and a page that fails raises out of the `for` loop.

Also available in: API [`GET /webhooks/deliveries`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-deliveries); TypeScript [`webhooks.iterateWorkspaceDeliveries()`](https://openemail.uk/docs/sdk/reference/webhooks#iterateWorkspaceDeliveries); Ruby [`webhooks.iterate_workspace_deliveries`](https://openemail.uk/docs/ruby/reference/webhooks#iterateWorkspaceDeliveries).

### `webhooks.list_activity()`

List one page of what happened to one endpoint

```python
def list_activity(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[WebhookActivityResource]
```

Returns one page of an endpoint's audit log, newest first, the Activity tab of the endpoint in the app. Every change is a row: `created`, `updated`, `enabled`, `disabled`, `auto_disabled`, `secret_rotated`, `tested`, `replayed` and `removed`, whether it came from the app, from a key over the API, or from OpenEmail itself. `actor` says who, with `label` already formatted the way the app shows it: `@username` for a person, `API key <name>` for a key, and `actor` is `None` when OpenEmail made the change on its own, such as switching an endpoint off after 100 failed events in a row. `detail` carries what moved: the URL, `previousUrl` when it changed, the event types and allowlists an update set, or the status and response code a test or a replay got.

Nothing is pruned, and a removed endpoint keeps its history, so this answers for an endpoint that is gone too. `since=` and `until=` keep a window.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `limit` (`int`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` from the previous page, passed back unchanged. Never build one yourself.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[WebhookActivityResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `endpointId`, `endpointLabel`, `type`, `createdAt`, `actor` and `detail`.

**Example**

```python
from openemail import openemail

page = openemail.webhooks.list_activity('whe_3f9c2a7b1e4d8f60a5c7b92d')

for change in page['items']:
    actor = change['actor']

    print(change['createdAt'], change['type'], actor['label'] if actor else 'OpenEmail')
```

**Notes**

- A 404 means no endpoint and no history with that id exists in this workspace. The cursor is opaque: pass `nextCursor` back as it came, and one this list did not hand out is a 400 `invalid_cursor`.

Also available in: API [`GET /webhooks/{id}/activity`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-activity); TypeScript [`webhooks.listActivity()`](https://openemail.uk/docs/sdk/reference/webhooks#listActivity); Ruby [`webhooks.list_activity`](https://openemail.uk/docs/ruby/reference/webhooks#listActivity); CLI [`openemail webhooks list-activity`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-list-activity).

### `webhooks.list_all_activity()`

Collect one endpoint's whole audit log into one list

```python
def list_all_activity(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[WebhookActivityResource]
```

Walks every page of `list_activity` under the same window and returns every change in one list, newest first.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `limit` (`int`): Page size for each request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[WebhookActivityResource]`, newest first.

**Example**

```python
from openemail import openemail

history = openemail.webhooks.list_all_activity('whe_3f9c2a7b1e4d8f60a5c7b92d')

for change in history:
    actor = change['actor']

    if change['type'] == 'secret_rotated':
        print(change['createdAt'], actor['label'] if actor else 'OpenEmail')
```

**Notes**

- If any page fails, the call raises and the rows already fetched are discarded.

Also available in: API [`GET /webhooks/{id}/activity`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-activity); TypeScript [`webhooks.listAllActivity()`](https://openemail.uk/docs/sdk/reference/webhooks#listAllActivity); Ruby [`webhooks.list_all_activity`](https://openemail.uk/docs/ruby/reference/webhooks#listAllActivity).

### `webhooks.iterate_activity()`

Stream one endpoint's audit log one change at a time

```python
def iterate_activity(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[WebhookActivityResource]
```

A generator over `list_activity` under the same window, fetching a page only when the one before is drained.

Scopes: `webhooks:read`.

**Parameters**

- `id` (`str`, required): Endpoint id, `whe_` followed by 24 hex characters.
- `limit` (`int`): Page size per request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[WebhookActivityResource]`, a generator yielding one change per step.

**Example**

```python
from openemail import openemail

for change in openemail.webhooks.iterate_activity('whe_3f9c2a7b1e4d8f60a5c7b92d'):
    if change['type'] == 'auto_disabled':
        print('Switched off by OpenEmail at', change['createdAt'], change['detail'])
        break
```

Also available in: API [`GET /webhooks/{id}/activity`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-id-activity); TypeScript [`webhooks.iterateActivity()`](https://openemail.uk/docs/sdk/reference/webhooks#iterateActivity); Ruby [`webhooks.iterate_activity`](https://openemail.uk/docs/ruby/reference/webhooks#iterateActivity).

### `webhooks.list_workspace_activity()`

List one page of what happened to every endpoint

```python
def list_workspace_activity(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    endpoint_ids: Sequence[str] | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[WebhookActivityResource]
```

Returns one page of the whole workspace's webhook audit log, newest first, the Activity tab of Settings, Webhooks. Every change is a row: `created`, `updated`, `enabled`, `disabled`, `auto_disabled`, `secret_rotated`, `tested`, `replayed` and `removed`, whether it came from the app, from a key over the API, or from OpenEmail itself. `actor` says who, with `label` already formatted the way the app shows it: `@username` for a person, `API key <name>` for a key, and `actor` is `None` when OpenEmail made the change on its own, such as switching an endpoint off after 100 failed events in a row. `detail` carries what moved: the URL, `previousUrl` when it changed, the event types and allowlists an update set, or the status and response code a test or a replay got.

`endpoint_ids=` narrows it to some endpoints, removed ones included, and `since=` and `until=` keep a window.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Rows per page, a whole number from 1 to 100. The server defaults to 25.
- `cursor` (`str`): The `nextCursor` from the previous page, passed back unchanged. Never build one yourself.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `endpoint_ids` (`Sequence[str]`): Endpoint ids to read, at most 50, sent comma-separated. Left out, every endpoint in the workspace.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[WebhookActivityResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `endpointId`, `endpointLabel`, `type`, `createdAt`, `actor` and `detail`.

**Example**

```python
from openemail import openemail

page = openemail.webhooks.list_workspace_activity(since='2026-09-01T00:00:00Z')

for change in page['items']:
    actor = change['actor']

    print(change['endpointLabel'], change['type'], actor['label'] if actor else 'OpenEmail')
```

**Notes**

- The cursor is opaque: pass `nextCursor` back as it came. One this list did not hand out is a 400 `invalid_cursor`.

Also available in: API [`GET /webhooks/activity`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-activity); TypeScript [`webhooks.listWorkspaceActivity()`](https://openemail.uk/docs/sdk/reference/webhooks#listWorkspaceActivity); Ruby [`webhooks.list_workspace_activity`](https://openemail.uk/docs/ruby/reference/webhooks#listWorkspaceActivity); CLI [`openemail webhooks list-workspace-activity`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-list-workspace-activity).

### `webhooks.list_all_workspace_activity()`

Collect the whole webhook audit log into one list

```python
def list_all_workspace_activity(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    endpoint_ids: Sequence[str] | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[WebhookActivityResource]
```

Walks every page of `list_workspace_activity` under the same filters and returns every change in one list, newest first.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size for each request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `endpoint_ids` (`Sequence[str]`): Endpoint ids to read, at most 50, sent comma-separated. Left out, every endpoint in the workspace.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[WebhookActivityResource]`, newest first.

**Example**

```python
from datetime import datetime, timezone

from openemail import openemail

changes = openemail.webhooks.list_all_workspace_activity(
    since=datetime(2026, 9, 1, tzinfo=timezone.utc)
)

by_keys = [
    change for change in changes if change['actor'] and change['actor']['kind'] == 'apiKey'
]

print(len(by_keys), 'of', len(changes), 'changes were made by API keys')
```

**Notes**

- If any page fails, the call raises and the rows already fetched are discarded.

Also available in: API [`GET /webhooks/activity`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-activity); TypeScript [`webhooks.listAllWorkspaceActivity()`](https://openemail.uk/docs/sdk/reference/webhooks#listAllWorkspaceActivity); Ruby [`webhooks.list_all_workspace_activity`](https://openemail.uk/docs/ruby/reference/webhooks#listAllWorkspaceActivity).

### `webhooks.iterate_workspace_activity()`

Stream the whole webhook audit log one change at a time

```python
def iterate_workspace_activity(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    endpoint_ids: Sequence[str] | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[WebhookActivityResource]
```

A generator over `list_workspace_activity` under the same filters, fetching a page only when the one before is drained.

Scopes: `webhooks:read`.

**Parameters**

- `limit` (`int`): Page size per request, 1 to 100. The server defaults to 25.
- `cursor` (`str`): Starts the walk from this cursor instead of the newest row.
- `since` (`datetime | str`): Only rows at or after this instant, a `datetime` or an ISO 8601 string. A `datetime` is sent in UTC, and a naive one is read as local time.
- `until` (`datetime | str`): Only rows before this instant, a `datetime` or an ISO 8601 string. It has to be later than `since`, or the server answers 400 `invalid_parameter`.
- `endpoint_ids` (`Sequence[str]`): Endpoint ids to read, at most 50, sent comma-separated. Left out, every endpoint in the workspace.
- `api_key` (`str`): Overrides the client API key for every page of this walk.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[WebhookActivityResource]`, a generator yielding one change per step.

**Example**

```python
from openemail import openemail

for change in openemail.webhooks.iterate_workspace_activity():
    if change['type'] == 'removed':
        actor = change['actor']

        print(change['endpointLabel'], 'removed by', actor['label'] if actor else 'OpenEmail')
```

Also available in: API [`GET /webhooks/activity`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-activity); TypeScript [`webhooks.iterateWorkspaceActivity()`](https://openemail.uk/docs/sdk/reference/webhooks#iterateWorkspaceActivity); Ruby [`webhooks.iterate_workspace_activity`](https://openemail.uk/docs/ruby/reference/webhooks#iterateWorkspaceActivity).

### `webhooks.list_events()`

List the events an endpoint can subscribe to

```python
def list_events(
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookCatalogueResource
```

Returns every event an endpoint can name in `eventTypes`, each with a sentence saying when it fires, and the limits an endpoint is held to: how many endpoints the workspace may have, which its plan decides, and how many addresses and domains one allowlist may name.

An endpoint that names no events receives every email event except `email.replied`, so `email.replied` and the domain, suppression, file and form families only reach an endpoint that asks for them by name.

Scopes: `webhooks:read`.

**Parameters**

- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookCatalogueResource` with `events`, `maxEndpoints`, `maxAddresses` and `maxDomains`.

**Example**

```python
from openemail import openemail

catalogue = openemail.webhooks.list_events()

for event in catalogue['events']:
    print(event['id'], event['label'])

print('Up to', catalogue['maxEndpoints'], 'endpoints')
```

**Notes**

- The same events are exported from `openemail` as `WEBHOOK_EVENTS`, so code that only needs the ids can skip the call.
- Retried automatically on network failure, since it only reads.

Also available in: API [`GET /webhooks/events`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-events); TypeScript [`webhooks.listEvents()`](https://openemail.uk/docs/sdk/reference/webhooks#listEvents); Ruby [`webhooks.list_events`](https://openemail.uk/docs/ruby/reference/webhooks#listEvents); CLI [`openemail webhooks list-events`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-list-events).

### `webhooks.stats()`

Read how webhook deliveries went inside a window

```python
def stats(
    *,
    since: datetime | str | None = None,
    until: datetime | str | None = None,
    endpoint_ids: Sequence[str] | None = None,
    grain: TrackingGrain | None = None,
    offset_minutes: int | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> WebhookStatsResource
```

Returns the numbers behind the Analytics tab of the Webhooks page: how many delivery attempts were made, how many were delivered and how many failed, the median time a receiver took to answer, a series of buckets, the events sent and the response codes received.

It covers every endpoint, or the ones `endpoint_ids=` names. Each try of an event counts as one attempt, so an event retried three times counts three times. The window runs from `since=` to `until=`, and left out it is the 30 days before now. `grain=` sets the bucket width and the key shape, `YYYY-MM-DD`, `YYYY-MM-DDTHH` or `YYYY-MM-DDTHH:MM`, and `offset_minutes=` shifts the boundaries so days break where the reader's day does.

Scopes: `webhooks:read`.

**Parameters**

- `since` (`datetime | str`): The start of the window, a `datetime` or an ISO 8601 instant. Defaults to 30 days before `until`.
- `until` (`datetime | str`): The end of the window, not included. Defaults to now.
- `endpoint_ids` (`Sequence[str]`): Only these endpoints, at most 50. Left out, every endpoint.
- `grain` (`TrackingGrain`): Bucket width: `minute`, `hour` or `day`, defaulting to `day`.
- `offset_minutes` (`int`): Minutes east of UTC to bucket in, from -840 to 840, defaulting to 0. Pass `time.localtime().tm_gmtoff // 60` for the local zone.
- `api_key` (`str`): Overrides the client API key for this call only.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`WebhookStatsResource` with the window it covered, `totals`, `buckets`, `events` and `codes`.

**Example**

```python
import time
from datetime import datetime, timedelta, timezone

from openemail import openemail

stats = openemail.webhooks.stats(
    since=datetime.now(timezone.utc) - timedelta(days=7),
    grain='day',
    offset_minutes=time.localtime().tm_gmtoff // 60,
)

print(stats['totals']['failed'], 'of', stats['totals']['attempts'], 'attempts failed')
print(stats['codes'])
```

**Notes**

- `buckets` is sparse: a bucket with no attempt has no entry, so a chart must fill the gaps.
- `medianDurationMs` is `None` when nothing was sent in the window.
- Retried automatically on network failure, since it only reads.

Also available in: API [`GET /webhooks/stats`](https://openemail.uk/docs/api/reference/webhooks#get-webhooks-stats); TypeScript [`webhooks.stats()`](https://openemail.uk/docs/sdk/reference/webhooks#stats); Ruby [`webhooks.stats`](https://openemail.uk/docs/ruby/reference/webhooks#stats); CLI [`openemail webhooks stats`](https://openemail.uk/docs/cli/reference/webhooks#webhooks-stats).
