Metadata-Version: 2.4
Name: replylayer
Version: 0.16.0
Summary: Official Python SDK for ReplyLayer — email for AI agents
License-Expression: MIT
Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: typing-extensions>=4.0
Provides-Extra: cli
Requires-Dist: rly>=0.6.3; extra == 'cli'
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# replylayer

Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.

> **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.

## Install

```bash
pip install replylayer
```

## Quick start

```python
from replylayer import ReplyLayer

rl = ReplyLayer(api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z")

# Create a mailbox
mailbox = rl.mailboxes.create(name="support")

# Send an email
sent = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="user@example.com",
    subject="Hello from my agent",
    body="Hi there!",
)

# Wait for a reply (long-poll, up to 30s)
result = rl.messages.wait(mailbox["id"])
if result["message"]:
    msg = result["message"]
    print(f"Got reply from {msg['sender']}: {msg['subject']}")

# Browse conversation threads
page = rl.threads.list(mailbox["id"])
for thread in page.data:
    print(f"{thread['subject']} ({thread['message_count']} messages)")
```

## Async usage

```python
from replylayer import AsyncReplyLayer

async with AsyncReplyLayer(api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z") as rl:
    mailbox = await rl.mailboxes.create(name="support")
    sent = await rl.messages.send(
        from_mailbox=mailbox["name"],
        to="user@example.com",
        subject="Hello",
        body="Hi!",
    )
```

## Constructor options

```python
ReplyLayer(
    api_key="rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z",  # required
    base_url="https://api.replylayer.ai",       # default
    max_retries=3,                              # retries on 429/5xx (0 = fail-fast)
    timeout=30.0,                               # seconds per request
    max_retry_after_seconds=4000.0,             # cap on honoring a 429 Retry-After (~67min)
    on_retry=None,                              # silent-by-default retry hook
)
```

## Retry behavior

The client retries failed requests up to `max_retries` times (default `3`). The
contract — read it before relying on retries:

- **`429` is retried on *every* method, including mutating ones** (`POST` /
  `PATCH` / `DELETE`). A `429` is a pre-dispatch rate-limit rejection, so nothing
  happened server-side — retrying is safe. The wait honors the `Retry-After`
  header.
- **`5xx` is retried only on non-mutating (`GET`) requests.** A `5xx` on a
  `POST` / `PATCH` / `DELETE` is **not** retried — the request may have executed,
  so a retry risks a double-send (or, for `DELETE`, retrying a lost-but-applied
  delete into a confusing `404`).
- **Multipart uploads are never retried** (a retry would re-send the body).
- **Long `Retry-After` values block up to `max_retry_after_seconds`** (default
  ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a
  server `Retry-After` *exceeds* this cap, the SDK **raises the `RateLimitError`**
  rather than sleeping — it never clamps-and-retries into a still-limited window.
  Interactive callers should set a low cap (e.g. `max_retry_after_seconds=30`).
- **`max_retries=0` is fail-fast** — no implicit retry of any kind. Recommended
  for interactive / agent contexts. Branch on `RateLimitError.retry_after`.
- **`on_retry` is silent by default** — the SDK never writes to stdout/stderr.
  Pass an `on_retry(info)` hook to log or meter retries; it receives a `RetryInfo`
  (`attempt`, `error`, `delay_seconds`, `method`, `path`). On the async client it
  may be a coroutine (it's awaited); a raising callback is swallowed so it can't
  break the retry.

## Resources

| Resource | Methods |
|----------|---------|
| `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
| `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
| `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
| `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
| `rl.threads` | `list`, `get`, `set_starred` |
| `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
| `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
| `rl.suppressions` | `list`, `delete` |
| `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
| `rl.account` | `get_usage` |
| `rl.health` | `check` |

*`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.

## Drafts: scan-then-review-then-send

`rl.drafts.create()` runs the outbound scanner synchronously and attaches the verdict to the draft. The create-time verdict is UX — it lets an agent (or a human approver) see the likely outcome before clicking send. `rl.drafts.send()` **re-runs the scanner authoritatively** against the mailbox's current policy, so a stale cached verdict cannot slip through.

```python
draft = rl.drafts.create(
    mailbox_id=mailbox["name"],
    to="user@example.com",
    subject="Re: your invoice",
    body="Thanks for your question.",
)
if draft["worst_decision"] == "allow":
    result = rl.drafts.send(draft["id"])
    print(f"Sent {result['message_id']}")
```

The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).

By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)

The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
- `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
- `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).

```python
from replylayer import ReplyLayerError

try:
    rl.drafts.send(draft["id"])
except ReplyLayerError as err:
    if err.code == "DRAFT_REJECTED_BY_RESCAN":
        print("Rescan blocked it:", err.details)
```

## Outbound attachments (Pro+)

Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** (a Pro+, session-gated dashboard action) — uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.

```python
import time

# Phase 1 — stage the file (returns an opaque handle).
with open("invoice.pdf", "rb") as f:
    handle = rl.attachments.upload(
        mailbox_id="support",
        file=f.read(),                  # bytes or a file-like object
        filename="invoice.pdf",
        content_type="application/pdf",  # advisory — the server re-sniffs the bytes
    )

# The content scan runs asynchronously. Poll until it leaves "pending".
status = handle["content_scan_status"]   # "pending" at upload time
while status == "pending":
    time.sleep(1)
    polled = rl.attachments.get_upload(handle["id"])
    if polled.get("status") == "consumed":
        break
    status = polled["content_scan_status"]

# Phase 2 — reference the handle on a send. "clean" and "flagged" both send
# (a "flagged" finding flows to the message verdict, like a body finding);
# "error" is fail-closed.
result = rl.messages.send(
    from_mailbox="support",
    to="user@example.com",
    subject="Your invoice",
    body="Attached.",
    attachment_ids=[handle["id"]],
)
```

A handle is **consumed once** at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with `rl.attachments.delete_upload(handle["id"])`. Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (`OUTBOUND_IMAGE_DISCLAIMER_REQUIRED`). Drafts hold handles and consume them at dispatch; `rl.drafts.update(draft_id, attachment_ids=None)` clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).

## Delivery history & manual retry

`rl.webhooks.list_deliveries(id, limit=..., before_at=..., before_id=...)` returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. `before_at` and `before_id` must be provided together — the SDK omits the cursor entirely if only one is given.

```python
page = rl.webhooks.list_deliveries(webhook_id, limit=50)
while page["has_more"]:
    page = rl.webhooks.list_deliveries(
        webhook_id,
        limit=50,
        before_at=page["next_before_at"],
        before_id=page["next_before_id"],
    )
```

`rl.webhooks.retry_delivery(webhook_id, delivery_id)` re-queues a single `failed` delivery. The API rejects retries on non-failed deliveries or deliveries whose parent webhook is disabled — surfaced as `ReplyLayerError` with `.code` set to `DELIVERY_NOT_FAILED` or `WEBHOOK_DISABLED`:

```python
from replylayer import ReplyLayer, ReplyLayerError

try:
    rl.webhooks.retry_delivery(webhook_id, delivery_id)
except ReplyLayerError as err:
    if err.code == "WEBHOOK_DISABLED":
        # Resume the webhook (PATCH enabled=True) before retrying.
        pass
```

## Mailbox settings

Each mailbox carries a scanner policy and a PII delivery mode:

```python
# Redact PII before delivering inbound bodies to the agent
rl.mailboxes.update(
    mailbox["id"],
    scanner_policy={"language_mode": "english_only"},
    pii_mode="redacted",
)
```

`pii_mode` values:
- `"passthrough"` (default) — message reads return `body.content` as a plaintext display projection. Session-cookie dashboard callers can opt into sanitized HTML with `body_format=html`.
- `"redacted"` — `body.content` is plaintext with detected PII spans replaced by `<TYPE>` tags (e.g. `<EMAIL_ADDRESS>`, `<PHONE_NUMBER>`). Requires Starter tier or above; sandbox accounts get `403 TIER_LIMIT`.

`pii_mode="redacted"` also applies to outbound webhook payloads: `message.*` events have `sender`/`recipient`/`to` → `<EMAIL_ADDRESS>` and `subject` → `<REDACTED>` before signing. The HMAC covers the redacted body — `verify_webhook_signature` works without any client-side changes.

### Advanced PII config (Pro+)

PR 8 added `pii_redaction_config` for **per-detector** control over redaction (e.g. "leave email visible, redact everything else") and **operator-level** rendering (`partial_mask` for credit cards, `hash_replace` for emails you want to dedupe without exposing). Pro+ feature; only meaningful when `pii_mode="redacted"`.

```python
# Per-detector toggle: show emails to the agent, keep everything else redacted.
rl.mailboxes.update(
    mailbox["id"],
    pii_mode="redacted",
    pii_redaction_config={
        "EMAIL_ADDRESS": {"redact": False},
    },
)

# partial_mask: render credit cards as ****-****-****-1111 (separators preserved).
# `keep_last` is 1-6; `mask_char` defaults to "*".
rl.mailboxes.update(
    mailbox["id"],
    pii_redaction_config={
        "CREDIT_CARD": {
            "redact": True,
            "operator": {"kind": "partial_mask", "keep_last": 4},
        },
    },
)

# hash_replace: <EMAIL_ADDRESS:a3f1b9c2>. Deterministic per account; opaque
# across accounts. Lets your agent dedupe without seeing raw values.
rl.mailboxes.update(
    mailbox["id"],
    pii_redaction_config={
        "EMAIL_ADDRESS": {
            "redact": True,
            "operator": {"kind": "hash_replace"},
        },
    },
)

# Reset to platform default.
rl.mailboxes.update(mailbox["id"], pii_redaction_config={})
```

**Tier gate.** Any non-default value (a `redact: False` entry OR an operator with `kind: "partial_mask"` or `kind: "hash_replace"`) requires the `pii_advanced_controls` feature (Pro+). Non-feature accounts can still PATCH `{}`, default-shape entries (`{"redact": True}`, `{"kind": "replace_with_type"}`).

**`partial_mask` whitelist.** `PERSON` and `EMAIL_ADDRESS` are rejected (422) — partial-masking a name produces nonsense; partial-masking an email is hard to do well in v1. Use `hash_replace` for those instead.

**Downgrade behavior.** If you configure non-default `pii_redaction_config` on Pro and then downgrade, the persisted JSONB stays on the row but the read-side IGNORES it. Reads fall back to platform default. Re-upgrading restores the config instantly. The dashboard surfaces a "Saved but inactive" banner in this state.

**Webhook scope-out.** Advanced PII config does NOT apply to webhook payload metadata. Webhook delivery continues to use `pii_mode` for envelope-level field redaction; per-detector and operator control is API read-side only.

The Python SDK ships static type hints for `PiiOperator` (a `Union` of `PiiReplaceWithTypeOperator`, `PiiPartialMaskOperator`, and `PiiHashReplaceOperator` TypedDicts) — so a config like `{"kind": "hash_replace", "keep_last": 4}` is caught by `mypy` / `pyright` at the SDK boundary, not just at the server's 422.

**Outbound PII send safety.** `ScannerPolicy.outbound_pii_policy` tunes send decisions for the local outbound PII scanner by type:

```python
rl.mailboxes.update(
    mailbox["id"],
    scanner_policy={
        "outbound_pii_policy": {
            "ssn": "quarantine",
            "credit_card": "review",
            "phone_number": "allow_with_warning",
        },
        "outbound_review_policy": {
            "approval_note": "required_for_sensitive_pii",
        },
    },
)
```

Supported actions are `"allow"`, `"allow_with_warning"`, `"review"`, `"quarantine"`, and `"block"`. `"review"` routes matching sends to Pending approval; enabling it requires both Pro+ outbound PII controls and the review queue feature. Relaxing below platform defaults requires Pro+ (`pii_advanced_controls`); default or stricter values are accepted on every tier. Outbound PII scan results include `pii_type` (`"ssn"`, `"credit_card"`, or `"phone_number"`) so clients can inspect which type drove the action.

Approval notes are optional by default. Set `outbound_review_policy.approval_note` to `"required_for_sensitive_pii"` when approvers must add a note before sending SSN or credit-card review holds.

### Agent Attachment Access

Effective attachment exposure now comes from the mailbox policy surface (`attachment_exposure_mode` plus `attachment_allowed_file_families`), not from the legacy `attachment_access_enabled` boolean alone. Admin keys, pre-scoping keys, and dashboard sessions still bypass the agent mailbox-policy gate. Agent-key download requests without an explicit raw-download policy return 403 `ATTACHMENT_ACCESS_DISABLED` — surfaced as `ReplyLayerError` with `.code == "ATTACHMENT_ACCESS_DISABLED"`:

```python
from replylayer import ReplyLayerError

try:
    rl.attachments.get_download_url(message_id, 0)
except ReplyLayerError as err:
    if err.code == "ATTACHMENT_ACCESS_DISABLED":
        # Admin can configure the mailbox attachment policy through the
        # dashboard or POST /v1/mailboxes/:id/attachment-access.
        ...
```

Explicit `raw_download_selected_types` enablement requires a Pro+ production account, session-cookie auth, and fresh TOTP/password re-auth, so Bearer-key SDK clients receive 403 `REAUTH_REQUIRES_SESSION` when they try to enable or widen approved downloads. The SDK can still read attachment policy state, disable raw downloads, set `metadata_only` / `derived_content`, and perform same-or-narrower writes on an already-explicit approved-download mailbox.

Images are a separately confirmed raw-download family. When `allowed_file_families` includes `"image"`, pass `accept_image_risk_version` matching the mailbox response's `current_image_risk_version` unless the mailbox already has current image-risk acceptance. A mailbox response reports image state with `image_raw_download_confirmed`, `attachment_image_access_accepted_at`, and `attachment_image_access_accepted_version`. Legacy wildcard rows and stale image acceptances do not grant raw image downloads.

Human dashboard sessions and admin/pre-scoping keys can download clean stored `metadata_only` attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.

See ENDPOINTS.md for the full contract and known limitations.

### Recipient allowlist (mailbox containment)

A mailbox is in `blocklist` mode by default — the pre-send gate rejects `suppressed_addresses` hits and allows everyone else. Switching to `allowlist` mode restricts outbound to a pre-approved list; an agent (or a compromised API key) physically cannot email outside the list.

```python
# Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
rl.mailboxes.allowlist.add(mailbox["id"], email="partner@corp.com")
rl.mailboxes.allowlist.add_bulk(mailbox["id"], emails=["a@x.com", "b@y.com"])

# Flip the mode. Returns 400 ALLOWLIST_EMPTY if the list is empty unless
# force_empty=True is passed to acknowledge the lockout.
rl.mailboxes.set_recipient_policy(mailbox["id"], "allowlist")

# Sends to off-list recipients now 403 with code RECIPIENT_NOT_ON_ALLOWLIST.
# Blocklist still runs first — a recipient on the do-not-contact list is
# rejected 403 with code RECIPIENT_SUPPRESSED (details["reason"] == "suppressed").

# Deleting the last entry while in allowlist mode returns 409 ALLOWLIST_LAST_ENTRY;
# pass force_empty=True to acknowledge.
rl.mailboxes.allowlist.delete(mailbox["id"], "partner@corp.com", force_empty=True)
```

A send/reply/draft-send to a recipient on your do-not-contact (suppression) list raises `ReplyLayerError` with `.code == "RECIPIENT_SUPPRESSED"` (HTTP 403, `details["reason"] == "suppressed"`). This is terminal — escalate, don't retry; remove the suppression or send to a different recipient.

Allowlist mutations are admin-only — granting send permission to an LLM defeats the containment boundary. Agents *can* `list` (so they can see what they're allowed to email) but not `add`/`add_bulk`/`delete`. Three new webhook events: `recipient_allowlist.added`, `recipient_allowlist.removed`, `mailbox.recipient_policy_changed`.

### Domain entries (sprint 039)

Entries can be either an exact email (`alice@corp.com`) or a bare-domain pattern (`@corp.com`) that matches every address at that domain. Exact-domain only — `@corp.com` matches `*@corp.com` but NOT `eve@sub.corp.com`.

```python
# Allow everyone at @partner.com.
rl.mailboxes.allowlist.add(mailbox["id"], email="@partner.com")

# Block a whole competitor domain.
rl.suppressions.add(email="@competitor.com")

# Bulk mix emails + domains.
bulk = rl.mailboxes.allowlist.add_bulk(
    mailbox["id"],
    emails=["alice@corp.com", "@partner.com", "not-an-email"],
)
# bulk["added"][0]["pattern_type"] == "email"
# bulk["added"][1]["pattern_type"] == "domain"
# bulk["invalid"][0]                    == {"email": "not-an-email", "reason": "invalid_format"}
```

Responses expose `pattern_type: "email" | "domain"` on every add/list/delete/bulk-added row. Pre-0.5.0 servers omit the field.

Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (`@`, `@.com`, `@foo`, `@corp-.com`, non-ASCII) raise `ReplyLayerError` with `.code == "INVALID_EMAIL"` (message: `"Invalid email or domain pattern"`).

### Blocked attempts (migration 038)

Every send the allowlist gate rejects writes an append-only audit row and emits a deduped `recipient_allowlist.blocked_attempt` webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.

```python
# Aggregated top-N view — grouped by (recipient, actor_id).
# next_cursor is always None; the aggregate is top-N, not paginated.
result = rl.mailboxes.allowlist.list_blocked_attempts(mailbox["id"])
for a in result["attempts"]:
    print(f"{a['recipient']} × {a['count']} (last: {a['last_attempted_at']})")

# "Blocked this week" — recency filter (1..365 days).
week = rl.mailboxes.allowlist.list_blocked_attempts(mailbox["id"], within_days=7)

# Raw per-attempt history for forensic drill-in. Paginates via tuple cursor.
raw = rl.mailboxes.allowlist.list_blocked_attempts(
    mailbox["id"], aggregate=False, limit=100,
)
```

Async parity is identical — `await rl.mailboxes.allowlist.list_blocked_attempts(...)`.

Webhook deliveries are deduped server-side to at most one per `(account, mailbox, recipient)` per 60 seconds — a looping agent produces one delivery, not hundreds, keeping your subscription below the 20-abandoned-deliveries auto-disable threshold. Full attempt history is always available via `list_blocked_attempts`.

The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).

## Mailbox identifiers

Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.

## Pagination

List endpoints return a `Page` with `data`, `has_more`, and `cursor`:

```python
page = rl.messages.list("mailbox-id", limit=50)
print(page.data)      # list of message dicts
print(page.has_more)  # bool
print(page.cursor)    # str | None
```

Pass `auto_paginate=True` for an iterator:

```python
for msg in rl.messages.list("mailbox-id", auto_paginate=True):
    print(msg["subject"])

# Async
async for msg in rl.messages.list("mailbox-id", auto_paginate=True):
    print(msg["subject"])
```

## Error handling

```python
from replylayer import ReplyLayer
from replylayer.errors import NotFoundError, RateLimitError

try:
    rl.messages.get("nonexistent")
except NotFoundError:
    print("Message not found")
except RateLimitError as e:
    print(f"Rate limited, retry after {e.retry_after}s")
```

Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `ForbiddenError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429).

## Webhook signature verification

> For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see [`docs/webhooks.md`](../../docs/webhooks.md).

```python
from replylayer import verify_webhook_signature

verify_webhook_signature(
    payload=request.body,
    signature=request.headers["x-replylayer-signature"],
    secret="whsec_...",
    tolerance=300,  # optional, seconds (default 300)
)
```

## Context managers

Both clients support context managers to properly close connection pools:

```python
with ReplyLayer(api_key="...") as rl:
    rl.messages.send(...)
# connections closed

async with AsyncReplyLayer(api_key="...") as rl:
    await rl.messages.send(...)
```

## Requirements

- Python >= 3.10
- httpx >= 0.27

## License

MIT
