Official TypeScript SDK for the Sendly REST API.
Type-safe email, contact, domain, template, webhook, and suppression
operations, plus mailbox and project reads and 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! });
const receipt = await sendly.emails.send({
from: "hello@your-domain.com",
to: "user@example.com",
subject: "Welcome to Acme",
body: "<p>Glad to have you.</p>",
});
console.log(receipt.id, receipt.status); // status is a real delivery state1.0 repoints emails.send to the versioned POST /api/v1/emails. It now
takes one recipient (cc/bcc copy others) and resolves the 202 receipt
{ id, status, to, from }, where status is a real delivery state. Before 1.0
it posted to the legacy POST /api/emails, fanned an array to out to several
recipients, and resolved { emails, timestamp } with no delivery status.
The old behaviour is kept, unchanged, as emails.sendLegacy. Two ways to
upgrade:
- Keep the old shapes: rename the call.
send(...)→sendLegacy(...). Done. - Take the new default: read the receipt instead of the envelope
(
receipt.id/receipt.statusin place ofresult.emails[0].email), send to one recipient per call, and note that failures now carry the v1 error fields (errorCodeis lowercase,requestIdandfieldErrorsare set) — theSendlyErrorsubclasses are the same, soinstanceofchecks stand.
Nothing else changed shape. See CHANGELOG.md for the full 1.0.0 entry.
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 receipt = await sendly.emails.send(
{
from: "hello@your-domain.com",
to: "user@example.com", // one recipient; `cc` / `bcc` copy others
subject: "Order confirmed",
body: "<p>Thanks for your order.</p>",
},
{ idempotencyKey: "order-confirm-12345" }, // optional, replays deduped 24h
);
// `receipt` is `{ id, status, to, from }`, answered with 202. `status` is a real
// delivery state — poll `emails.get(receipt.id)` for the events behind it.
console.log(receipt.id, receipt.status);The pre-1.0 send — the legacy POST /api/emails, which fans an array to out
to several recipients and answers { emails, timestamp } with no delivery
status — is still here as emails.sendLegacy:
const result = await sendly.emails.sendLegacy({
from: "hello@your-domain.com",
to: ["a@example.com", "b@example.com"],
subject: "Order confirmed",
body: "<p>Thanks for your order.</p>",
});
// One `emails` entry per recipient: `{ contact: { id, email }, email }`, where
// `email` is the id of the queued email record. Poll `emails.get(id)` for status.
console.log(
result.emails.map((entry) => entry.email),
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({ domain: "mail.your-domain.com" });
await sendly.domains.verify(domain.id);
const status = await sendly.domains.getVerification(domain.id);Pass region to pin the domain to an SES region (us-east-1, us-west-2 or
eu-west-1). The first domain locks the project's region; later ones must match
it.
Publishing the DNS records by hand is not the only route. startSetup opens the
guided hand-off and returns the session exactly as the API returns it:
const session = await sendly.domains.startSetup(domain.id);
// { token, connectUrl, expiresAt } — connectUrl is short-lived and domain-specific.
console.log("finish setup at", session.connectUrl, "before", session.expiresAt);Nothing here is reshaped, because finishing setup means a person opening
connectUrl and authorising the change at their registrar. The SDK's job is to
hand back the link, not to model the flow behind it.
Receiving mailboxes on the project's verified domains. Reads only — see What the SDK deliberately does not expose.
const mailboxes = await sendly.mailboxes.list(); // not paginated
const mailbox = await sendly.mailboxes.get(mailboxes[0].id);
// `settings` carries the IMAP and SMTP host, port, security and username.
console.log(mailbox.settings.imap.host, mailbox.settings.imap.port);
// App passwords, metadata only — `lastFour` is the one fragment of the secret
// that survives creation, so a credential can be identified but not rebuilt.
for (const pw of await sendly.mailboxes.listAppPasswords(mailbox.id)) {
console.log(pw.name, pw.lastFour, pw.lastUsedAt);
}This lists the mailboxes themselves, never their contents — received messages
are not part of the public API. The mailbox password is never returned by
any of these reads; mailbox credentials are app passwords, created from the
dashboard and shown once. listAppPasswords returns only the passwords that are
still active — a revoked one drops out, so this is not an audit history.
The per-project cap is 10 mailboxes. It counts only those holding, or
mid-way to holding, a real account — PROVISIONING, ACTIVE and SUSPENDED.
FAILED rows are excluded on purpose, so that a burst of failed provisions
cannot eat a project's allowance and turn an outage into "you have reached your
mailbox limit"; they are still returned by list(), so a project that has had
failures can list more than 10. Exceeding the cap is a 409
(SendlyConflictError) from whatever creates the mailbox — which is not this
SDK, since mailbox creation needs a signed-in user.
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.
The same split, resolved the other way round: since 1.0, emails.send IS the
versioned send. It posts to /api/v1/emails and answers 202 with
{ id, status, to, from }, where status is a real delivery state you can poll
on. It takes one recipient — use cc/bcc to copy others — instead of fanning
an array out. emails.sendLegacy is the pre-1.0 send on POST /api/emails,
unchanged: row ids, no delivery status, array to fanned out. See
Upgrading from 0.x.
send accepts an idempotencyKey; sendTest deliberately does not (see
Idempotency).
const receipt = await sendly.emails.send(
{ to: "user@example.com", subject: "Order confirmed", body: "<p>Thanks.</p>" },
{ idempotencyKey: `order-${orderId}` },
);
console.log(receipt.id, receipt.status); // status is a real delivery stateemails.sendTest proves the send path works without touching a live
recipient. Two things about it are easy to get backwards:
- The sandbox address is the sender, not the destination. It is resolved
server-side, and naming a
fromyourself is refused rather than ignored — so a request expecting a different sender never gets a success it would misread.projects.get().sandbox_addresstells you what it sends from; the response'sfromsays the same thing. - It lands in the project owner's own inbox.
tois optional and defaults to the project owner's verified account email, which is the only address a sandbox send may reach — any other value is refused.
const test = await sendly.emails.sendTest({ subject: "hi", body: "<p>hi</p>" });
console.log(test.to, test.from, test.sandbox); // sandbox is always true hereEverything else applies unchanged: the same rendering, the same content scan, the same daily and trust-tier caps as a real send.
const project = await sendly.projects.get();
console.log(project.name, project.sandbox_address, project.ses_region);Takes no id — the project is whichever one the API key belongs to. There is no
create here; see below.
An API key resolves no user, and a handful of routes resolve the acting project
admin from the session before reading any scope — so they answer 401 to any
key, however broad its scopes. The contract states this: those operations
publish SessionAuth without ApiKeyAuth.
Rather than ship methods that could never succeed, they are listed in the
contract suite's NOT_SDK_CALLABLE and checked against the spec's own
declarations, in both directions. They are: creating and deleting a mailbox,
creating and revoking an app password, all four API-key operations, and
creating a project. Use the dashboard or an OAuth connection for those.
Mailbox reads are exposed (mailboxes.list, mailboxes.get,
mailboxes.listAppPasswords) — their membership check is conditional, so a key
really can call them.
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, body });
} 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.sendLegacy, emails.batch, contacts.create, contacts.upsert,
contacts.bulkCreate, campaigns.create and campaigns.send — to make
retries safe. Replays within 24 hours return the original result instead of
acting twice.
Two v1 writes deliberately take no key. events.record is append-only and
high-volume. emails.sendTest reaches only the caller's own inbox, a daily
cap already bounds it, and "send me another one" is the normal second call
rather than a mistake worth deduplicating.
await sendly.emails.send({ from, to, subject, body }, { 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 OpenAPI contract, and the SDK surface is verified against it by the
contract suite in src/__tests__/contract.test.ts.
pnpm sync-spec requires SENDLY_OPENAPI_URL. There is no default, and in
particular it does not default to production:
SENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json pnpm sync-spec
pnpm build:types # regenerate types (pnpm build runs this for you)SENDLY_OPENAPI_URL accepts a filesystem path (the normal case — the committed
contract in the Sendly platform monorepo at apps/web/openapi/openapi.json) or
an http(s):// URL of a local or staging API. Running pnpm sync-spec with it
unset exits non-zero and prints what to set.
Do not point it at https://api.sendly.now. Vendoring the spec from the
deployed API makes the SDK mirror what is running rather than what the repo
declares, so any drift between the platform's code and its committed contract
is laundered into "correct" on the way in — the SDK regenerates to match the
deployment and the mismatch vanishes silently. That destroys the vendored spec's
only job: it is the fixed reference the contract suite compares against, so an
SDK synced from production can no longer detect the very drift it exists to
catch. It is also unreproducible and unreviewable.
This is not hard-blocked — "what does production actually serve?" is a legitimate one-off. Doing it prints an unmissable warning (and a CI annotation), because quiet is what made the old default dangerous, not the host. Never commit the result, and never wire that host into CI or any unattended job.
pnpm check-spec-drift compares the committed openapi.json to the same source
and never fails the build. With SENDLY_OPENAPI_URL unset it skips with a notice
rather than erroring, so CI and fork pull requests stay green.
MIT © Devino Solutions