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)TypeMeaning
statusCode / status_codenumber / intHTTP status.
codestringMachine-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>.
messagestringHuman-readable summary. Do not branch on this — branch on code.
detailsRecord<string, unknown> / dict[str, Any] | NoneStructured, code-specific payload. Untyped — read by key.

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

SubclassStatusExtra fields
AuthenticationError401
ForbiddenError403
NotFoundError404
ValidationError400 / 422
RateLimitError429retryAfter, limit, remaining, reset (parsed from Retry-After / X-RateLimit-*; null when absent/unparseable)
SchedulingErroranyreasonCode / reason_code — one of TIMEZONE_REQUIRED, SEND_AT_TOO_SOON, SEND_AT_TOO_FAR, SCHEDULED_SEND_QUOTA_EXCEEDED
WebhookSignatureError0Thrown 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 SDKdetails is Record<string, unknown> (TS) / dict[str, Any] (Python), so you read the envelope fields by key:

KeyMeaning
reason_axisWhy the request was denied (e.g. trust_capacity).
remedyThe axis-derived corrective action.
cheapest_next_stepThe lowest-cost unblock path.
upgrade_urlPresent 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):

SubclassStatuseffect_statusCorrect agent action
EmailEffectRejectedError422blockedTerminal content rejection — edit and resend, or escalate. Never retry the same body.
EmailEffectHeldError409held_for_reviewAccepted into governance (quarantine / human review). Report "awaiting approval"; it is releasable, not a content error.
EmailEffectRetryableError503held_infrastructureTransient 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 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).

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 kindMeaning
missNo prior keyed send — proceed with the upload + keyed POST.
replayA prior send replays; carries the prior message.
in_flightA concurrent same-key send is mid-flight; carries retryAfter (from the response body).
not_proven_sentA prior attempt is unproven; do not re-send.
bound_to_draftThe 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');
}
<!-- docs-lint:skip-compile -->
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.

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; for attachment staging see /agents/attachments.