@replylayer/sdk
v0.23.0
Published
Official TypeScript SDK for ReplyLayer — email for AI agents
Downloads
975
Maintainers
Readme
@replylayer/sdk
Official TypeScript SDK for ReplyLayer — secure email for AI agents.
Install
npm install @replylayer/sdkQuick start
import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });
// Create a mailbox
const mailbox = await rl.mailboxes.create({ name: 'support' });
// Send an email
const sent = await rl.messages.send({
from_mailbox: mailbox.name,
to: '[email protected]',
subject: 'Hello from my agent',
body: 'Hi there!',
});
// Wait for a reply (long-poll, up to 30s)
const { message } = await rl.messages.wait(mailbox.id);
if (message) {
console.log(`Got reply from ${message.sender}: ${message.subject}`);
}
// Browse conversation threads
const page = await rl.threads.list(mailbox.id);
for (const thread of page.data) {
console.log(`${thread.subject} (${thread.message_count} messages)`);
}Constructor options
new ReplyLayer({
apiKey: 'rl_live_...', // required
baseUrl: 'https://api.replylayer.ai', // default
maxRetries: 3, // retries on 429/5xx (0 = fail-fast)
timeout: 30_000, // ms per request
maxRetryAfterMs: 4_000_000, // cap on honoring a 429 Retry-After (default ~67min)
onRetry: (info) => {}, // silent-by-default retry hook
});Retry behavior
The client retries failed requests up to maxRetries times (default 3). The
contract — read it before relying on retries:
429is retried on every method, including mutating ones (POST/PATCH/DELETE). A429is a pre-dispatch rate-limit rejection, so nothing happened server-side — retrying is safe. The wait honors theRetry-Afterheader.5xxis retried only on non-mutating (GET) requests. A5xxon aPOST/PATCH/DELETEis not retried — the request may have executed, so a retry risks a double-send (or, forDELETE, retrying a lost-but-applied delete into a confusing404). To retry asend/replysafely, pass an idempotency key — see Idempotent sends.- Multipart uploads are never retried (a retry would re-send the body).
- Long
Retry-Aftervalues block up tomaxRetryAfterMs(default ~67 minutes, sized to ride out hour-bucket rate limits for batch jobs). When a serverRetry-Afterexceeds this cap, the SDK throws theRateLimitErrorrather than sleeping — it never clamps-and-retries into a still-limited window. Interactive callers should set a low cap (e.g.maxRetryAfterMs: 30_000). maxRetries: 0is fail-fast — no implicit retry of any kind. Recommended for interactive / agent contexts where a request should surface its error immediately. Branch onRateLimitError.retryAfterand retry intentionally.onRetryis silent by default — the SDK never writes to stdout/stderr. Pass anonRetry(info)hook to log or meter retries; it receives{ attempt, error, delayMs, method, path }, may be async (it's awaited), and a throwing/rejecting callback is swallowed so it can't break the retry.- A retry sleep is abort-aware: a
signalpassed to a request (e.g.messages.wait({ signal })) interrupts an in-flightRetry-Afterwait.
Idempotent sends
Because a 5xx on a send is not auto-retried (a blind retry risks a second
delivery + a second charge), the SDK gives you a way to retry it yourself
safely. messages.send, messages.reply, and scheduled sends via
drafts.create (with send_at) accept 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.
import { randomUUID } from 'node:crypto';
const key = randomUUID(); // stable per send intent — persist it with the job
// First call sends; a same-key retry replays the first result (no second send).
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 idThe key travels as the Idempotency-Key request header and is permanent (no
expiry). A non-throwing probe, rl.messages.getIdempotencyReplay(key), reports
whether a key already produced a result (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 message) may no longer be available.
Resources
| Resource | Methods |
|----------|---------|
| rl.domains | create, list, get, verify, updateSelfHostedConfig, delete, setDefault, recheck |
| rl.mailboxes | create, list, getMailbox, delete, update, setRecipientPolicy, setThreadReplies, setAgentSendContainment, setAgentSendPolicy, setAttachmentAccess, setSenderPolicy |
| rl.mailboxes.allowlist | list, add, addBulk, delete, listBlockedAttempts |
| rl.mailboxes.inboundAllowlist | list, add, addBulk, delete, blockedAttempts |
| rl.messages | send, list, get, reply, getIdempotencyReplay, wait, release, block, report, delete, firewallRelease, markRead, setStarred, approveReview, denyReview |
| rl.drafts | create, get, list, update, send, delete |
| rl.threads | list, get, markRead, setStarred |
| rl.attachments | getDownloadUrl, getPreview, upload, getUpload, deleteUpload |
| rl.webhooks | create, list, get, update, delete, rotateSecret, test, listDeliveries, retryDelivery |
| rl.recipients | create, list, delete, resend |
| rl.suppressions | list, add, addBulk, delete |
| rl.inboundBlocklist | list, add, addBulk, delete |
| rl.apiKeys | create, list, revoke, rotate* |
| rl.account | getUsage, getQuota, getLinkScanningStatus, enableLinkScanning, export |
| rl.legalHolds | apply, release, list, get |
| rl.health | check |
*apiKeys.rotate() revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new ReplyLayer instance with the returned key.
Drafts: scan-then-review-then-send
rl.drafts.create() runs the outbound scanner synchronously and attaches the verdict to the draft. The create-time verdict is UX — it lets an agent (or a human approver) see the likely outcome before clicking send. rl.drafts.send() re-runs the scanner authoritatively against the mailbox's current policy, so a stale cached verdict cannot slip through.
const draft = await rl.drafts.create({
mailbox_id: mailbox.name,
to: '[email protected]',
subject: 'Re: your invoice',
body: 'Thanks for your question.',
});
if (draft.worst_decision === 'allow') {
const result = await rl.drafts.send(draft.id);
console.log(`Sent ${result.message_id}`);
}The send/reply/draft-send response carries two additive, nullable fields that explain a held send inline (no second read call). result.scan is the vendor-neutral scanner verdict (ScanSummary); result.hold_context ({ trigger_source, summary_reasons } or null) is the policy/human-review reason, non-null only when the delivery status diverges from scan.verdict because of a policy/human-review hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (trigger_source: mailbox_policy | scanner | both).
By default drafts.send() is synchronous — it returns SendMessageResponse once the scanner verdict is known, with scan and hold_context inline. Pass { async: true } to request the optimistic-ack path via the Prefer: respond-async header. The header is an advisory hint — the server decides. When async dispatch is available the server returns 202 with status: 'queued_for_dispatch' (AsyncSendAck) and the scanner runs in the background, so scan and hold_context are absent on the 202; otherwise the hint is ignored and you get the normal 200 SendMessageResponse. Always branch on the response — check status === 'queued_for_dispatch' rather than assuming a 202. For a queued send, poll messages.get(message_id) or listen for message.delivered / message.dispatch_failed webhooks to observe the terminal state. (messages.wait() is a mailbox long-poll for new inbound mail, not a way to observe a specific outbound message by ID.)
The send endpoint returns 409 with distinct codes:
DRAFT_REJECTED_BY_RESCAN— send-time scan flipped the verdict toblock/quarantine. The draft stays indraftstate; edit the body and retry.err.detailscarriesscan,releasable(truefor aquarantinehold the customer can release viaPOST /v1/drafts/:id/release-and-send,falsefor a terminalblock), and, when a policy/human-review decision drove the hold,hold_context.DRAFT_ALREADY_SENT— the draft was already sent (race or retry after success).
try {
await rl.drafts.send(draft.id);
} catch (err) {
if (err instanceof ReplyLayerError && err.code === 'DRAFT_REJECTED_BY_RESCAN') {
console.error('Rescan blocked it:', err.details);
}
}Outbound attachments (Pro+)
Attaching a file is a two-phase flow: upload the bytes to stage a handle, then reference handle.id in a send/reply/draft attachment_ids array. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text and filename) before it leaves. The mailbox must have outbound attachments explicitly enabled by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox return 403 OUTBOUND_ATTACHMENTS_DISABLED.
// Phase 1 — stage the file (returns an opaque handle).
const handle = await rl.attachments.upload({
mailboxId: 'support',
file: await readFile('invoice.pdf'), // Buffer/Uint8Array/Blob/ArrayBuffer
filename: 'invoice.pdf',
contentType: 'application/pdf', // advisory — the server re-sniffs the bytes
});
// The content scan runs asynchronously. Poll until it leaves `pending`.
let status = handle.content_scan_status; // 'pending' at upload time
while (status === 'pending') {
await new Promise((r) => setTimeout(r, 1000));
const polled = await rl.attachments.getUpload(handle.id);
if ('status' in polled) break; // already consumed
status = polled.content_scan_status;
}
// Phase 2 — reference the handle on a send. `clean` and `flagged` both send
// (a `flagged` finding flows to the message verdict, like a body finding);
// `error` is fail-closed.
const result = await rl.messages.send({
from_mailbox: 'support',
to: '[email protected]',
subject: 'Your invoice',
body: 'Attached.',
attachment_ids: [handle.id],
});A handle is consumed once at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with rl.attachments.deleteUpload(handle.id). Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (OUTBOUND_IMAGE_DISCLAIMER_REQUIRED). Drafts hold handles and consume them at dispatch; rl.drafts.update({ attachment_ids: null }) clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).
Recipient allowlist (mailbox containment)
By default a mailbox uses blocklist mode — the pre-send gate rejects recipients on suppressed_addresses and allows everyone else. Switching a mailbox to allowlist mode contains agent-origin outbound to a fixed list of recipients (plus thread participants): a prompt-injected or compromised agent key cannot email outside the list. It is a containment boundary against a hijacked agent, not an all-origin lock — a human send (your dashboard session or an admin API key) is not restricted by the allowlist; only your do-not-contact (suppression) list binds a human send.
// Populate the allowlist first. Admin-only — agent keys get 403 INSUFFICIENT_SCOPE.
await rl.mailboxes.allowlist.add(mailbox.id, { email: '[email protected]' });
await rl.mailboxes.allowlist.addBulk(mailbox.id, { emails: ['[email protected]', '[email protected]'] });
// Flip the mode. Server returns 400 ALLOWLIST_EMPTY if the list is empty
// unless you pass forceEmpty: true to acknowledge the lockout.
await rl.mailboxes.setRecipientPolicy(mailbox.id, 'allowlist');
// Sends to off-list recipients now 403 with code RECIPIENT_NOT_ON_ALLOWLIST.
// Blocklist still runs first — a recipient on the do-not-contact list is
// rejected 403 with code RECIPIENT_SUPPRESSED (details.reason === 'suppressed').
// Deleting the last entry while in allowlist mode returns 409 ALLOWLIST_LAST_ENTRY;
// pass forceEmpty: true to acknowledge.
await rl.mailboxes.allowlist.delete(mailbox.id, '[email protected]', { forceEmpty: true });A send/reply/draft-send to a recipient on your do-not-contact (suppression) list raises ReplyLayerError with err.code === 'RECIPIENT_SUPPRESSED' (HTTP 403, details.reason === 'suppressed'). This is terminal — escalate, don't retry; remove the suppression or send to a different recipient.
Allowlist mutations are admin-only — granting mutation to an LLM would defeat the agent-containment boundary. Agents can list (so they can see what they're allowed to email) but not add/addBulk/delete. Three new webhook events emit on change: recipient_allowlist.added, recipient_allowlist.removed, mailbox.recipient_policy_changed.
Domain entries
Entries can be either an exact email ([email protected]) or a bare-domain pattern (@corp.com) that matches every address at that domain. Exact-domain only — @corp.com matches *@corp.com but NOT [email protected].
// Allow everyone at @partner.com — one entry covers every address there.
await rl.mailboxes.allowlist.add(mailbox.id, { email: '@partner.com' });
// Block a whole competitor domain.
await rl.suppressions.add({ email: '@competitor.com' });
// Bulk mix emails + domains; partial-success buckets handle invalids.
const bulk = await rl.mailboxes.allowlist.addBulk(mailbox.id, {
emails: ['[email protected]', '@partner.com', 'not-an-email'],
});
// bulk.added[0].pattern_type === 'email'
// bulk.added[1].pattern_type === 'domain'
// bulk.invalid[0] === { email: 'not-an-email', reason: 'invalid_format' }Responses expose pattern_type: 'email' | 'domain' on every add/list/delete/bulk-added row. The field is optional on the type — pre-0.5.0 servers omit it.
Blocklist precedence still holds: a domain-block beats an exact-allow at the same domain. Malformed patterns (@, @.com, @foo, @corp-.com, non-ASCII) return 400 INVALID_EMAIL (message: "Invalid email or domain pattern").
Blocked attempts
Every send the allowlist gate rejects writes an append-only audit row and emits a deduped recipient_allowlist.blocked_attempt webhook. Review the log to see what your agent tried to email and one-click add legitimate recipients.
// Aggregated top-N view — grouped by (recipient, actor_id).
// next_cursor is always null for aggregated; the view is top-N, not paginated.
const { attempts } = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id);
for (const a of attempts) {
console.log(`${a.recipient} × ${a.count} (last: ${a.last_attempted_at})`);
}
// "Blocked this week" — same aggregate with a recency filter (1..365 days).
const week = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id, {
withinDays: 7,
});
// Raw per-attempt history for forensic drill-in. Paginates via tuple cursor.
const raw = await rl.mailboxes.allowlist.listBlockedAttempts(mailbox.id, {
aggregate: false,
limit: 100,
});Webhook deliveries are deduped server-side to at most one per (account, mailbox, recipient) per 60 seconds — a looping agent produces one delivery, not hundreds, keeping your subscription below the 20-abandoned-deliveries auto-disable threshold. Full attempt history is always available via listBlockedAttempts.
The MCP tool list_allowlist_blocked_attempts exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
Delivery history & manual retry
rl.webhooks.listDeliveries(id, { limit?, before_at?, before_id? }) returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. before_at and before_id must be provided together — the SDK omits the cursor entirely if only one is given.
let page = await rl.webhooks.listDeliveries(webhookId, { limit: 50 });
while (page.has_more) {
page = await rl.webhooks.listDeliveries(webhookId, {
limit: 50,
before_at: page.next_before_at,
before_id: page.next_before_id,
});
}rl.webhooks.retryDelivery(webhookId, deliveryId) re-queues a single failed delivery. The API rejects retries on non-failed deliveries or deliveries whose parent webhook is disabled — surfaced as ReplyLayerError with .code set to DELIVERY_NOT_FAILED or WEBHOOK_DISABLED:
try {
await rl.webhooks.retryDelivery(webhookId, deliveryId);
} catch (err) {
if (err instanceof ReplyLayerError && err.code === 'WEBHOOK_DISABLED') {
// Resume the webhook (PATCH enabled=true) before retrying.
}
}Mailbox settings
Each mailbox carries a scanner policy and a PII delivery mode:
// Redact PII before delivering inbound bodies to the agent
await rl.mailboxes.update(mailbox.id, {
scanner_policy: { language_mode: 'english_only' },
pii_mode: 'redacted',
});pii_mode values:
'passthrough'(default) — message reads returnbody.contentas the plaintext display projection; dashboard session callers may opt into sanitized HTML.'redacted'—body.contentis plaintext with detected PII spans replaced by<TYPE>tags (e.g.<EMAIL_ADDRESS>,<PHONE_NUMBER>). Requires Starter tier or above; sandbox accounts get403 TIER_LIMIT.
pii_mode='redacted' also applies to outbound webhook payloads: message.* events have sender/recipient/to → <EMAIL_ADDRESS> and subject → <REDACTED> before signing. The HMAC covers the redacted body — verifyWebhookSignature works without any client-side changes.
Advanced PII config (Pro+)
pii_redaction_config gives per-detector control over redaction (e.g. "leave email visible, redact everything else") and operator-level rendering (partial_mask for credit cards, hash_replace for emails you want to dedupe without exposing). Pro+ feature; only meaningful when pii_mode='redacted'.
// Per-detector toggle: show emails to the agent, keep everything else redacted.
await rl.mailboxes.update(mailbox.id, {
pii_mode: 'redacted',
pii_redaction_config: {
EMAIL_ADDRESS: { redact: false },
},
});
// partial_mask: render credit cards as ****-****-****-1111 (separators preserved).
// `keep_last` is 1-6; `mask_char` defaults to '*'.
await rl.mailboxes.update(mailbox.id, {
pii_redaction_config: {
CREDIT_CARD: {
redact: true,
operator: { kind: 'partial_mask', keep_last: 4 },
},
},
});
// hash_replace: <EMAIL_ADDRESS:a3f1b9c2>. Deterministic per account; opaque
// across accounts. Lets your agent dedupe without seeing raw values.
await rl.mailboxes.update(mailbox.id, {
pii_redaction_config: {
EMAIL_ADDRESS: {
redact: true,
operator: { kind: 'hash_replace' },
},
},
});
// Reset to platform default.
await rl.mailboxes.update(mailbox.id, { pii_redaction_config: {} });Tier gate. Any non-default value (a redact: false entry OR an operator with kind: 'partial_mask' or kind: 'hash_replace') requires the pii_advanced_controls feature (Pro+). Non-feature accounts can still PATCH {}, null, or default-shape entries ({ redact: true }, { kind: 'replace_with_type' }).
partial_mask whitelist. PERSON and EMAIL_ADDRESS are rejected (422) — partial-masking a name produces nonsense; partial-masking an email is hard to do well in v1. Use hash_replace for those instead.
Downgrade behavior. If you configure non-default pii_redaction_config on Pro and then downgrade, the persisted JSONB stays on the row but the read-side IGNORES it. Reads fall back to platform default. Re-upgrading restores the config instantly. The dashboard surfaces a "Saved but inactive" banner in this state.
Webhook scope-out. Advanced PII config does NOT apply to webhook payload metadata. Webhook delivery continues to use pii_mode for envelope-level field redaction; per-detector and operator control is API read-side only. Hosted webhook docs live at https://replylayer.ai/docs/webhooks.
Outbound PII send safety. ScannerPolicy.outbound_pii_policy tunes send decisions for the local outbound PII scanner by type:
await rl.mailboxes.update(mailbox.id, {
scanner_policy: {
outbound_pii_policy: {
ssn: 'quarantine',
credit_card: 'review',
phone_number: 'allow_with_warning',
},
outbound_review_policy: {
approval_note: 'required_for_sensitive_pii',
},
},
});Supported actions are allow, allow_with_warning, review, quarantine, and block. review routes matching sends to Pending approval; enabling it requires both Pro+ outbound PII controls and the review queue feature. Relaxing below platform defaults requires Pro+ (pii_advanced_controls); default or stricter values are accepted on every tier. Outbound PII scan results include pii_type?: 'ssn' | 'credit_card' | 'phone_number' so clients can inspect which type drove the action.
Approval notes are optional by default. Set outbound_review_policy.approval_note to required_for_sensitive_pii when approvers must add a note before sending SSN or credit-card review holds.
Agent Attachment Access
Each mailbox has a per-mailbox gate (attachment_access_enabled, default off) that controls whether agent-role API keys can download attachment bytes via the attachment endpoint. Admin keys, pre-scoping keys, and dashboard sessions always bypass. When the flag is off for a mailbox, agent-key requests return 403 ATTACHMENT_ACCESS_DISABLED — surfaced as ReplyLayerError with .code === 'ATTACHMENT_ACCESS_DISABLED':
try {
await rl.attachments.getDownloadUrl(messageId, 0);
} catch (err) {
if (err instanceof ReplyLayerError && err.code === 'ATTACHMENT_ACCESS_DISABLED') {
// Admin can enable via the dashboard (Mailbox settings → Agent Attachment Access)
// or via POST /v1/mailboxes/:id/attachment-access (requires accept_disclaimer_version).
}
}Why it exists: ReplyLayer scans attachment bytes (virus/AV, MIME mismatch, policy) but does NOT extract text from raw downloads for prompt-injection scanning. Images are a separate raw-download family because prompt-injection text can be embedded in pixels and later exposed by OCR, vision models, or multimodal LLMs. Including image requires accept_image_risk_version unless the mailbox already has current image-risk acceptance. See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full policy contract.
Human dashboard sessions and admin/pre-scoping keys can download clean stored metadata_only attachments, including attachments held back from agent raw-download policy. Agent-role keys remain bound to the mailbox policy gate plus hard safety checks; all callers remain blocked by infected AV verdicts, non-terminal message states, missing stored bytes, and hard attachment blocks.
See the Mailboxes API reference at https://replylayer.ai/docs/api/mailboxes for the full contract and known limitations.
Malicious link scanning (URL reputation)
Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an admin action — it turns on an account-wide sub-processor data flow — so agent-scoped keys can read status but not enable it (they get ForbiddenError).
Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
const status = await rl.account.getLinkScanningStatus();
// { active, accepted_version, current_version, privacy_ok }
if (!status.active && status.privacy_ok) {
// current_version is the disclaimer you are acknowledging
const res = await rl.account.enableLinkScanning({ accept_web_risk_version: status.current_version });
// res.url_reputation.active === true; res.disclosure.notice / res.disclosure.advisory_url
}If privacy_ok is false the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (enableLinkScanning would otherwise fail with a 409 PRIVACY_VERSION_TOO_OLD).
Trusted instruction sources
Every inbound message read carries agent_safety_context with untrusted_content: true — the body is external data, not instructions for the agent to act on. Trusted instruction sources is a read-path-only relaxation of that default for one specific, customer-designated, verified sender address per mailbox (address-grain only — there is no domain-wide grant). It does not change what a resulting send is allowed to do; the only related send-side control is a per-mailbox strict-recipient toggle, configured outside the SDK.
The relaxation is entirely operator-configured, server-side — there is no client-side opt-in and no request header to send. It applies on a read only when every layer is configured: the platform feature is enabled, the mailbox's instruction-trust mode is on, the designated sender's message passed Mailgun sender verification (verified_aligned), the message scanned clean and is available, and the reading API key is a role='agent' key with the per-key capability enabled. All of that configuration happens in the dashboard (each a loosening change requiring session re-auth); the SDK simply returns whatever the server decides:
const rl = new ReplyLayer({
apiKey: process.env.REPLYLAYER_API_KEY!,
});
const { message } = await rl.messages.wait(mailbox.id);
if (message?.agent_safety_context?.instruction_trust) {
// Gate passed: guidance was REPLACED with trusted-instruction wording (the
// agent may act on this verified sender's own explicit request in this
// message). untrusted_content is still true — this is metadata, not a
// content-safety judgment.
console.log(message.agent_safety_context.guidance);
console.log(message.agent_safety_context.instruction_trust);
// { version: 'v1', match: 'address', verified_domain, verdict: 'verified_aligned', provenance: 'managed' }
}A human account owner enables the mailbox mode and the key's capability, and designates the trusted sender, from the dashboard. Both the mailbox mode and the per-key capability default to off, so existing integrations are unaffected until a customer opts in — and because relaxation is decided purely server-side, a copied or hijacked API key cannot influence it.
Mailbox identifiers
Every SDK method that takes a mailboxId argument accepts either the mailbox's UUID or its name. The server resolves names against the authenticated account's active mailboxes. rl.messages.list('support-bot', ...) and rl.messages.list('a1b2...', ...) are equivalent.
Pagination
List endpoints return a Page<T> with data, hasMore, and cursor:
const page = await rl.messages.list(mailboxId, { limit: 50 });
console.log(page.data); // MessageSummary[]
console.log(page.hasMore); // boolean
console.log(page.cursor); // string | undefinedPass autoPaginate: true for an async iterator:
for await (const msg of rl.messages.list(mailboxId, { autoPaginate: true })) {
console.log(msg.subject);
}Error handling
import { ReplyLayer, RateLimitError, NotFoundError } from '@replylayer/sdk';
try {
await rl.messages.get('nonexistent');
} catch (err) {
if (err instanceof NotFoundError) {
console.log('Message not found');
} else if (err instanceof RateLimitError) {
console.log(`Rate limited, retry after ${err.retryAfter}s`);
}
}Error classes: ReplyLayerError (base), AuthenticationError (401), ForbiddenError (403), NotFoundError (404), ValidationError (400/422), RateLimitError (429).
Webhook signature verification
For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see https://replylayer.ai/docs/webhooks.
Verify inbound webhook signatures from ReplyLayer:
import { verifyWebhookSignature } from '@replylayer/sdk';
const isValid = verifyWebhookSignature(
requestBody, // raw body string
request.headers['x-replylayer-signature'],
process.env.WEBHOOK_SIGNING_SECRET!,
{ tolerance: 300 }, // optional, seconds (default 300)
);Once verified, parse and dispatch on the event type. The discriminator field is event, not type:
const payload = JSON.parse(requestBody);
// payload.event is the discriminator — NOT payload.type
if (payload.event === 'message.received') {
// handle inbound message
} else if (payload.event === 'message.dispatch_failed') {
// handle failed outbound send
}Requirements
- Node.js >= 20
- No external dependencies (uses native
fetch)
License
MIT
