> ## Documentation Index
> Fetch the complete documentation index at: https://docs.senderkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# TypeScript SDK

> The official @senderkit/sdk client for Node.js and edge runtimes.

`@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](/api-reference/introduction) with
typed requests, automatic retries with backoff, idempotency, and batch helpers.

## Install

<CodeGroup>
  ```bash npm theme={null}
  npm install @senderkit/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @senderkit/sdk
  ```

  ```bash bun theme={null}
  bun add @senderkit/sdk
  ```
</CodeGroup>

## Quickstart

```ts theme={null}
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

```ts theme={null}
new SenderKit(options: SenderKitOptions)
```

<ParamField path="apiKey" type="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](/authentication).
</ParamField>

<ParamField path="baseUrl" type="string" default="https://api.senderkit.com">
  Override the API base URL. Useful for pointing at a proxy or a self-hosted
  gateway.
</ParamField>

<ParamField path="timeout" type="number" default="30000">
  Per-request timeout in milliseconds. A timed-out request rejects with
  `SenderKitTimeoutError` (after retries are exhausted).
</ParamField>

<ParamField path="maxRetries" type="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.
</ParamField>

<ParamField path="fetch" type="typeof fetch">
  Inject a custom `fetch` implementation — handy for tests or edge runtimes
  that don't expose a global `fetch`. Defaults to `globalThis.fetch`.
</ParamField>

<ParamField path="mode" type="&#x22;live&#x22; | &#x22;test&#x22;">
  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") …`.
</ParamField>

## `send()`

Send a [templated message](/concepts/templates), interpolating
[variables](/concepts/variables) at send time.

```ts theme={null}
senderkit.send(request: SendRequest): Promise<SendResponse>
```

<ParamField path="template" type="string" required>
  Template slug, e.g. `"welcome"`.
</ParamField>

<ParamField path="to" type="string" required>
  Recipient address — email, phone number, or push token.
</ParamField>

<ParamField path="vars" type="Record<string, unknown>">
  Template variables. Defaults to `{}`.
</ParamField>

<ParamField path="channel" type="&#x22;email&#x22; | &#x22;sms&#x22; | &#x22;push&#x22; | &#x22;web-push&#x22;">
  Force a channel. Defaults to the template's primary channel.
</ParamField>

<ParamField path="version" type="number">
  Pin a specific template [version](/concepts/versioning). Omit to use the
  current published version for the environment.
</ParamField>

<ParamField path="metadata" type="Record<string, string | number | boolean>">
  Free-form metadata attached to the message. Indexed server-side, so you can
  later filter with `messages.list({ metadata })`.
</ParamField>

<ParamField path="scheduledAt" type="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](/concepts/sending).
</ParamField>

<ParamField path="idempotencyKey" type="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.
</ParamField>

<ParamField path="cc" type="string[]">
  Cc recipients. Email only.
</ParamField>

<ParamField path="bcc" type="string[]">
  Bcc recipients. Email only.
</ParamField>

<ParamField path="replyTo" type="string">
  Reply-To address. Email only.
</ParamField>

<ParamField path="attachments" type="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.
</ParamField>

<ParamField path="from" type="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](/concepts/channels-and-providers#custom-sending-domains).
</ParamField>

<ParamField path="fromName" type="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.
</ParamField>

### Response

<ResponseField name="id" type="string">
  Message id, e.g. `"msg_…"`.
</ResponseField>

<ResponseField name="status" type="&#x22;queued&#x22; | &#x22;scheduled&#x22;">
  `"scheduled"` when `scheduledAt` is in the future, otherwise `"queued"`.
</ResponseField>

<ResponseField name="livemode" type="boolean">
  Whether the request ran against live mode. Derived from the API key prefix.
</ResponseField>

```ts theme={null}
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`.

```ts theme={null}
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()`.

<Tabs>
  <Tab title="email">
    `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](/concepts/channels-and-providers#custom-sending-domains)
    on managed sending) and `fromName?` (display name, always applies).

    ```ts theme={null}
    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,
    });
    ```
  </Tab>

  <Tab title="sms">
    `content: RawSmsContent` — `{ body }`.

    ```ts theme={null}
    await senderkit.sendRaw({
      channel: "sms",
      to: "+14155551234",
      content: { body: "Your code is 042195" },
    });
    ```
  </Tab>

  <Tab title="push">
    `content: RawPushContent` — `{ title, body, data?, badge?, sound? }`.

    ```ts theme={null}
    await senderkit.sendRaw({
      channel: "push",
      to: "device_token_…",
      content: { title: "Order shipped", body: "Tracking attached", badge: 1 },
    });
    ```
  </Tab>

  <Tab title="web-push">
    `content: RawWebPushContent` — `{ title, body, icon?, clickUrl?, badge?, data? }`.
    `to` is the JSON-encoded browser `PushSubscription` from the Web Push API.

    ```ts theme={null}
    await senderkit.sendRaw({
      channel: "web-push",
      to: JSON.stringify(subscription), // PushSubscription from browser
      content: {
        title: "New order shipped",
        body: "Your package is on its way.",
        clickUrl: "https://acme.com/orders/ord_123",
      },
    });
    ```
  </Tab>
</Tabs>

## `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.

```ts theme={null}
senderkit.sendBatch(
  requests: Array<SendRequest | SendRawRequest>,
  options?: BatchSendOptions,
): Promise<BatchSendResult[]>
```

<ParamField path="options.concurrency" type="number" default="5">
  Max parallel in-flight requests.
</ParamField>

<ParamField path="options.idempotencyKey" type="string">
  Base key. Each item is sent with `${key}-${index}` (unless the item carries
  its own `idempotencyKey`).
</ParamField>

Each `BatchSendResult` is one of:

```ts theme={null}
| { ok: true;  index: number; id: string; status: "queued" | "scheduled"; livemode: boolean }
| { ok: false; index: number; error: SenderKitError }
```

```ts theme={null}
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.

```ts theme={null}
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`](/api-reference/introduction) and the
[`senderkit_context`](/mcp/tools) MCP tool.

```ts theme={null}
const ctx = await senderkit.context();
console.log(ctx.workspace.name); // "Acme Inc"
console.log(ctx.mode);           // "live"
```

## Templates

```ts theme={null}
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`.

<Note>
  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.
</Note>

```ts theme={null}
const templates = await senderkit.templates.list();
const welcome = await senderkit.templates.get("welcome");
console.log(welcome.currentVersion?.versionNumber);
```

## Messages

```ts theme={null}
senderkit.messages.list(params?: ListMessagesParams): Promise<ListMessagesResponse>
senderkit.messages.get(id: string): Promise<Message>
senderkit.messages.cancel(id: string): Promise<CancelMessageResponse>
```

<ParamField path="params.limit" type="number">
  Max messages to return. The server applies its own default and cap.
</ParamField>

<ParamField path="params.cursor" type="string">
  Pagination cursor — pass the previous response's `nextCursor`.
</ParamField>

<ParamField path="params.status" type="string">
  Filter by [status](/concepts/messages), e.g. `"delivered"`, `"failed"`, `"blocked"`.
</ParamField>

<ParamField path="params.channel" type="&#x22;email&#x22; | &#x22;sms&#x22; | &#x22;push&#x22; | &#x22;web-push&#x22;">
  Filter by channel.
</ParamField>

<ParamField path="params.template" type="string">
  Filter by template slug.
</ParamField>

<ParamField path="params.metadata" type="Record<string, string | number | boolean>">
  Filter by metadata attached at send time. Every key/value pair must match.
</ParamField>

`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" }`.

<Note>
  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.
</Note>

<Note>
  **`blocked` status** — `Message.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" })`.
</Note>

<Note>
  **Engagement** — `Message.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](/webhooks) to be notified as they happen.
</Note>

```ts theme={null}
// 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).

| Class                          | Thrown when                                                                                                           | Extra properties                                                                |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `SenderKitAuthenticationError` | `401` — bad, missing, or revoked key                                                                                  | `status`, `code`, `issues`, `requestId`                                         |
| `SenderKitPermissionError`     | `403` — key is valid but lacks the required scope; extends `SenderKitApiError` (see [Scopes](/authentication#scopes)) | `status`, `code`, `issues`, `requestId`                                         |
| `SenderKitValidationError`     | `400` / `422` — invalid request                                                                                       | `status`, `code`, `issues`, `requestId`                                         |
| `SenderKitRateLimitError`      | `429` — rate limited                                                                                                  | `retryAfter` (**milliseconds**, when the server sent `Retry-After`) + the above |
| `SenderKitApiError`            | any other non-2xx (e.g. `409`, `5xx`)                                                                                 | `status`, `code`, `issues`, `requestId`                                         |
| `SenderKitTimeoutError`        | request exceeded `timeout`                                                                                            | —                                                                               |
| `SenderKitNetworkError`        | network-level failure                                                                                                 | `cause`                                                                         |

The SDK already retries transient failures (`429`, `5xx`, network, timeout) up
to `maxRetries` with backoff, so a thrown error means retries were exhausted.

```ts theme={null}
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`.

<CardGroup cols={2}>
  <Card title="Sending" icon="paper-plane" href="/concepts/sending">
    What a send accepts now and delivers later.
  </Card>

  <Card title="Messages" icon="list-check" href="/concepts/messages">
    The lifecycle behind `messages.list` and `cancel`.
  </Card>

  <Card title="HTTP API" icon="code" href="/guides/http-api">
    Integrate without the SDK, via raw `fetch`.
  </Card>

  <Card title="API Reference" icon="code" href="/api-reference/introduction">
    The underlying REST endpoints.
  </Card>
</CardGroup>
