# 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`).
- **Python** — `replylayer` (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](/agents/sdk).

> Looking for the command-line tool instead of a library? The `rly` CLI is a
> separate package — see [/docs/cli](/docs/cli). The Python **library** is
> `replylayer`; the CLI is `rly`.

## Install

```bash
npm install @replylayer/sdk
```

```bash
pip install replylayer
```

## Client setup

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

```typescript
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
});
```

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

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

const sent = await rl.messages.send({
  from_mailbox: mailbox.name,
  to: 'user@example.com',
  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}`);
}
```

```python
mailbox = rl.mailboxes.create(name="support")

sent = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="user@example.com",
    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](/agents/send-outcomes) and
[/agents/send-gates](/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](/docs/guides/simulator) 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.

```ts
const outbound = await rl.messages.send({
  from_mailbox: mailbox.name,
  to: 'bounced+ci-run-42@simulator.replylayer.net',
  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);
```

```python
outbound = rl.messages.send(
    from_mailbox=mailbox["name"],
    to="bounced+ci-run-42@simulator.replylayer.net",
    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).

```typescript
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: 'user@example.com', 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);
```

```python
import uuid

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

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

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

```typescript
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
  }
}
```

```python
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](/agents/webhooks); the human integration guide is at
[/docs/webhooks](/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 |

```typescript
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
  }
}
```

```python
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](/agents/errors). The typed error subclasses specific to sends
(governed email-effect errors) are covered on [/agents/sdk](/agents/sdk).

## Next steps

- [/docs/authentication](/docs/authentication) — key format, admin vs agent
  scoping, session-vs-Bearer, rotation.
- [/docs/webhooks](/docs/webhooks) — webhook setup and delivery.
- [/docs/cli](/docs/cli) — the `rly` command-line tool.
- [/docs/mcp](/docs/mcp) — the MCP server for agent runtimes.
- [/agents/sdk](/agents/sdk) — the machine-precise SDK contract.
- [/agents/errors](/agents/errors) — the error-code reference.
