Skip to main content
@senderkit/sdk is the official TypeScript client. It is ESM + CJS, zero-dependency, fully typed, and runs on Node.js 18+ and edge runtimes (it uses the global fetch). It wraps the REST API with typed requests, automatic retries with backoff, idempotency, and batch helpers.

Install

npm install @senderkit/sdk
pnpm add @senderkit/sdk
bun add @senderkit/sdk

Quickstart

import { SenderKit } from "@senderkit/sdk";

const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });

const result = await senderkit.send({
  template: "welcome",
  to: "user@example.com",
  vars: { name: "Ada" },
});

console.log(result.id); // "msg_…"

Client construction

new SenderKit(options: SenderKitOptions)
apiKey
string
required
Your API key. Must start with sk_live_ (live mode) or sk_test_ (test mode); the constructor throws a TypeError otherwise. The SDK does not read process.env for you — pass it explicitly. See Authentication.
baseUrl
string
default:"https://api.senderkit.com"
Override the API base URL. Useful for pointing at a proxy or a self-hosted gateway.
timeout
number
default:"30000"
Per-request timeout in milliseconds. A timed-out request rejects with SenderKitTimeoutError (after retries are exhausted).
maxRetries
number
default:"2"
Max retry attempts for transient failures — network errors, timeouts, 429, and 5xx (except 501). Retries use exponential backoff with jitter and honor a Retry-After response header.
fetch
typeof fetch
Inject a custom fetch implementation — handy for tests or edge runtimes that don’t expose a global fetch. Defaults to globalThis.fetch.
mode
"live" | "test"
Read-only property (not a constructor option). Derived from the API key prefix: sk_test_…"test", anything else → "live". Useful for conditional logging or safety checks: if (senderkit.mode === "test") ….

send()

Send a templated message, interpolating variables at send time.
senderkit.send(request: SendRequest): Promise<SendResponse>
template
string
required
Template slug, e.g. "welcome".
to
string
required
Recipient address — email, phone number, or push token.
vars
Record<string, unknown>
Template variables. Defaults to {}.
channel
"email" | "sms" | "push" | "web-push"
Force a channel. Defaults to the template’s primary channel.
version
number
Pin a specific template version. Omit to use the current published version for the environment.
metadata
Record<string, string | number | boolean>
Free-form metadata attached to the message. Indexed server-side, so you can later filter with messages.list({ metadata }).
scheduledAt
string | Date
Defer delivery to a future time — an ISO 8601 string or a Date. Must be in the future and within 30 days. The response comes back with status: "scheduled". See Sending.
idempotencyKey
string
Idempotency key. If omitted, the SDK auto-generates one so a retried request never duplicates a send. Reusing a key returns the original message.
cc
string[]
Cc recipients. Email only.
bcc
string[]
Bcc recipients. Email only.
replyTo
string
Reply-To address. Email only.
attachments
Attachment[]
File or inline attachments (email only). Each Attachment is { filename, contentType, content, inline?, contentId? } where content is base64-encoded bytes. Provider caps the total across all attachments at 10 MB.
from
string
Per-message From address override (email only, bare address). Falls back to the connection’s From address. On managed sending it’s honored only on the workspace’s verified custom sending domain.
fromName
string
Per-message From display name override (email only), rendered as Name <address>. Falls back to the connection’s From name. Max 128 characters; no control characters or angle brackets. Unlike from, it always applies regardless of sending domain.

Response

id
string
Message id, e.g. "msg_…".
status
"queued" | "scheduled"
"scheduled" when scheduledAt is in the future, otherwise "queued".
livemode
boolean
Whether the request ran against live mode. Derived from the API key prefix.
const res = await senderkit.send({
  template: "receipt",
  to: "user@example.com",
  vars: { amount: "$42.00" },
  cc: ["accounting@acme.com"],
  metadata: { orderId: "ord_9" },
  idempotencyKey: "receipt:ord_9",
});
// { id: "msg_…", status: "queued", livemode: true }

sendRaw()

Send inline content without a registered template. The content shape is selected by channel.
senderkit.sendRaw(request: SendRawRequest): Promise<SendResponse>
SendRawRequest is a discriminated union on channel. Shared fields: to (required), vars, metadata, interpolate, scheduledAt, idempotencyKey. By default content is delivered verbatim — set interpolate: true to run server-side variable substitution over it. Returns the same SendResponse as send().
content: RawEmailContent{ subject, html, preheader?, text? } plus the email envelope fields (cc, bcc, replyTo, attachments). Two top-level From overrides are also available, identical to send(): from? (bare address; must match a verified custom sending domain on managed sending) and fromName? (display name, always applies).
await senderkit.sendRaw({
  channel: "email",
  to: "user@example.com",
  from: "hello@acme.com",
  fromName: "Acme",
  content: {
    subject: "Welcome, {{name}}",
    html: "<p>Hello, {{name}}.</p>",
  },
  vars: { name: "Ada" },
  interpolate: true,
});

sendBatch()

Send many messages with bounded concurrency. Never throws for individual failures — each result reports success or the error for that item, so one bad recipient doesn’t sink the batch.
senderkit.sendBatch(
  requests: Array<SendRequest | SendRawRequest>,
  options?: BatchSendOptions,
): Promise<BatchSendResult[]>
options.concurrency
number
default:"5"
Max parallel in-flight requests.
options.idempotencyKey
string
Base key. Each item is sent with ${key}-${index} (unless the item carries its own idempotencyKey).
Each BatchSendResult is one of:
| { ok: true;  index: number; id: string; status: "queued" | "scheduled"; livemode: boolean }
| { ok: false; index: number; error: SenderKitError }
const results = await senderkit.sendBatch(
  recipients.map((to) => ({ template: "digest", to })),
  { concurrency: 10, idempotencyKey: "digest:2026-05-31" },
);

const failed = results.filter((r) => !r.ok);
if (failed.length) console.error(`${failed.length} sends failed`);

context()

Fetch the workspace the API key belongs to and the active send mode.
senderkit.context(): Promise<SenderKitContext>
Returns { workspace: { id, slug, name }, mode: "live" | "test" }. Useful to confirm which workspace subsequent calls will affect — mirrors GET /v1/context and the senderkit_context MCP tool.
const ctx = await senderkit.context();
console.log(ctx.workspace.name); // "Acme Inc"
console.log(ctx.mode);           // "live"

Templates

senderkit.templates.list(): Promise<Template[]>
senderkit.templates.get(slug: string): Promise<Template>
list() returns templates without their version body. get(slug) includes currentVersion ({ versionNumber, variables, publishedAt }). A Template has slug, channel, description, status, and updatedAt.
The content (raw HTML/blocks) field is intentionally omitted from both list() and get() responses to keep payloads lean. Use the dashboard or the /v1/templates/{slug}/render endpoint when you need the rendered output.
const templates = await senderkit.templates.list();
const welcome = await senderkit.templates.get("welcome");
console.log(welcome.currentVersion?.versionNumber);

Messages

senderkit.messages.list(params?: ListMessagesParams): Promise<ListMessagesResponse>
senderkit.messages.get(id: string): Promise<Message>
senderkit.messages.cancel(id: string): Promise<CancelMessageResponse>
params.limit
number
Max messages to return. The server applies its own default and cap.
params.cursor
string
Pagination cursor — pass the previous response’s nextCursor.
params.status
string
Filter by status, e.g. "delivered", "failed", "blocked".
params.channel
"email" | "sms" | "push" | "web-push"
Filter by channel.
params.template
string
Filter by template slug.
params.metadata
Record<string, string | number | boolean>
Filter by metadata attached at send time. Every key/value pair must match.
list() resolves to { data: Message[]; nextCursor: string | null }. cancel(id) only works on scheduled or queued messages — later states return a 409 (SenderKitApiError) — and resolves to { id, status: "canceled" }.
Message reads are lean — the rendered content blob is intentionally omitted to keep payloads manageable. The vars, timeline, and metadata attached at send time are still returned on every message.
blocked statusMessage.status can be "blocked" when outbound abuse detection halts a send before it reaches a provider. The message timeline records a generic notice ("Blocked by automated content safety checks."); detailed detection signals are operator-only and not returned in customer API responses. Filter for blocked messages with messages.list({ status: "blocked" }).
EngagementMessage.openedAt / Message.clickedAt record the first provider-reported email open / link click as an ISO 8601 string, or null if it hasn’t happened yet. Each is set once, on the first occurrence — later opens or clicks don’t update it. Subscribe to the message.opened / message.clicked webhook events to be notified as they happen.
// Page through every failed email
let cursor: string | undefined;
do {
  const page = await senderkit.messages.list({
    status: "failed",
    channel: "email",
    cursor,
    limit: 100,
  });
  for (const m of page.data) console.log(m.publicId, m.recipient);
  cursor = page.nextCursor ?? undefined;
} while (cursor);

// Cancel a scheduled message
await senderkit.messages.cancel("msg_…");

Error handling

Every error extends SenderKitError. API errors carry status, code, issues, and requestId (echoed from the x-request-id response header — quote it in support requests).
ClassThrown whenExtra properties
SenderKitAuthenticationError401 — bad, missing, or revoked keystatus, code, issues, requestId
SenderKitPermissionError403 — key is valid but lacks the required scope; extends SenderKitApiError (see Scopes)status, code, issues, requestId
SenderKitValidationError400 / 422 — invalid requeststatus, code, issues, requestId
SenderKitRateLimitError429 — rate limitedretryAfter (milliseconds, when the server sent Retry-After) + the above
SenderKitApiErrorany other non-2xx (e.g. 409, 5xx)status, code, issues, requestId
SenderKitTimeoutErrorrequest exceeded timeout
SenderKitNetworkErrornetwork-level failurecause
The SDK already retries transient failures (429, 5xx, network, timeout) up to maxRetries with backoff, so a thrown error means retries were exhausted.
import {
  SenderKit,
  SenderKitPermissionError,
  SenderKitRateLimitError,
  SenderKitValidationError,
  SenderKitError,
} from "@senderkit/sdk";

try {
  await senderkit.send({ template: "welcome", to: "user@example.com" });
} catch (err) {
  if (err instanceof SenderKitValidationError) {
    console.error("Bad request:", err.issues);
  } else if (err instanceof SenderKitPermissionError) {
    console.error("Permission denied — key lacks required scope:", err.code);
  } else if (err instanceof SenderKitRateLimitError) {
    console.error(`Rate limited; retry after ${err.retryAfter}ms`);
  } else if (err instanceof SenderKitError) {
    console.error("Send failed:", err.message);
  } else {
    throw err;
  }
}

Exports

The package exports the SenderKit class, every error class above, the VERSION constant, and types including SenderKitOptions, SenderKitContext, ApiScope, SendRequest, SendRawRequest, SendResponse, BatchSendOptions, BatchSendResult, Attachment, EmailEnvelope, Template, Message, ListMessagesParams, ListMessagesResponse, and CancelMessageResponse.

Sending

What a send accepts now and delivers later.

Messages

The lifecycle behind messages.list and cancel.

HTTP API

Integrate without the SDK, via raw fetch.

API Reference

The underlying REST endpoints.