@senderkit/sdk
v0.10.0
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 }status is "scheduled" when scheduledAt is in the future, otherwise "queued".
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:
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",
});
const message = await senderkit.messages.get("msg_…");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
