@senderkit/sdk
v0.17.1
Published
Official TypeScript SDK for SenderKit — notification infrastructure for modern SaaS apps.
Maintainers
Readme
@senderkit/sdk
Official TypeScript SDK for SenderKit — notification infrastructure for modern SaaS apps. Send welcome emails, password resets, billing notifications, and other transactional messages with a single, predictable API.
- ESM + CJS dual-publish — works with
importandrequire - Zero runtime dependencies
- Node.js 18+ (uses native
fetch); runs on edge runtimes too via injectablefetch - Typed end-to-end with generated
.d.ts/.d.cts - Safe by default — automatic retries, timeouts, and idempotency
Install
npm install @senderkit/sdk
# or
pnpm add @senderkit/sdk
# or
bun add @senderkit/sdkQuick start
import { SenderKit } from "@senderkit/sdk";
const senderkit = new SenderKit({
apiKey: process.env.SENDERKIT_API_KEY!,
});
await senderkit.send({
template: "welcome",
to: "[email protected]",
vars: { name: "John" },
});Sending
send
const { id } = await senderkit.send({
template: "welcome", // template slug
to: "[email protected]", // recipient
vars: { name: "John" }, // template variables
channel: "email", // optional; defaults to template's primary channel
idempotencyKey: "welcome-u123", // optional; prevents duplicate sends on retry
});Defer delivery with scheduledAt — accepts an ISO 8601 string or a Date:
await senderkit.send({
template: "trial-ending",
to: "[email protected]",
scheduledAt: "2026-06-01T09:00:00Z",
});The response shape:
{ id: "msg_…", status: "queued" | "scheduled", livemode: boolean }scheduledAt must be in the future and at most 30 days ahead; the API rejects a past or too-distant timestamp with 400. status is "scheduled" when scheduledAt is in the future, otherwise "queued".
Override the From identity per send (email only) with from (bare address) and/or fromName (display name). Either can be set on its own; both fall back to the provider connection's configured values. The same two fields work on sendRaw.
await senderkit.send({
template: "welcome",
to: "[email protected]",
from: "[email protected]", // bare address
fromName: "Acme Support", // display name → "Acme Support <[email protected]>"
});On managed sending, the from address is honored only when it's on the workspace's verified sending domain; fromName always applies. fromName is capped at 128 characters and may not contain control characters or angle brackets.
sendRaw
Send inline content without registering a template — useful for one-off admin notifications, contact-form replies, AI-generated drafts, or any case where the body is known at call-time.
await senderkit.sendRaw({
channel: "email",
to: "[email protected]",
content: {
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
},
metadata: { source: "checkout" },
});SMS, push, and web-push work the same way — switch the channel and the content shape. SMS recipients must be an E.164 phone number (e.g. +15555550123); anything else is rejected with 400 invalid_recipient.
await senderkit.sendRaw({
channel: "sms",
to: "+15555550123",
content: { body: "Your code is 123456" },
});
await senderkit.sendRaw({
channel: "push",
to: "ExponentPushToken[xxx]",
content: { title: "Shipped", body: "Tracking #ABC", badge: 1 },
});
// Web push (browser). `to` is the JSON-encoded browser PushSubscription.
await senderkit.sendRaw({
channel: "web-push",
to: JSON.stringify(subscription), // { endpoint, keys: { p256dh, auth } }
content: {
title: "Back in stock",
body: "The item you wanted is available.",
icon: "https://app.example.com/icon-192.png",
clickUrl: "https://app.example.com/product/42",
},
});Raw sends accept the same retry, idempotency, and error-handling behavior as template sends. Pass interpolate: true together with vars to opt into server-side variable substitution inside content. Add scheduledAt to defer delivery, same as with send.
sendBatch
Fan out many messages at once. Returns one result per item — failures don't abort the batch. A batch can mix template and raw items freely.
const results = await senderkit.sendBatch([
{ template: "welcome", to: "[email protected]", vars: { name: "John" } },
{ template: "trial-ending", to: "[email protected]", vars: { daysLeft: 3 } },
]);
for (const r of results) {
if (r.ok) console.log("sent", r.id);
else console.error("failed", r.index, r.error.message);
}Options:
await senderkit.sendBatch(messages, {
concurrency: 5, // max parallel requests (default 5)
idempotencyKey: "cohort-1", // each item gets `cohort-1-0`, `cohort-1-1`, …
});Live vs test mode
The mode is encoded in your API key — no flag to set:
sk_live_…→ production / live modesk_test_…→ test mode
Switch environments by swapping the key.
Inspect the client's mode before sending — useful for dev-environment guardrails:
const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });
if (process.env.NODE_ENV === "production" && senderkit.mode === "test") {
throw new Error("refusing to boot prod with a test key");
}mode is "test" when the key starts with sk_test_, otherwise "live".
Idempotency
Every send call includes an Idempotency-Key header. If you don't pass one, the SDK auto-generates a UUID per call so transparent retries (network blips, 429s, 5xx) never duplicate a send. Pass your own key when you need end-to-end deduplication across your own retries:
await senderkit.send({
template: "invoice-paid",
to: "[email protected]",
vars: { invoice: "inv_123" },
idempotencyKey: `invoice-paid:inv_123`,
});Error handling
All errors extend SenderKitError. Use instanceof to branch:
import {
SenderKitAuthenticationError,
SenderKitNetworkError,
SenderKitPermissionError,
SenderKitRateLimitError,
SenderKitTimeoutError,
SenderKitValidationError,
} from "@senderkit/sdk";
try {
await senderkit.send({ template: "welcome", to: "[email protected]" });
} catch (err) {
if (err instanceof SenderKitValidationError) {
console.error("Bad request:", err.message, err.issues);
} else if (err instanceof SenderKitAuthenticationError) {
console.error("Check your API key");
} else if (err instanceof SenderKitPermissionError) {
console.error("API key is missing the required scope:", err.message);
} else if (err instanceof SenderKitRateLimitError) {
console.warn(`Rate limited, retry after ${err.retryAfter}ms`);
} else if (err instanceof SenderKitTimeoutError) {
console.warn("Request timed out");
} else if (err instanceof SenderKitNetworkError) {
console.warn("Network error", err.cause);
} else {
throw err;
}
}| Class | When it fires |
| --- | --- |
| SenderKitValidationError | 400 / 422 — invalid request, includes issues from the API |
| SenderKitAuthenticationError | 401 — missing or invalid API key |
| SenderKitPermissionError | 403 — key is valid but lacks the required scope (code: "insufficient_scope") |
| SenderKitRateLimitError | 429 — includes retryAfter (ms) |
| SenderKitApiError | other 4xx / 5xx after retries are exhausted |
| SenderKitTimeoutError | request exceeded the configured timeout |
| SenderKitNetworkError | fetch threw (DNS, connection refused, …) |
| SenderKitError | base class — catch this to handle them all |
SenderKitPermissionError extends SenderKitApiError (not SenderKitAuthenticationError), so a catch (SenderKitApiError) still handles it.
Scopes
API keys can be restricted to a least-privilege set of scopes. A key minted without explicit scopes is unscoped and has full access (the default), so existing keys are unaffected. A scoped key only authorizes the matching operations:
| Scope | Authorizes |
| --- | --- |
| read | templates.list / templates.get, messages.list / messages.get, context() |
| send | send, sendRaw, sendBatch |
| cancel | messages.cancel |
Calling an operation outside a scoped key's grant returns 403 with
code: "insufficient_scope" — surfaced as SenderKitPermissionError. The
ApiScope type ("read" | "send" | "cancel") is exported for convenience.
Templates and messages
const templates = await senderkit.templates.list();
const welcome = await senderkit.templates.get("welcome");
const { data, nextCursor } = await senderkit.messages.list({
limit: 50,
status: "delivered",
template: "welcome",
search: "[email protected]", // substring match over id, recipient, template slug, and metadata
});
const message = await senderkit.messages.get("msg_…");A returned Message carries engagement timestamps when the provider reports
them: openedAt (first email open) and clickedAt (first link click). Both are
ISO 8601 strings, or null until the event occurs. The matching
message.opened and message.clicked webhook events are delivered as they
happen (the message.clicked payload also includes the clicked link).
SenderKit also emits delivery-outcome webhook events you can subscribe an
endpoint to: message.sent, message.delivered, message.failed,
message.opted_out, message.complained, message.suppressed, and (for
inbound mail) message.received. message.complained fires when the recipient
marks the message as spam; a complaint emits both message.complained and
message.opted_out, so handlers that only watch for opt-outs keep working
unchanged. message.suppressed fires when a send is skipped because the address
failed validation or was already on the suppression list after an earlier bounce
or complaint — distinct from message.failed, which is a real bounce reported by
the receiving mail server.
Next.js route handler
// app/api/welcome/route.ts
import { NextResponse } from "next/server";
import { SenderKit } from "@senderkit/sdk";
const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });
export async function POST(req: Request) {
const { email, name } = (await req.json()) as { email: string; name: string };
const result = await senderkit.send({
template: "welcome",
to: email,
vars: { name },
idempotencyKey: `welcome:${email}`,
});
return NextResponse.json(result, { status: 202 });
}React Email
Use @senderkit/react-email to wrap React Email components with SenderKit metadata (id, subject, preview data, optional schema) and render them to HTML. The two packages are designed to be used together.
API reference
new SenderKit(options)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| apiKey | string | — | Required. sk_live_… or sk_test_…. |
| baseUrl | string | https://api.senderkit.com | Override the API endpoint. |
| timeout | number | 30000 | Per-request timeout in ms. |
| maxRetries | number | 2 | Retries on network / timeout / 429 / 5xx. |
| fetch | typeof fetch | globalThis.fetch | Inject a custom fetch (tests, edge runtimes). |
Methods
| Method | Returns |
| --- | --- |
| send(request) | Promise<SendResponse> |
| sendRaw(request) | Promise<SendResponse> |
| sendBatch(requests, options?) | Promise<BatchSendResult[]> |
| templates.list() | Promise<Template[]> |
| templates.get(slug) | Promise<Template> |
| messages.list(params?) | Promise<{ data: Message[]; nextCursor: string \| null }> |
| messages.get(id) | Promise<Message> |
License
MIT
