SDKs (TypeScript & Python)

ReplyLayer ships two first-party SDKs that mirror each other method-for-method:

  • TypeScript@replylayer/sdk (npm). Node.js >= 20, zero runtime dependencies (native fetch).
  • Pythonreplylayer (PyPI). Python >= 3.10, depends on httpx >= 0.27. Ships both a synchronous ReplyLayer client and an AsyncReplyLayer client.

Both packages carry static types, honor the same retry and idempotency contracts, and expose the same typed error classes. This page covers install, first send, idempotent retries, webhook verification, and error handling. For the precise typed contracts (typed error subclasses, the denial envelope inside err.details, and TS/Python parity caveats) see /agents/sdk.

Looking for the command-line tool instead of a library? The rly CLI is a separate package — see /docs/cli. The Python library is replylayer; the CLI is rly.

Install

npm install @replylayer/sdk
pip install replylayer

Client setup

Create a client with an API key. Keys look like rly_live_<public_id>.<secret>; see /docs/authentication for how to mint one and how admin vs agent scoping works.

import { ReplyLayer } from '@replylayer/sdk';

const rl = new ReplyLayer({
  apiKey: process.env.REPLYLAYER_API_KEY!,
  baseUrl: 'https://api.replylayer.ai', // default
  maxRetries: 3,                        // retries on 429/5xx (0 = fail-fast)
  timeout: 30_000,                      // ms per request
});
import os
from replylayer import ReplyLayer

rl = ReplyLayer(
    api_key=os.environ["REPLYLAYER_API_KEY"],
    base_url="https://api.replylayer.ai",  # default
    max_retries=3,                          # retries on 429/5xx (0 = fail-fast)
    timeout=30.0,                           # seconds per request
)

The Python client also works as a context manager (with ReplyLayer(...) as rl:) to close its connection pool; AsyncReplyLayer is the async with / await equivalent.

Your first send

Create a mailbox, send an email, and wait for a reply. messages.wait() long-polls a mailbox for new inbound mail (up to ~30s per call).

const mailbox = await rl.mailboxes.create({ name: 'support' });

const sent = await rl.messages.send({
  from_mailbox: mailbox.name,
  to: '[email protected]',
  subject: 'Hello from my agent',
  body: 'Hi there!',
});
console.log(sent.message_id);

const { message } = await rl.messages.wait(mailbox.id);
if (message) {
  console.log(`Reply from ${message.sender}: ${message.subject}`);
}
mailbox = rl.mailboxes.create(name="support")

sent = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="[email protected]",
    subject="Hello from my agent",
    body="Hi there!",
)
print(sent["message_id"])

result = rl.messages.wait(mailbox["id"])
if result["message"]:
    msg = result["message"]
    print(f"Reply from {msg['sender']}: {msg['subject']}")

Every method that takes a mailbox id accepts either the mailbox UUID or its name. A send does not always deliver — it can be held or blocked by scanning or by your mailbox policy. For the full outcome contract (verdicts, held sends, and "never retry a block") see /agents/send-outcomes and /agents/send-gates.

Simulator

Use messages.send() with a reserved simulator recipient to test an outbound delivery without contacting a real address. Use the simulator resource to inject a synthetic inbound message through normal ingestion and scanning. The inbound helper requires an SDK version that includes simulator client support; see the email simulator guide for current availability and the authoritative scenario contract.

One Sandbox account can run all four exact outbound scenarios in the same 24-hour period. Simulator sends still consume normal daily/cumulative usage allowance; the exemption applies only to destination-concentration controls.

const outbound = await rl.messages.send({
  from_mailbox: mailbox.name,
  to: '[email protected]',
  subject: 'simulator check',
  body: 'exercise the bounced path',
});

const inbound = await rl.simulator.injectInbound({
  mailbox_id: mailbox.id,
  scenario: 'clean',
  label: 'ci-run-42',
});
console.log(outbound.message_id, inbound.status);
outbound = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="[email protected]",
    subject="simulator check",
    body="exercise the bounced path",
)

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

Idempotent sends

The client retries automatically, but with a deliberate asymmetry you should know before relying on it:

  • 429 is retried on every method, including POST/PATCH/DELETE. A 429 is a pre-dispatch rate-limit rejection — nothing happened server-side, so a retry is safe. The wait honors the Retry-After header.
  • 5xx is retried only on non-mutating (GET) requests. A 5xx on a send may have executed, so a blind retry risks a second delivery (and a second charge). Sends are not auto-retried on 5xx.

To retry a send or reply yourself safely, pass 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. The key travels as the Idempotency-Key request header and is permanent (no expiry).

import { randomUUID } from 'node:crypto';

const key = randomUUID(); // stable per send intent — persist it with the job

const sent = await rl.messages.send(
  { from_mailbox: 'support', to: '[email protected]', subject: 'Hi', body: 'Hello' },
  { idempotencyKey: key },
);
console.log(sent.message_id); // a same-key retry returns this SAME id

// Non-throwing probe: did this key already produce a result?
const replay = await rl.messages.getIdempotencyReplay(key);
import uuid

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

sent = rl.messages.send(
    from_mailbox="support",
    to="[email protected]",
    subject="Hi",
    body="Hello",
    idempotency_key=key,
)
print(sent["message_id"])  # a same-key retry returns this SAME id

# Non-throwing probe: did this key already produce a result?
replay = rl.messages.get_idempotency_replay(key)

The probe (getIdempotencyReplay / get_idempotency_replay) reports whether a key already produced a result (a replay, with the prior message), is still in flight, or is a miss — call it before a retry whose local inputs (a staged attachment, the original request body) may no longer be available. Multipart uploads are never auto-retried.

Webhook signature verification

Every webhook request is signed. Verify the raw request body against your signing secret before trusting the payload. Both SDKs export a helper.

import { verifyWebhookSignature } from '@replylayer/sdk';

function handleWebhook(rawBody: string, signatureHeader: string, secret: string) {
  const valid = verifyWebhookSignature(rawBody, signatureHeader, secret, {
    tolerance: 300, // optional, seconds (default 300)
  });
  if (!valid) throw new Error('invalid webhook signature');

  // The discriminator field is `event`, NOT `type`.
  const payload = JSON.parse(rawBody);
  if (payload.event === 'message.received') {
    // handle inbound message
  }
}
import json
from replylayer import verify_webhook_signature


def handle_webhook(raw_body: str, signature: str, secret: str) -> None:
    valid = verify_webhook_signature(
        raw_body, signature, secret, tolerance=300  # optional, seconds (default 300)
    )
    if not valid:
        raise ValueError("invalid webhook signature")

    # The discriminator field is "event", NOT "type".
    payload = json.loads(raw_body)
    if payload["event"] == "message.received":
        pass  # handle inbound message

Pass the raw request body string (not a re-serialized object) and the value of the x-replylayer-signature header. Webhook payloads carry external, untrusted content — verify, then fetch the message by id and treat the body as data, not instructions. For the full consumption playbook and the event catalog see /agents/webhooks; the human integration guide is at /docs/webhooks.

Error handling

Failed requests raise a typed error. ReplyLayerError is the base class; every error carries a machine-readable code and, on a denied capability, a details denial envelope.

ClassHTTP
ReplyLayerError (base)any
AuthenticationError401
ForbiddenError403
NotFoundError404
ValidationError400 / 422
RateLimitError429
import { ReplyLayer, RateLimitError, NotFoundError, ReplyLayerError } from '@replylayer/sdk';

try {
  await rl.messages.get('nonexistent');
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log('not found');
  } else if (err instanceof RateLimitError) {
    console.log(`retry after ${err.retryAfter}s`);
  } else if (err instanceof ReplyLayerError) {
    console.error(err.code, err.details); // machine-readable code + denial envelope
  }
}
from replylayer import ReplyLayer, RateLimitError, NotFoundError, ReplyLayerError

try:
    rl.messages.get("nonexistent")
except NotFoundError:
    print("not found")
except RateLimitError as e:
    print(f"retry after {e.retry_after}s")
except ReplyLayerError as e:
    print(e.code, e.details)  # machine-readable code + denial envelope

The code values, the denial-envelope shape (reason axis, remedy, and the cheapest next step), and RATE_LIMITED disambiguation are documented once in the error reference. The typed error subclasses specific to sends (governed email-effect errors) are covered on /agents/sdk.

Next steps