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 (nativefetch). - Python —
replylayer(PyPI). Python >= 3.10, depends onhttpx >= 0.27. Ships both a synchronousReplyLayerclient and anAsyncReplyLayerclient.
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
rlyCLI is a separate package — see /docs/cli. The Python library isreplylayer; the CLI isrly.
Install
npm install @replylayer/sdkpip install replylayerClient 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:
429is retried on every method, includingPOST/PATCH/DELETE. A429is a pre-dispatch rate-limit rejection — nothing happened server-side, so a retry is safe. The wait honors theRetry-Afterheader.5xxis retried only on non-mutating (GET) requests. A5xxon a send may have executed, so a blind retry risks a second delivery (and a second charge). Sends are not auto-retried on5xx.
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 messagePass 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.
| Class | HTTP |
|---|---|
ReplyLayerError (base) | any |
AuthenticationError | 401 |
ForbiddenError | 403 |
NotFoundError | 404 |
ValidationError | 400 / 422 |
RateLimitError | 429 |
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 envelopeThe 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
- /docs/authentication — key format, admin vs agent scoping, session-vs-Bearer, rotation.
- /docs/webhooks — webhook setup and delivery.
- /docs/cli — the
rlycommand-line tool. - /docs/mcp — the MCP server for agent runtimes.
- /agents/sdk — the machine-precise SDK contract.
- /agents/errors — the error-code reference.