Skip to main content

replylayer

Official Python SDK for ReplyLayer — secure email for AI agents.

Start a free 30-day Sandbox and follow the quickstart to create a mailbox-scoped key.

Looking for the command-line tool? This package is the SDK library (import replylayer). For the rly / replylayer CLI, install rly instead: pipx install rly.

Install

pip install replylayer

Quick start

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="delivered@simulator.replylayer.net",
    subject="Hello from my agent",
    body="Hi there!",
)
print(f"{sent['message_id']}: {sent['status']}")

# The simulator does not reply. For a real conversation, verify a recipient,
# send to that address, then call rl.messages.wait(mailbox["id"]).

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

Async usage

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="delivered@simulator.replylayer.net",
        subject="Hello",
        body="Hi!",
    )

Constructor options

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. The one exception is the failed-authentication 429 (details.reason: "failed_authentication"), which is raised immediately: it means the API key is wrong or revoked, and waiting does not fix that.
  • 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). To retry a send / reply safely, pass an idempotency key — see Idempotent sends.
  • 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.

Idempotent sends

Because a 5xx on a send is not auto-retried (a blind retry risks a second delivery + a second charge), the SDK gives you a way to retry it yourself safely. messages.send, messages.reply, and scheduled sends via drafts.create (with send_at) accept an idempotency_key: a network-retried request carrying the same key produces at most one message and one charge — the server replays the original outcome and returns the same message_id instead of sending again.

import uuid

key = str(uuid.uuid4())  # stable per send intent — persist it with the job

# First call sends; a same-key retry replays the first result (no second send).
sent = rl.messages.send(
    from_mailbox="support",
    to="user@example.com",
    subject="Hi",
    body="Hello",
    idempotency_key=key,
)
print(sent["message_id"])  # a same-key retry returns this SAME id

The key travels as the Idempotency-Key request header and is permanent (no expiry). A non-throwing probe, rl.messages.get_idempotency_replay(key), reports whether a key already produced a result, is still in flight, or is a miss — call it before a retry whose local inputs (a staged attachment, the original message) may no longer be available. The async client exposes the same methods.

Resources

Resource Methods
rl.domains create, list, get, verify, update_self_hosted_config, delete, set_default, recheck
rl.mailboxes create, list, get_mailbox, delete, update, set_recipient_policy, set_thread_replies, set_agent_send_containment, set_agent_send_policy, set_attachment_access, set_sender_policy
rl.mailboxes.allowlist list, add, add_bulk, delete, list_blocked_attempts
rl.mailboxes.inbound_allowlist list, add, add_bulk, delete, list_blocked_attempts
rl.messages send, list, get, reply, get_idempotency_replay, wait, release, block, report, delete, firewall_release, mark_read, set_starred, approve_review, deny_review
rl.drafts create, get, list, update, send, delete
rl.threads list, get, mark_read, 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, add, add_bulk, delete
rl.inbound_blocklist list, add, add_bulk, delete
rl.api_keys create, list, update, revoke, rotate*
rl.account get_usage, get_quota, get_limits, get_link_scanning_status, enable_link_scanning, export
rl.legal_holds apply, release, list, get
rl.health check
rl.simulator inject_inbound
rl.policy get_mailbox_policy, get_overview, get_account_policy, update_account_policy, preview_mailbox_policy

*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.

Simulator

Outbound scenarios use the normal send methods. Inbound scenarios use rl.simulator.inject_inbound():

outbound = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="delivered+ci-run-42@simulator.replylayer.net",
    subject="simulator check",
    body="exercise the delivered path",
)

inbound = rl.simulator.inject_inbound({
    "mailbox_id": mailbox["id"],
    "scenario": "clean",
    "label": "ci-run-42",
})

# Branch on the result: available | quarantined | pending.
print(outbound["message_id"], inbound["status"], inbound.get("message_id"))

Outbound delivery, bounce, complaint, and suppression addresses; delayed webhook outcomes; inbound scenario semantics; and billing/suppression caveats are defined in the email simulator guide. One Sandbox account can run all four exact outbound scenarios in the same day; those addresses bypass destination-concentration controls but still consume normal daily/cumulative usage allowance.

Sandbox recipients

On the free Sandbox, a send reaches only recipients the account may email: its own address, simulator scenarios, replies to authenticated senders, and people on its recipient list. Any other recipient raises ForbiddenError with code="SANDBOX_RECIPIENT_NOT_VERIFIED" (err.details is a DenialDetails with reason_axis="sandbox_recipient" and attestations_remaining). Add the person with rl.recipients.create():

# A confirmation request: the person is emailed a link and becomes sendable once they click it.
rl.recipients.create(email="friend@example.com")

# An account attestation: sendable now, no email. Only for someone you know.
limits = rl.account.get_limits()
if (limits.get("sandbox_attestations_remaining") or 0) > 0:
    added = rl.recipients.create(email="friend@example.com", attest=True)
    print(added["attestation"], added["complaint_notice"])

Each attestation spends one of the account's per-trial attestations and is never refunded. If someone added that way reports your email as spam, the account can no longer add people without confirmation, and a second report suspends the Sandbox account; complaint_notice says so, for whoever attested. An attestation past the limit, or after a complaint forfeited them, is 403 TIER_LIMIT with attestations_remaining=0 (and forfeited=True).

An agent key (every hosted-MCP sign-in connection is one) can add recipients only when the operator has turned agent recipient writes on, and only while every mailbox it is bound to is open to agent sends; otherwise it gets 403 INSUFFICIENT_SCOPE or 403 RECIPIENT_WRITE_AGENT_CONTAINED (remedy="ask_account_owner_to_add_recipient"), and the account owner adds the person. 503 RECIPIENT_WRITE_UNAVAILABLE means recipient writes are unavailable and nothing was charged; retry once, and if it fails again, stop and tell the user (a misconfigured transport makes it persistent). The async client exposes the same methods.

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.

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", "review_causes"?, "agent_instructions"} or None) is the policy/human-review reason plus held-send guidance, non-None on held sends — a typed policy cause (first-contact/send-window/Supervised), a policy/human-review hold that changed the scanner's decision, or a genuine scan-explained hold such as a real scanner quarantine (trigger_source: mailbox_policy | scanner | both); it stays None on sent/terminal outcomes and normally on retryable infrastructure holds — a typed policy cause still attaches one (branch on email_effect["effect_status"] for those). review_causes is the typed hold-cause discriminator (content_warning | first_contact | send_window | mailbox_policy) driving a Supervised (risky_only) hold or a send-window promotion — a NotRequired key, absent on holds this dashboard policy builder didn't cause-type.

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 decides. When async dispatch is available the server returns 202 with status="queued_for_dispatch" (AsyncSendAck); otherwise it ignores the hint and returns a normal 200 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, releasable (True for a quarantine hold the customer can release via POST /v1/drafts/:id/release-and-send, False for a terminal block), and, when a policy/human-review decision drove the hold, hold_context.
  • DRAFT_ALREADY_SENT — the draft was already sent (race or retry after success).
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 by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise ForbiddenError with code="OUTBOUND_ATTACHMENTS_DISABLED".

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).

Webhook custom request headers

For a receiver that authenticates by a fixed header rather than by ReplyLayer's HMAC, webhooks.create and webhooks.update accept request_headers — up to 8 static headers added to every delivery attempt for that webhook (first attempt, every retry, test(), and a manual retry_delivery).

wh = rl.webhooks.create(
    url="https://your-server.com/hooks/replylayer",
    enabled_events=["message.received"],
    request_headers={"Authorization": "Bearer your-receiver-token"},
)

wh["request_header_names"]  # ['authorization'] — names only, lower-cased + sorted

Values are write-only. They are encrypted at rest and returned by no read path: every webhook response carries request_header_names ([] when none) and never a value. Because of that, update is three-way and there is no per-header merge:

rl.webhooks.update(wh["id"], request_headers={"Authorization": "Bearer rotated"})  # replaces the whole map
rl.webhooks.update(wh["id"], request_headers=None)                                # clears every header
rl.webhooks.update(wh["id"], enabled=False)                                       # leaves headers untouched

None means "clear" here, so omitting the argument entirely is the only way to leave the configured headers alone.

Names match ^[A-Za-z0-9-]{1,64}$ and are compared case-insensitively; values are 1–1024 printable-ASCII characters. Reserved names — content-type, content-length, host, user-agent, x-replylayer-signature, x-webhook-timestamp, x-webhook-signature-v2, transfer-encoding, connection, expect, te, upgrade, keep-alive, trailer, and anything starting with proxy- — are rejected. The SDK does not pre-validate: rejections arrive as ReplyLayerError with .code set to WEBHOOK_HEADER_RESERVED, WEBHOOK_HEADER_DUPLICATE, WEBHOOK_HEADER_INVALID, or VALIDATION_ERROR.

Deliveries for a webhook with custom headers store no response body (response_preview is always None), so a receiver that echoes the request cannot leak a configured credential back into the delivery history.

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.

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:

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:

# 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, or pay-as-you-go; 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+)

pii_redaction_config gives 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".

# 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:

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 your review queue (approve/deny) — available on every tier. 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.

Mailbox policy fields (dashboard policy builder)

rl.mailboxes.update() also accepts the per-mailbox fields the dashboard policy builder governs: agent_authoring_mode ("send_and_draft" | "draft_only" | "read_only"), hitl_mode (now widened to "disabled" | "all_outbound" | "risky_only"), approval_expiry ("24h" | "72h" | "7d" | "never"), and send_window (a dict binding AGENT-origin sends to a weekly window). apply_policy_mode applies one of the four named modes ("read_only" / "draft_only" / "supervised" / "trusted") atomically and is mutually exclusive with those raw identity fields in the same call (the server returns 400 AMBIGUOUS_POLICY_MODE_APPLICATION).

# Apply a named mode — writes agent_authoring_mode/hitl_mode/agent_send_policy together.
rl.mailboxes.update(mailbox["id"], apply_policy_mode="supervised")

# Or set fields directly (not combined with apply_policy_mode in the same call).
rl.mailboxes.update(
    mailbox["id"],
    agent_authoring_mode="draft_only",
    approval_expiry="72h",
    send_window={
        "timezone": "America/Chicago",
        "days": ["mon", "tue", "wed", "thu", "fri"],
        "start": "09:00",
        "end": "18:00",
        "outside_action": "require_approval",
    },
)

# send_window omitted (default) leaves it unchanged; pass send_window=None
# explicitly to CLEAR it (always-open — a loosening).
rl.mailboxes.update(mailbox["id"], send_window=None)

Direction gate. Tightening (toward read_only/shorter expiry/narrower window) works with an admin API key. Any loosening requires a dashboard session + fresh re-auth — a bearer key gets 403 REAUTH_REQUIRES_SESSION. rl.policy.get_mailbox_policy(mailbox_id) reads the derived policy_mode, last_applied_policy_mode, the calling key's binding["permitted_verbs"], and enforcement — the live rollout-lever state ({"risky_only": ..., "send_window": ...}, each "off" | "shadow" | "enforce"). A stored hitl_mode="risky_only" or send_window only actually holds a send when the matching enforcement field reads "enforce"; off/shadow mean the posture is saved but not yet active. rl.policy.get_overview() carries the same enforcement block once, account-wide (the levers are env-global, not per-mailbox). rl.policy.preview_mailbox_policy(mailbox_id, to=...) is a side-effect-free dry-run of the gate stack for a sample send.

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":

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 the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes 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 contains agent-origin outbound to a pre-approved list (plus thread participants): a prompt-injected or compromised agent key cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.

# 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.

The same RECIPIENT_SUPPRESSED code also fires for a platform-scoped cross-account hard-bounce hit — an address that hard-bounced somewhere on the platform (not necessarily on your account), which ReplyLayer refuses on every ReplyLayer-managed sending domain to protect the shared reputation every customer rides on (never enforced on a delegated/BYOD or self-hosted domain). details["scope"] == "platform" distinguishes it from your own list (which omits scope); this variant isn't in GET /v1/suppressions and can't be removed via the SDK — it's an operator-only override.

Allowlist mutations are admin-only — granting mutation to an LLM defeats the agent-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

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.

# 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

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.

# 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).

Recipient verification (send-path safety checks)

POST /v1/messages/send, .../reply, and POST /v1/drafts/:id/send run a permissive, fail-open recipient-quality check before dispatch. Only a confirmed violation rejects the send — an infrastructure hiccup (DNS blip, verification service outage) never blocks it. A reply or thread continuation is exempt (the recipient is a proven correspondent). Five ReplyLayerError codes, all HTTP 422:

Code Meaning
RECIPIENT_ADDRESS_INVALID The address fails a strict syntax check beyond the basic email-format validation.
RECIPIENT_DOMAIN_TYPO_SUSPECTED The domain looks like a single-character typo of a common consumer mail provider (e.g. gmial.com). The suggested domain rides in the exception message.
RECIPIENT_ROLE_ADDRESS The local part is a structural role/distribution mailbox (noreply@, no-reply@, etc. by default), not an individual inbox.
RECIPIENT_DISPOSABLE_ADDRESS The domain is a known disposable/temporary email provider.
RECIPIENT_UNDELIVERABLE The domain has no mail servers (no MX or A record) — mail to it would hard-bounce.
from replylayer import ValidationError

try:
    rl.messages.send(from_mailbox="support", to="noreply@example.com", subject="hi", body="x")
except ValidationError as err:
    if err.code == "RECIPIENT_ROLE_ADDRESS":
        print("That looks like a role mailbox, not a person — double-check the recipient.")

Mailbox creation & sending-domain provisioning

On paid accounts (once the per-account sending-domain estate is enabled), rl.mailboxes.create(...) can race the account's sending-domain setup. There are no new SDK methods — two error codes flow through the existing error envelope:

Code HTTP Meaning
DOMAIN_PROVISIONING_PENDING 409 The account's sending domain is still being set up (or a domain change is in flight). Retryable — err.details["retry_after"] carries the suggested seconds. Poll rl.domains.list() for the platform row's verification_status.
DOMAIN_PROVISIONING_FAILED 409 Sending-domain setup failed. Not retryable from the client — contact support.

The SDK deliberately does not auto-retry; retry loops live in the CLI (rly mailbox create waits up to 60s) and the dashboard.

Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). New accounts have it enabled by default at signup (disclosed in Privacy Policy §7a; per-mailbox opt-out via the mailbox scanner policy); older accounts opt in per account. Enabling it is an admin action (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise ForbiddenError).

Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:

status = rl.account.get_link_scanning_status()
# {"active", "accepted_version", "current_version", "privacy_ok"}

if not status["active"] and status["privacy_ok"]:
    res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
    # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]

If privacy_ok is False the account's acknowledged privacy policy version predates the disclosed sub-processor — review and acknowledge the current Privacy Policy first (enable_link_scanning would otherwise raise a 409 PRIVACY_VERSION_TOO_OLD). The async client exposes the same methods (await rl.account.get_link_scanning_status() / await rl.account.enable_link_scanning(...)).

Trusted instruction sources

Every inbound message read carries agent_safety_context with untrusted_content True — the body is external data, not instructions for the agent to act on. Trusted instruction sources is an opt-in, read-path-only relaxation of that default for one specific, customer-designated, verified sender address per mailbox (address-grain only — there is no domain-wide grant). It does not change what a resulting send is allowed to do; the only related send-side control is a per-mailbox strict-recipient toggle, configured outside the SDK.

The relaxation only applies on a read when every layer is satisfied, entirely on the server side: the platform feature is enabled, the mailbox's instruction-trust mode is on, the designated sender's message passed Mailgun sender verification (verified_aligned), the message scanned clean and is available, and the reading API key is a role="agent" key with the per-key capability enabled. There is no client-side opt-in — the SDK does not send any header or constructor flag to request this; a message either qualifies under the operator's configuration or it doesn't, and the response reflects that automatically:

result = rl.messages.wait(mailbox["id"])
msg = result["message"]
ctx = msg.get("agent_safety_context") if msg else None
if ctx and ctx.get("instruction_trust"):
    # Gate passed: guidance was REPLACED with trusted-instruction wording (the
    # agent may act on this verified sender's own explicit request in this
    # message). untrusted_content is still True — this is metadata, not a
    # content-safety judgment.
    print(ctx["guidance"])
    print(ctx["instruction_trust"])
    # {"version": "v1", "match": "address", "verified_domain": ..., "verdict": "verified_aligned", "provenance": "managed"}

A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard (each a loosening change requiring session re-auth). Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in. This is a read-path signal only — a copied or hijacked API key cannot self-grant the capability, and there is nothing for a client to set to request it.

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:

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:

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

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 https://replylayer.ai/docs/webhooks.

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)
)

Once verified, parse and dispatch on the event type. The discriminator field is event, not type:

import json

payload = json.loads(request.body)
# payload["event"] is the discriminator — NOT payload["type"]
if payload["event"] == "message.received":
    print("handle inbound message")
elif payload["event"] == "message.dispatch_failed":
    print("handle failed outbound send")

Context managers

Both clients support context managers to properly close connection pools:

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

Metadata

Release files for replylayer 0.28.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for replylayer 0.28.1
File Size Uploaded Provenance
replylayer-0.28.1.tar.gz 141.9 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for replylayer 0.28.1
File Interpreter ABI Platform
replylayer-0.28.1-py3-none-any.whl Python 3 none any Details

Total release size: 231.8 kB

Release files / replylayer-0.28.1.tar.gz

Download URL replylayer-0.28.1.tar.gz
Size 141.9 kB
Tags Source
SHA-256 checksum
How to use checksums
cbbe451739ee992d46579b3740cfb7cb7c5369a5a135b826b0a4a8eb9c3d0618
BLAKE2b-256 checksum
How to use checksums
ef870441f09948f0b5952e1236f549c1b8e00aee704e0307a9c0a806e03dbfdc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / replylayer-0.28.1-py3-none-any.whl

Download URL replylayer-0.28.1-py3-none-any.whl
Size 89.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6b64653f914721fae5cbeb38029cefb76620f9ca16637d1b234aa51c964ff95a
BLAKE2b-256 checksum
How to use checksums
af290820ade741bdd1426e9a571ba3dae8d726a31d0e4aa59cddb1160bad90cd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Security for replylayer 0.28.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for replylayer-0.28.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page