# SDK contract

Two first-party SDKs wrap the REST API method-for-method:

```bash
npm install @replylayer/sdk    # TypeScript / Node
pip install replylayer          # Python (sync + async)
```

```ts
import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });
```

```python
from replylayer import ReplyLayer
rl = ReplyLayer(api_key=os.environ["REPLYLAYER_API_KEY"])
```

Client defaults: `baseUrl` `https://api.replylayer.ai`, `timeout` 30 s,
`maxRetries` 3. This page documents the machine contract an agent integration
depends on — how errors are typed, what `error.details` carries, how retries and
idempotency behave, and how to verify webhooks. For the canonical catalogs it
links out: error codes → [/agents/errors](/agents/errors); the webhook event
catalog → [/agents/webhooks](/agents/webhooks); message states and verdict
vocabulary → [/agents/messages](/agents/messages); the send-gate decision tree →
[/agents/send-gates](/agents/send-gates).

## Typed error classes

Every non-2xx response is thrown as a `ReplyLayerError` (Python:
`ReplyLayerError`, a subclass of `Exception`). It always carries three fields plus
an optional bag:

| Field (TS / Python) | Type | Meaning |
|---|---|---|
| `statusCode` / `status_code` | `number` / `int` | HTTP status. |
| `code` | `string` | Machine-readable error code (see [/agents/errors](/agents/errors)). Coerced to a string even if a gateway put a numeric code on the body; falls back to `HTTP_<status>`. |
| `message` | `string` | Human-readable summary. Do not branch on this — branch on `code`. |
| `details` | `Record<string, unknown>` / `dict[str, Any] \| None` | Structured, code-specific payload. **Untyped** — read by key. |

Status is mapped to a narrower subclass so `instanceof` / `isinstance` checks
work without inspecting `statusCode`:

| Subclass | Status | Extra fields |
|---|---|---|
| `AuthenticationError` | 401 | — |
| `ForbiddenError` | 403 | — |
| `NotFoundError` | 404 | — |
| `ValidationError` | 400 / 422 | — |
| `RateLimitError` | 429 | `retryAfter`, `limit`, `remaining`, `reset` (parsed from `Retry-After` / `X-RateLimit-*`; `null` when absent/unparseable) |
| `SchedulingError` | any | `reasonCode` / `reason_code` — one of `TIMEZONE_REQUIRED`, `SEND_AT_TOO_SOON`, `SEND_AT_TOO_FAR`, `SCHEDULED_SEND_QUOTA_EXCEEDED` |
| `WebhookSignatureError` | 0 | Thrown locally by the verify helper, not from an HTTP call |

`SchedulingError` takes precedence over the status mapping: a 429 carrying
`SCHEDULED_SEND_QUOTA_EXCEEDED` is a `SchedulingError`, not a `RateLimitError`, so
`instanceof SchedulingError` catches all scheduling rejections uniformly.

```ts
import { ReplyLayerError, ForbiddenError, RateLimitError } from '@replylayer/sdk';
try {
  await rl.messages.send({ from_mailbox: 'support', to: 'user@example.com', subject: 'Hi', body: 'Hello' });
} catch (err) {
  if (err instanceof RateLimitError) {
    // err.retryAfter is seconds (or null); back off and retry.
  } else if (err instanceof ForbiddenError) {
    // Inspect err.details for the denial envelope (below).
  } else if (err instanceof ReplyLayerError) {
    switch (err.code) { /* … */ }
  }
}
```

## Reading the denial envelope from `details`

Capability denials (403 / 429 that stem from tier, trust, or sandbox capacity)
carry a machine-readable **denial envelope** inside `error.details`. There is **no
typed `DenialDetails` export in either SDK** — `details` is `Record<string,
unknown>` (TS) / `dict[str, Any]` (Python), so you read the envelope fields by
key:

| Key | Meaning |
|---|---|
| `reason_axis` | Why the request was denied (e.g. `trust_capacity`). |
| `remedy` | The axis-derived corrective action. |
| `cheapest_next_step` | The lowest-cost unblock path. |
| `upgrade_url` | Present only when the remedy is monetary and a URL is configured. |

Capability denials also carry `feature`, `current_count`, and `max_allowed`
where relevant (webhook-cap and sandbox-budget denials use this shape). The
canonical field-by-field envelope contract lives on
[/agents/errors](/agents/errors); tier and sandbox limits live on
[/docs/limits](/docs/limits).

```python
try:
    rl.messages.send(from_mailbox="support", to="user@example.com", subject="Hi", body="Hello")
except ForbiddenError as err:
    d = err.details or {}
    if d.get("reason_axis") == "trust_capacity":
        route_owner_to(d.get("cheapest_next_step"), d.get("upgrade_url"))
```

## EmailEffect: strict-outcome send errors

By default, `messages.send()`, `messages.reply()`, and `drafts.send()` resolve a
**blocked or held send as HTTP 200** — the response body carries an `email_effect`
view (and a `scan` summary + `hold_context`) rather than throwing. Branch on the
result to detect a non-delivered outcome.

Opt into honest (non-2xx) outcomes with the **Governed Email-Effect Contract**:
set `strictOutcome` / `strict_outcome` on the client or per call (it forwards the
`Prefer: outcome=strict` header). A non-`sent` outcome then throws a typed
subclass of `EmailEffectError` (itself a `ReplyLayerError`):

| Subclass | Status | `effect_status` | Correct agent action |
|---|---|---|---|
| `EmailEffectRejectedError` | 422 | `blocked` | Terminal content rejection — edit and resend, or escalate. **Never retry the same body.** |
| `EmailEffectHeldError` | 409 | `held_for_review` | Accepted into governance (quarantine / human review). Report "awaiting approval"; it is releasable, not a content error. |
| `EmailEffectRetryableError` | 503 | `held_infrastructure` | Transient infrastructure hold — the content was never judged. Retry later; carries `retryAfter` / `retry_after` (seconds, or `null`). |

Each error exposes `.emailEffect` / `.email_effect` (the parsed view) and `.scan`
(the scanner summary), so you can branch retry-vs-edit-vs-escalate from the throw
without a follow-up read. The `email_effect` view carries four booleans:
`releasable`, `terminal`, `retryable`, plus the `effect_status` discriminator.

`effect_status` is one of `sent`, `held_for_review`, `held_infrastructure`,
`blocked`. **The four members are frozen but the enum is open** — a tolerant
reader must treat any unknown value fail-closed (never as `sent`). The canonical
outbound-outcome and verdict vocabulary lives on
[/agents/messages](/agents/messages); the gate that produces these outcomes is on
[/agents/send-gates](/agents/send-gates).

```ts
const rl = new ReplyLayer({ apiKey: KEY, strictOutcome: true });
try {
  await rl.messages.send({ from_mailbox: 'support', to: 'user@example.com', subject: 'Hi', body });
} catch (err) {
  if (err instanceof EmailEffectRetryableError) {
    scheduleRetry(err.retryAfter);        // infra hold — safe to retry
  } else if (err instanceof EmailEffectRejectedError) {
    escalateOrEdit(err.scan);             // terminal block — do not retry
  } else if (err instanceof EmailEffectHeldError) {
    reportAwaitingApproval();             // releasable hold
  }
}
```

## Retry semantics

The SDKs retry automatically, but only where a retry is safe:

- **429** is retried on every method — it is a pre-dispatch gate rejection, so
  nothing happened server-side.
- **5xx** is retried **only on non-mutating `GET`** requests. A 5xx on
  `POST` / `PATCH` / `DELETE` is thrown immediately — a blind retry could
  double-send or lose a mutation.
- **Multipart uploads are never retried** (a retry would re-send the body).
- Backoff is exponential (`500 ms × 2^attempt`, capped at 30 s), bounded by
  `maxRetries` (default 3).

On a 429, the SDK honors `Retry-After` up to a cap (`maxRetryAfterMs`, default
~67 min in TS; `max_retry_after_seconds` in Python) so a client can ride out an
hour-bucket limit instead of throwing. Beyond the cap it throws the
`RateLimitError` and lets you decide. A silent-by-default `onRetry` / `on_retry`
hook fires once before each retry sleep with `{ attempt, error, delayMs / delay_seconds, method, path }`;
a throwing hook is swallowed so logging never breaks a retry.

## Idempotency for safe manual retries

Because a 5xx on a send is not auto-retried, retry it yourself *safely* with an
**idempotency key**. Pass `idempotencyKey` / `idempotency_key` to
`messages.send()` / `messages.reply()` (or `send_at`-bearing `drafts.create()`);
it travels as the `Idempotency-Key` header. A network-retried request with the
same key produces **at most one message and one charge** — the server replays the
prior outcome and returns the **same `message_id`**. The key is a stable per-send
identity and is permanent (no expiry).

```ts
import { randomUUID } from 'node:crypto';
const key = randomUUID(); // persist alongside the job
const sent = await rl.messages.send({ /* … */ }, { idempotencyKey: key });
// A same-key retry returns this same message_id instead of sending again.
```

Before retrying a send whose local inputs (a staged attachment, the original
message) may be gone, probe first with the **non-throwing**
`messages.getIdempotencyReplay(key)` / `messages.get_idempotency_replay(key)`. It
maps the idempotency states to a discriminated value rather than a throw:

| Result `kind` | Meaning |
|---|---|
| `miss` | No prior keyed send — proceed with the upload + keyed POST. |
| `replay` | A prior send replays; carries the prior `message`. |
| `in_flight` | A concurrent same-key send is mid-flight; carries `retryAfter` (from the response body). |
| `not_proven_sent` | A prior attempt is unproven; do not re-send. |
| `bound_to_draft` | The key is bound to a scheduled draft, not an immediate send. |

Any other non-2xx (401 / 403 / 500 …) still throws the original error.

## Webhook signature verification

Both SDKs export a standalone verify helper. Import it and call it on the **raw
request body** (never a parsed-then-re-serialized copy — that mutates whitespace
and invalidates the HMAC):

<!-- docs-lint:skip-compile -->
```ts
import { verifyWebhookSignature, WebhookSignatureError } from '@replylayer/sdk';
// Express-style handler
try {
  verifyWebhookSignature(rawBody, req.header('X-ReplyLayer-Signature')!, process.env.REPLYLAYER_WEBHOOK_SECRET!);
} catch (err) {
  if (err instanceof WebhookSignatureError) return res.status(400).send('bad signature');
}
```

<!-- docs-lint:skip-compile -->
```python
from replylayer import verify_webhook_signature, WebhookSignatureError
try:
    verify_webhook_signature(request.data, request.headers.get("X-ReplyLayer-Signature", ""), SECRET)
except WebhookSignatureError:
    return ("bad signature", 400)
```

Contract details:

- The signing header is `X-ReplyLayer-Signature: t=<unix_ts>,v1=<hex>`, where
  `<hex> = HMAC_SHA256(signing_secret, "<unix_ts>.<raw_body>")`.
- The helper returns `true` **only on success**. On any failure — malformed
  header, non-numeric timestamp, timestamp skew beyond tolerance, or signature
  mismatch — it **throws `WebhookSignatureError` and never returns `false`**. Wrap
  it in try/catch and respond 4xx on the throw.
- Default clock-skew `tolerance` is **300 seconds**; override it
  (`{ tolerance: 60 }` / `tolerance=60`) if your endpoint's clock is well-synced.

The event catalog and delivery/retry contract live on
[/agents/webhooks](/agents/webhooks).

## TypeScript / Python parity

The resource surface is identical method-for-method at the same released version.
Watch these language-shaped differences:

- **Async client (Python only).** `AsyncReplyLayer` exposes the same methods with
  `await` (and `async for` on auto-paginated lists). The TS SDK is a single
  promise-based client.
- **Client-side timezone check (Python only).** Passing a naive `datetime` to
  `send_at` raises `TimezoneRequiredError` **before** any HTTP call. The TS SDK
  has no such pre-check — it relies on the server's `400 TIMEZONE_REQUIRED` (a
  `SchedulingError`).
- **Units differ.** TS retry knobs are milliseconds (`maxRetryAfterMs`,
  `RetryInfo.delayMs`); Python uses seconds (`max_retry_after_seconds`,
  `RetryInfo.delay_seconds`).
- **Naming.** TS `strictOutcome` / `idempotencyKey` and `drafts.send({ async: true })`;
  Python `strict_outcome` / `idempotency_key` and `drafts.send(async_dispatch=True)`.
- **Return shapes.** TS returns typed response objects (`result.message_id`);
  Python returns dicts (`result["message_id"]`).

For CLI exit codes see [/agents/cli](/agents/cli); for attachment staging see
[/agents/attachments](/agents/attachments).
