SDK contract
Two first-party SDKs wrap the REST API method-for-method:
npm install @replylayer/sdk # TypeScript / Node
pip install replylayer # Python (sync + async)import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });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; the webhook event
catalog → /agents/webhooks; message states and verdict
vocabulary → /agents/messages; the send-gate decision tree →
/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). 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.
import { ReplyLayerError, ForbiddenError, RateLimitError } from '@replylayer/sdk';
try {
await rl.messages.send({ from_mailbox: 'support', to: '[email protected]', 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; tier and sandbox limits live on
/docs/limits.
try:
rl.messages.send(from_mailbox="support", to="[email protected]", 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; the gate that produces these outcomes is on
/agents/send-gates.
const rl = new ReplyLayer({ apiKey: KEY, strictOutcome: true });
try {
await rl.messages.send({ from_mailbox: 'support', to: '[email protected]', 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
GETrequests. A 5xx onPOST/PATCH/DELETEis 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 bymaxRetries(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).
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 -->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');
}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
trueonly on success. On any failure — malformed header, non-numeric timestamp, timestamp skew beyond tolerance, or signature mismatch — it throwsWebhookSignatureErrorand never returnsfalse. Wrap it in try/catch and respond 4xx on the throw. - Default clock-skew
toleranceis 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.
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).
AsyncReplyLayerexposes the same methods withawait(andasync foron auto-paginated lists). The TS SDK is a single promise-based client. - Client-side timezone check (Python only). Passing a naive
datetimetosend_atraisesTimezoneRequiredErrorbefore any HTTP call. The TS SDK has no such pre-check — it relies on the server's400 TIMEZONE_REQUIRED(aSchedulingError). - Units differ. TS retry knobs are milliseconds (
maxRetryAfterMs,RetryInfo.delayMs); Python uses seconds (max_retry_after_seconds,RetryInfo.delay_seconds). - Naming. TS
strictOutcome/idempotencyKeyanddrafts.send({ async: true }); Pythonstrict_outcome/idempotency_keyanddrafts.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; for attachment staging see /agents/attachments.