Official TypeScript SDK for the Sendly REST API.
Type-safe email, contact, domain, template, webhook, and suppression
operations, plus the versioned /api/v1 surface — campaigns, segments,
workflows, analytics, and usage. Generated from the public OpenAPI spec, so
every endpoint and schema stays in sync.
This repository is the official standalone home and source of truth for the Sendly TypeScript SDK — issues and PRs are welcome here. Its surface is contract-tested against Sendly's public OpenAPI spec on every change, so the client never drifts from the live API. Full docs live at https://docs.sendly.now.
npm install sendly-sdk
# or
pnpm add sendly-sdkShips both ESM and CommonJS builds, so import and require both work.
Alternatively, install the latest main directly from GitHub:
npm install github:DevinoSolutions/sendly-jsRequires Node 20+ (or any runtime with global fetch and AbortSignal.timeout).
The API base is https://api.sendly.now; full docs live at
https://docs.sendly.now.
You don't even need this SDK to try Sendly. The API also speaks the transactional-send dialect of those providers — keep the vendor SDK you already run and change two things: the base URL and the API key.
import { Resend } from "resend"; // your existing Resend integration
const resend = new Resend("sk_your_sendly_key", {
baseUrl: "https://api.sendly.now/api/compat/resend",
});
// resend.emails.send(...) now sends through Sendly — same code, same shapes.Every compat request runs through the same pipeline as the native API (domain verification, suppression, limits), and anything a dialect can express that Sendly doesn't support returns a clean error in that vendor's own error shape. Per-provider guides: docs.sendly.now/migrate.
import { Sendly } from "sendly-sdk";
const sendly = new Sendly({ apiKey: process.env.SENDLY_API_KEY! });
await sendly.emails.send({
from: "hello@your-domain.com",
to: "user@example.com",
subject: "Welcome to Acme",
html: "<p>Glad to have you.</p>",
});Pass a project API key. sk_* keys allow full access; pk_* keys are
sending-only. Keys are sent in the Authorization: Bearer <key> header
on every request.
const sendly = new Sendly({
apiKey: "sk_live_...", // required
baseUrl: "https://api.sendly.now", // optional, override for staging / self-hosted
timeout: 30_000, // ms, optional (default 30s)
});const result = await sendly.emails.send(
{
from: "hello@your-domain.com",
to: "user@example.com",
subject: "Order confirmed",
html: "<p>Thanks for your order.</p>",
},
{ idempotencyKey: "order-confirm-12345" }, // optional, replays deduped 24h
);
// `result` is `{ emails, timestamp }` with one `emails` entry per recipient —
// an array `to` fans out to several. Each entry is
// `{ contact: { id, email }, email }`, where `email` is the id of the queued
// email record for that recipient. Poll `emails.get(id)` for its status.
const emailId = result.emails[0].email;
console.log("queued", emailId, "at", result.timestamp);const page = await sendly.emails.list({ limit: 20, tag: "welcome", status: "DELIVERED" });
for (const email of page.data.items) {
console.log(email.id, email.to, email.status);
}
if (page.data.cursor) {
const next = await sendly.emails.list({ limit: 20, cursor: page.data.cursor });
}const contact = await sendly.contacts.upsert({
email: "user@example.com",
customFields: { plan: "pro", signedUpAt: new Date().toISOString() },
});const domain = await sendly.domains.create({ name: "mail.your-domain.com" });
await sendly.domains.verify(domain.id);
const status = await sendly.domains.getVerification(domain.id);const { webhook, secret } = await sendly.webhooks.create({
url: "https://your-app.com/webhooks/sendly",
eventTypes: ["email.delivered", "email.bounced", "email.complained"],
});
// store `secret` securely — used to verify HMAC signatures on incoming callsawait sendly.suppression.add({ email: "angry@example.com", reason: "MANUAL" });Records a custom event against a contact. Works with both sk_* and pk_*
keys (reserved system event names are rejected).
const tracked = await sendly.events.track({
event: "purchase.completed",
email: "user@example.com",
data: { plan: "pro", amount: 4900 },
});
console.log(tracked.contact, tracked.event);const check = await sendly.verify.email({ email: "user@example.com" });
if (!check.valid) {
console.log("rejecting", check.reason);
}Campaigns, segments, workflows, analytics, usage, and events live on Sendly's
versioned API. They hang off the same client and the same base URL, but they
speak a different dialect from the /api/* resources above:
- Responses are the bare resource, not a
{ success, data }envelope, and fields aresnake_case. - Errors are RFC 9457 problem
documents (
application/problem+json) — see below. - Lists are cursor-paginated only —
{ data, has_more, next_cursor }, no total.
const campaign = await sendly.campaigns.create(
{
name: "August launch",
subject: "We shipped it",
body: "<p>Read all about it.</p>",
from: "hello@your-domain.com",
audience_type: "SEGMENT",
segment_id: segment.id,
},
{ idempotencyKey: `launch-${releaseId}` },
);
// Creating never sends. Send now, or schedule it:
await sendly.campaigns.send(campaign.id, { scheduled_for: "2026-09-01T10:00:00Z" });
const stats = await sendly.campaigns.stats(campaign.id);
console.log(stats.delivered, stats.open_rate);Every v1 list takes limit (1–100, default 20) and after (an opaque cursor
from the previous response's next_cursor). Page manually, or let the SDK do
it — each list has a companion *All async generator that walks the pages and
yields individual items:
// Manual: stop when has_more goes false.
let page = await sendly.campaigns.list({ limit: 50 });
while (page.has_more && page.next_cursor) {
page = await sendly.campaigns.list({ limit: 50, after: page.next_cursor });
}
// Automatic: campaigns.listAll, segments.listAll, segments.listContactsAll,
// workflows.listAll, workflows.listExecutionsAll, events.listAll.
for await (const campaign of sendly.campaigns.listAll({ limit: 50 })) {
console.log(campaign.id, campaign.status);
}Keep the filter and sort arguments fixed for the whole walk — the cursor
encodes them, and changing them mid-pagination is answered with
422 validation_error telling you to restart from the first page. There is
deliberately no total count.
events.track is the legacy POST /api/track endpoint and is unchanged.
events.record is the same capability on /api/v1/events — a different name
only because track was taken. New integrations should prefer record, which
also unlocks events.list, events.listNames, and events.stats.
Every non-2xx response throws a typed SendlyError subclass. Switch on the
class (no string matching needed):
import {
SendlyValidationError,
SendlyAuthenticationError,
SendlyNotFoundError,
SendlyRateLimitError,
SendlyServerError,
} from "sendly-sdk";
try {
await sendly.emails.send({ from, to, subject, html });
} catch (err) {
if (err instanceof SendlyValidationError) {
console.warn("bad input:", err.errorCode, err.message);
} else if (err instanceof SendlyAuthenticationError) {
console.error("check your API key");
} else if (err instanceof SendlyRateLimitError) {
// back off and retry
} else if (err instanceof SendlyServerError) {
// 5xx — retry with exponential backoff
} else {
throw err;
}
}Each error exposes:
statusCode— HTTP status (0 for transport failures)errorCode— stable machine code from the API envelopemessage— human-readable messagebody— full parsed response body for debuggingrequestId— correlation id, on/api/v1errors only (see below)fieldErrors— per-field failures, on/api/v1422responses only
The versioned surface answers failures with a application/problem+json
document: { type, title, status, detail?, instance?, code, request_id?, errors? }. The SDK maps it onto the same error subclasses by HTTP status,
so nothing about instanceof handling changes. What it adds is better detail:
errorCodeis the problem's stable lowercase registry value —invalid_api_key,invalid_session,scope_missing,project_access_denied,project_disabled,validation_error,resource_not_found,conflict,rate_limited,quota_exhausted,idempotency_key_reused,enqueue_failed,internal_error.messageis the problem'sdetail(falling back totitle).requestIdis therequest_id— quote it in support requests.fieldErrorsis theerrorsarray on a422 validation_error: one{ pointer, code, message }per offending field,pointerbeing an RFC 6901 JSON Pointer.
try {
await sendly.campaigns.create({ name: "", subject: "Hi", body, from, audience_type: "ALL" });
} catch (err) {
if (err instanceof SendlyValidationError) {
for (const field of err.fieldErrors ?? []) {
console.warn(`${field.pointer}: ${field.message}`);
}
console.warn("request id:", err.requestId);
}
}Two different situations share HTTP 429, and errorCode is what separates
them: rate_limited is the per-key burst limiter and clears on its own, while
quota_exhausted is your billing-period quota and stays until the period resets
or the plan is upgraded — retrying it will not help. When reading the reset
hint, note that X-RateLimit-Reset is an absolute epoch-seconds instant
whereas the draft-11 RateLimit header's t= is delta seconds. The SDK
does not retry on your behalf.
Invalid input is reported as SendlyValidationError. The API returns 422
(errorCode: "VALIDATION_ERROR") for schema validation failures; the SDK maps
both 400 and 422 to SendlyValidationError, so existing instanceof
checks keep working. Field-level detail, when present, is on
err.body.error.details.errors:
if (err instanceof SendlyValidationError) {
const fields = (err.body as { error?: { details?: { errors?: unknown[] } } })?.error?.details?.errors;
console.warn("validation failed:", err.errorCode, fields);
}The error envelope is { success: false, error: { message, code, details? } }.
Contact bulk operations that previously failed with a NO_PROJECT code now
surface as VALIDATION_ERROR.
Pass idempotencyKey on any write that supports it (emails.send,
emails.batch, contacts.create, contacts.upsert, contacts.bulkCreate,
and on /api/v1 exactly campaigns.create and campaigns.send) to make
retries safe. Replays within 24 hours return the original result instead of
acting twice. events.record deliberately takes no key — events are
append-only, high-volume writes.
await sendly.emails.send({ from, to, subject, html }, { idempotencyKey: `signup-${userId}` });Inject your own fetch for SSR, instrumentation, or testing:
const sendly = new Sendly({
apiKey: "sk_test",
fetch: async (input, init) => {
console.log("outbound", init?.method, input);
return globalThis.fetch(input, init);
},
});Full reference, schemas, and live OpenAPI spec live at https://docs.sendly.now.
pnpm install # install pinned toolchain
pnpm test # run the vitest suite once
pnpm lint # eslint (0 warnings tolerated)
pnpm check-types # tsc --noEmit
pnpm build # regenerate types from openapi.json, then bundle with tsupThe type definitions in src/types.generated.ts are generated from
openapi.json via pnpm build:types. openapi.json is a committed snapshot
of Sendly's public OpenAPI spec; refresh it from the live API with
pnpm sync-spec, then regenerate the types (pnpm build:types, which
pnpm build runs for you). The SDK surface is verified against this snapshot
by the contract suite in src/__tests__/contract.test.ts.
MIT © Devino Solutions