Official TypeScript SDK for the Sendly REST API.
Type-safe email, contact, list, topic, domain, template, snippet, webhook and
suppression operations; mailbox reads plus the two composition calls a key may
drive; address validation and deliverability reporting; 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.1 is mostly additive — four new resources and the /api/v1 half of six more —
but it also tracks a set of wire-visible renames that landed in the platform,
so it is a breaking release. Do not deploy 1.1 against an API that has not taken
the renamed wire yet: the old field names are gone from these types, and sending
type where the API now expects emailCategory is a 422, not a shrug.
What to change, in the order a codebase usually hits it:
type→emailCategory(legacy) /email_category(v1) on templates and campaigns. Affectstemplates.create,templates.update, theemailCategoryfilter ontemplates.list, andcampaigns.create. The enum memberHEADLESSis nowSELF_MANAGED_UNSUBSCRIBE;MARKETINGandTRANSACTIONALare unchanged.data→payloadin the body ofevents.record(the v1 write). The legacyevents.trackis untouched and still takesdata— the two endpoints were renamed on different schedules, and this SDK reports what each one actually accepts rather than papering over the difference.mailFromStatus→mailFromDomainStatuson a domain, andmail_from_domain_statuson the v1 document.emails.getreturns a different body — the one change here worth reading in full; see below.EmailGetResponseis gone. It named the operation rather than the shape, and was then reused by an operation that is not a GET. It is now two types:EmailResponse(a single email) andEmailDetailResponse(an email plus its delivery events), andemails.getresolves the latter.- The double-opt-in confirmation route moved from
/api/lists/confirmto/api/lists/confirm-subscription. Sendly has never sent that email for you, so if you build the URL yourself — andlists.subscribeis documented on the assumption that you do — change the path.
It used to hand back the whole database row together with an events array that
was the wrong relation: the custom analytics events a caller records with
events.record, not the delivery history the operation has always promised.
It now returns an explicit field list plus events as the delivery timeline
(EmailEvent[], oldest first), and it fills to from the joined contact — which
the spec had always declared and the response had never carried.
Fields that used to leak out of it and no longer do: bodyHash, dedupKey,
idempotencyKey, linkMap, sesMessageId, sesInboundMessageId, body and
headers. Four of those are ledger keys for deduplication and idempotency; the
rest are internal routing state or the rendered message itself. None of them were
ever documented, and a caller reading them was reading Sendly's bookkeeping.
If you were reading events from this call expecting custom events, read
events.list instead. If you were reading the message body back out of it, keep
your own copy — it is not published here.
OPENED, CLICKED and COMPLAINED are no longer delivery statuses on the
platform, and the SDK's own status type — the enum behind email.status and the
status filter on emails.list — no longer offers them. A message is
PENDING, SENDING, SENT, DELIVERED, RECEIVED, BOUNCED, FAILED,
REJECTED, RENDERING_FAILURE, DELIVERY_DELAY or CANCELLED. Engagement is a
separate axis, read from openedAt / clickedAt / complainedAt and the
opens / clicks counters on the email itself:
const { data: email } = await sendly.emails.get(id);
const delivered = email.status === "DELIVERED"; // a delivery fact
const engaged = email.openedAt !== null || email.clicks > 0; // an engagement factThe two used to be one enum, which meant an opened message stopped reporting that it had been delivered.
1.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)
});Every resource hangs off the client. A V1 suffix means the method speaks the
versioned dialect; an unsuffixed method on the same resource speaks the legacy
one. See Both dialects, one client for why both are
here.
sendly.* |
Methods |
|---|---|
emails |
send, sendLegacy, sendTest, batch, list, get, cancelSchedule |
contacts |
create, upsert, bulkCreate, bulkDelete, list, get, update, delete, listV1, listAllV1, createV1, getV1, updateV1, deleteV1, topicPreferences |
lists |
subscribe, unsubscribe, listV1, listAllV1, createV1, getV1, updateV1, deleteV1, startValidationRun |
topics |
list, listAll, create, get, update, setSubscription |
templates |
create, list, get, update, delete, listV1, listAllV1, createV1, getV1, updateV1, deleteV1 |
snippets |
create, list, get, update, delete |
domains |
create, list, get, verify, getVerification, startSetup, assignStream, delete, listV1, listAllV1, createV1, getV1, verifyV1, deleteV1 |
webhooks |
create, list, get, update, delete, rotateSecret, listCalls, listV1, listAllV1, createV1, getV1, updateV1, deleteV1, rotateSecretV1 |
suppression |
add, list, get, remove, listV1, listAllV1, createV1, getV1, deleteV1 |
events |
track, record, list, listAll, listNames, stats |
campaigns |
list, listAll, create, get, update, delete, send, cancel, pause, resume, stats, listFailures, listFailuresAll, retryFailed |
segments |
list, listAll, create, get, update, delete, listContacts, listContactsAll |
workflows |
list, listAll, create, get, update, delete, listExecutions, listExecutionsAll, startExecution, cancelExecution, stats, getGraph, replaceGraph, clone, pause, resume |
mailboxes |
list, get, listAppPasswords, sendMessage, draftMessage |
validation |
validateEmails, getRun, listResults, listResultsAll |
deliverability |
diagnose, listDomainStats, listDomainStatsAll, listDmarcReports, listDmarcReportsAll |
analytics |
timeseries, campaigns, topCampaigns |
usage |
get |
projects |
get |
verify |
email |
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 { data: email } = await sendly.emails.get(receipt.id);
console.log(email.to, email.status, email.opens, email.clicks);
// `events` is the DELIVERY timeline behind `status`, oldest first — not the
// custom events you record with `events.record`, which are read from
// `events.list`.
for (const event of email.events) {
console.log(event.timestamp, event.status);
}This is one of the few legacy reads the SDK hands back enveloped rather than
unwrapped, so the email is under .data.
const page = await sendly.emails.list({ limit: 20, tag: "welcome", status: "DELIVERED" });
for (const email of page.data) {
console.log(email.id, email.to, email.status);
}
if (page.nextCursor) {
const next = await sendly.emails.list({ limit: 20, cursor: page.nextCursor });
}status filters on the delivery lifecycle only. To find the messages somebody
opened, read openedAt / opens on the rows — engagement is not a status.
const contact = await sendly.contacts.upsert({
email: "user@example.com",
customFields: { plan: "pro", signedUpAt: new Date().toISOString() },
});The v1 half of the resource manages the same contacts with snake_case bodies and
cursor pagination — contacts.listV1, createV1, getV1, updateV1,
deleteV1, and contacts.listAllV1 to walk every page:
// `subscribed` is the string "true" / "false" here, not a boolean — it is a
// query parameter with three states, and omitting it means "both".
for await (const contact of sendly.contacts.listAllV1({ subscribed: "true" })) {
console.log(contact.email, contact.custom_fields);
}Two things about contacts.updateV1 catch people out: email is not patchable
at all (an address is the contact's identity, and rewriting it in place would
change who every earlier send was addressed to), and custom_fields is
replaced, not merged — send back every key you mean to keep.
const prefs = await sendly.contacts.topicPreferences(contact.id);
// `prefs.subscribed` is the global marketing opt-out and OUTRANKS every topic:
// false means nothing marketing reaches them whatever the rows below say.
for (const topic of prefs.topics) {
console.log(topic.key, topic.subscribed, topic.pending);
}Each topic's subscribed is the effective answer the send path reaches today,
with the topic's default_opt_in already folded in, so a contact who has never
answered still reads correctly.
const domain = await sendly.domains.create({ domain: "mail.your-domain.com" });
// Publish each token as a CNAME record before verification can succeed.
console.log(domain.dkimTokens);
await sendly.domains.verify(domain.id);
const status = await sendly.domains.getVerification(domain.id);
// One status per record type, not one verdict for the domain.
console.log(status.dkimStatus, status.spfStatus, status.dmarcStatus);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.
A domain reports each DNS record type separately — dkimStatus, spfStatus and
dmarcStatus are each NOT_CHECKED, PENDING, VERIFIED or FAILED, and
lastHealthCheckAt says when they were last filled. status on the verification
response is a different thing: SES's own raw DKIM state (Success, Pending),
which is why both are published rather than collapsed into one. receivingEnabled
says whether inbound mail for the domain is routed to Sendly mailboxes.
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.
assignStream points a verified identity at one kind of traffic:
await sendly.domains.assignStream(domain.id, {
stream: "TRANSACTIONAL",
streamDefault: true,
defaultFromAddress: "receipts@mail.your-domain.com",
});Streams are enforced, not labelled: once assigned, a send of the other kind from
this identity is refused with 403 — which is what keeps a campaign's complaint
rate off the identity your password resets go out on. stream: null unassigns
it, returning it to carrying both. streamDefault demotes whichever identity
currently holds the default for that stream, and defaultFromAddress has to be
an address on this identity's own host.
Receiving mailboxes on the project's verified domains. The reads are reads; the
two composition calls are not — sendMessage really sends.
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.
sendMessage sends real mail, from the mailbox in the path, over its own
domain, and the recipient can reply to it:
const sent = await sendly.mailboxes.sendMessage(mailbox.id, {
to: ["customer@example.com"],
subject: "Re: your order",
body: "Shipping tomorrow — tracking to follow.",
});
console.log(sent.conversationId, sent.messageId);There is no from field, on purpose: a route that sends under a customer's own
identity must not take that identity as an argument. body is plain text and
HTML is refused — Sendly renders the HTML part itself, escaping as it goes, so
text becomes markup in exactly one place. Bcc recipients are delivered to but
appear in no header, so the copy filed in the Sent folder does not record them.
Refusals worth handling by name: 422 RECIPIENT_SUPPRESSED,
422 CONTENT_REFUSED, and 503 CONTENT_SCAN_UNAVAILABLE (no verdict yet for a
young project — nothing was sent, retry shortly). A mailbox may send 60 messages
an hour here.
draftMessage sends nothing. It asks Sendly's assistant to write text and
hands it back for you to review:
const draft = await sendly.mailboxes.draftMessage(mailbox.id, {
mode: "draft", // or "rewrite", or "subject"
brief: "Tell the customer their order ships tomorrow and apologise for the delay.",
tone: "apologetic",
});
console.log(draft.subject, draft.body, draft.sent); // sent is always falsesent: false is reported rather than assumed, so a draft cannot be mistaken for
a send. It stores nothing, reads no correspondence, and needs only
mailboxes:read where sending needs mailboxes:send — a client that may draft
is not thereby a client that may mail your customers. Everything you pass is
treated strictly as data describing what to write, never as instructions to the
model. Capped at 120 requests an hour per project; 502 means the model was
unreachable.
const template = await sendly.templates.create({
name: "Welcome",
subject: "Welcome to Acme",
body: "<p>Hi {{ name }}</p>{{> footer }}",
from: "hello@your-domain.com",
emailCategory: "MARKETING", // was `type` before 1.1
});emailCategory is MARKETING, TRANSACTIONAL or SELF_MANAGED_UNSUBSCRIBE
(the member that used to be called HEADLESS). It defaults to MARKETING and
is also the legacy list filter: templates.list({ emailCategory: "MARKETING" }).
A template carries currentVersion, a counter an update increments only when it
changes the rendered content — a rename leaves it alone. A campaign records
the version it sent, so comparing the two is how you tell "the template changed
since this went out" from "somebody retitled it".
A snippet is a reusable fragment a template pulls in with {{> name}}.
name is the literal identifier templates include, unique within the project, so
a clash answers 409:
await sendly.snippets.create({
name: "footer",
description: "Address block and unsubscribe line",
body: "<hr /><p>Acme Inc, 1 Example Way</p>",
});
const page = await sendly.snippets.list({ limit: 25, search: "footer" });
console.log(page.data.data.length, page.data.hasMore);Snippets are gated by the same templates:* scopes as the templates that include
them, because a snippet is part of a template body rather than a resource with an
audience of its own. Deleting one does not break the templates that include it —
an absent snippet renders as an empty string, like an absent variable.
A topic is the subject a project mails about — a contact subscribes to a topic rather than to a campaign, so switching one off silences a whole audience.
const topic = await sendly.topics.create({
key: "product-updates", // stable; survives a rename of `name`, and is not patchable
name: "Product updates",
default_opt_in: true,
});
const result = await sendly.topics.setSubscription(topic.id, {
contact_id: contact.id,
subscribed: true,
});Subscribing somebody through the API does not bypass confirmation.
subscribed: true parks the contact at pending and answers a
confirmation_url; nothing is mailed on this topic until someone opens that
link, and there is no parameter to skip it — a subscription a caller asserts is
not evidence the mailbox holder agreed. Sendly does not send that email; your
application delivers result.confirmation_url, from your own verified domain.
subscribed: false records the opt-out immediately.
default_opt_in decides what silence means for a contact who never answers: true
for a topic introduced over a list that already consented to hear from you, false
for anything a person has to ask for.
There is no topics.delete. A topic is where people's answers are recorded, so
deleting it would delete the choices they made; update(id, { archived: true })
is the retire button and drops it from the preference centre and from new sends
while every opt-out survives. list({ include_archived: true }) brings them back.
Every address checked is billed. Looping this over a contact list is looping over your invoice.
const batch = await sendly.validation.validateEmails({
emails: ["user@example.com", "typo@exmaple.com"], // at most 50 per call
});
for (const result of batch.results) {
// Branch on `verdict`, never on the flags: `is_personal` (Gmail, Outlook) and
// `is_role_address` (`support@`) describe ordinary, deliverable addresses.
console.log(result.email, result.verdict);
}The 50-address ceiling is a latency bound, not a payload one: every distinct domain in the batch costs a DNS round trip. To check a whole list, start the background run instead — one call, then poll:
const run = await sendly.lists.startValidationRun(list.id);
const progress = await sendly.validation.getRun(run.id);
// Finished when `status` is "completed" or "failed" — never when a percentage
// reaches 100, because there is deliberately no total to divide by: a list
// changes size while a run walks it.
console.log(progress.status, progress.processed_count, progress.undeliverable_count);
for await (const result of sendly.validation.listResultsAll(run.id, { verdict: "undeliverable" })) {
console.log(result.email, result.contact_id, result.reasons);
}A verdict of unknown is deliberately a separate value from undeliverable: it
means DNS did not answer in time, so that address was not checked. Deleting a
contact on unknown deletes a live one over a network hiccup. undeliverable is
the page to read before acting on a run; unknown is the one never to act on.
const diagnosis = await sendly.deliverability.diagnose({
domain: "mail.your-domain.com", // required — this endpoint answers about one domain
address: "user@example.com", // optional RECIPIENT to check alongside it
window_days: 7,
});
// `findings` is worst first, and an empty array means nothing here explains a
// delivery problem. Branch on a finding's `code`, never on its prose.
for (const finding of diagnosis.findings) {
console.log(finding.severity, finding.code);
}Nothing there is looked up live: the DNS statuses are the verification refresh
job's cached results, and identity.last_checked_at says when they were filled.
recent_delivery is project-wide rather than per-domain — its own scope field
says so — because an email row records no sending domain.
listDomainStats is the axis diagnose cannot report: outcomes broken out by
recipient domain and UTC day. These are the domains you send to —
gmail.com, outlook.com — not the domains you send from, and they are how you
catch one provider refusing nearly everything while the rest of your mail is
healthy.
for await (const row of sendly.deliverability.listDomainStatsAll({ limit: 100 })) {
console.log(row.day, row.domain, row.delivered, row.bounced, row.computed_at);
}The counts come from an hourly rollup over a rolling 30-day window, not from a
query run on request; each row's computed_at says when it was last rebuilt. No
rate is published, because a rate over three sends is not information.
listDmarcReports returns the DMARC aggregate (RUA) reports receiving providers
have sent about your domains. An empty list is the correct answer, not a bug,
until a policy domain is registered in this project and its DMARC record names an
address we receive — and receivers send on their own schedule, typically once a
day.
const reports = await sendly.deliverability.listDmarcReports({ limit: 20 });
// Which kind of empty is this? `false` means no intake mailbox exists, so no
// report can ever arrive — the feature is off, your domains are not "clean".
if (!reports.intake_configured) {
console.warn("DMARC report intake is not configured on this deployment");
}
for (const report of reports.data) {
console.log(report.org_name, report.policy_domain, report.pass_count, report.fail_count);
}intake_configured exists because the two empty lists are otherwise
indistinguishable, and reporting "no DMARC failures" off a feature that was
never switched on is the worse of the two mistakes. Read the flag before you
tell anyone the domains are healthy.
pass_count counts DMARC alignment taken from policy_evaluated, not raw
authentication results — a message can pass SPF for a domain that is not the one
in its From header, which is exactly the case DMARC exists to catch.
const created = await sendly.webhooks.create({
url: "https://your-app.com/webhooks/sendly",
eventTypes: ["email.delivered", "email.bounced", "email.complained"],
});
// store `created.data.secret` securely — used to verify HMAC signatures.
// The endpoint is beside it rather than spread around it: `created.data.webhook.id`.A webhook record carries domains — the sending domains this endpoint is scoped
to, where an empty array means every domain on the project — and, while a
rotation is in flight, previousSecretExpiresAt. A record never carries a
secret or any fragment of one.
On v1 the same registration resolves the secret beside the webhook, and adds rotation:
const { webhook, secret } = await sendly.webhooks.createV1({
url: "https://your-app.com/webhooks/sendly",
event_types: ["email.delivered", "email.bounced"],
});
const rotated = await sendly.webhooks.rotateSecretV1(webhook.id);
console.log(rotated.secret, rotated.previous_secret_expires_at);createV1 and rotateSecretV1 are the only two responses that ever carry a
signing secret; no read endpoint hands it back, so a secret you lose is replaced
by rotating rather than recovered. The outgoing secret is not cut off at once —
it keeps verifying until previous_secret_expires_at, and every delivery inside
that window carries both signatures, so a verifier can be redeployed without
dropping an event.
await sendly.suppression.add({ email: "angry@example.com", reason: "MANUAL" });
// Alone among the legacy reads, this one answers no `{ success, data }`
// envelope — the page IS the body.
const page = await sendly.suppression.list({ reason: "MANUAL", limit: 100 });
for (const record of page.items) {
console.log(record.email, record.reason, record.scope);
}scope is PROJECT on every record this API creates or returns today; GLOBAL
is reserved for a platform-wide block recorded outside your project.
The v1 half addresses a record by the address itself and answers definitively
either way — 200 means suppressed and says why, 404 resource_not_found means
it is not on the list. That is the difference from the legacy suppression.get,
which answers 200 { suppressed: false } for an address nobody suppressed:
import { SendlyNotFoundError } from "sendly-sdk";
try {
const record = await sendly.suppression.getV1("angry@example.com");
console.log("suppressed:", record.reason, record.source);
} catch (err) {
if (err instanceof SendlyNotFoundError) {
// not suppressed — mail may flow
} else throw err;
}Suppressing is idempotent and the first reason wins: an already-suppressed
address answers with the existing record, so a later manual entry cannot
overwrite what an SES bounce recorded. source is not accepted in the body — it
is derived from the credential, so a record's provenance cannot be dressed up as
a deliverability fact.
deleteV1 is the one call on this surface that can put mail back into an inbox
that asked you to stop, and it does not clear AWS SES's own account-level
suppression list: an address SES suppressed after a hard bounce stays
undeliverable through SES even once this record is gone.
Records a custom event against a contact. The legacy events.track works with
both sk_* and pk_* keys (reserved system event names are rejected) and
carries its payload in data:
const tracked = await sendly.events.track({
event: "purchase.completed",
email: "user@example.com",
data: { plan: "pro", amount: 4900 },
});
console.log(tracked.contact, tracked.event);events.record is the same capability on /api/v1/events, and its payload field
is called payload:
const event = await sendly.events.record({
name: "purchase.completed",
contact_id: contact.id, // must already exist — this endpoint never creates contacts
payload: { plan: "pro", amount: 4900 },
});New integrations should prefer record, which also unlocks events.list,
events.listNames and events.stats.
const check = await sendly.verify.email({ email: "user@example.com" });
if (!check.valid) {
console.log("rejecting", check.reason);
}This is the free single-address syntax/MX check. It is not
validation.validateEmails, which is the billed batch check with a verdict
vocabulary behind it.
Campaigns, segments, workflows, analytics, usage, topics, validation,
deliverability and events live on Sendly's versioned API, as does the V1 half
of contacts, lists, templates, domains, webhooks and suppression. 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);Six resources — contacts, lists, templates, domains, webhooks and suppression —
now answer on both surfaces, so their v1 methods carry a V1 suffix:
contacts.list is the legacy one, contacts.listV1 the versioned one.
The suffix is not decoration. The two methods answer the same question with
different envelopes, different field cases and different error bodies, and a call
site that mixes them up reads a data that is not there:
const legacy = await sendly.contacts.list({ limit: 20 });
legacy.data.data; // Contact[] — inside the `{ success, data }` envelope
legacy.data.nextCursor; // camelCase
const v1 = await sendly.contacts.listV1({ limit: 20 });
v1.data; // ContactV1[] — the bare body IS the list envelope
v1.next_cursor; // snake_caseLegacy methods keep working and nothing about them changed in 1.1. New code
should reach for the V1 ones: they are the surface the contract is versioned
against, and they carry request_id on every failure.
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:
for await (const campaign of sendly.campaigns.listAll({ limit: 50 })) {
console.log(campaign.id, campaign.status);
}The seventeen companions: campaigns.listAll, campaigns.listFailuresAll,
contacts.listAllV1, deliverability.listDmarcReportsAll,
deliverability.listDomainStatsAll, domains.listAllV1, events.listAll,
lists.listAllV1, segments.listAll, segments.listContactsAll,
suppression.listAllV1, templates.listAllV1, topics.listAll,
validation.listResultsAll, webhooks.listAllV1, workflows.listAll and
workflows.listExecutionsAll.
Through 1.0 there were two dialects: topics.list and
validation.listResults took cursor and answered cursor where every other
v1 list took after. The platform collapsed that for 1.1, so there is one shape
to learn and one to write. If you were driving either of those two by hand, pass
after and read next_cursor.
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. The one exception is campaigns.listFailures, which
also carries total, because retryFailed acts on that number and has_more
alone cannot tell you whether 3 or 30,000 sends failed.
stats says how many sends failed; only listFailures says who.
const failures = await sendly.campaigns.listFailures(campaign.id, { limit: 100 });
console.log(failures.total, "recipients did not receive it");
for await (const failure of sendly.campaigns.listFailuresAll(campaign.id)) {
console.log(failure.email, failure.reason, failure.failed_at);
}
const retry = await sendly.campaigns.retryFailed(campaign.id);
console.log("re-queued", retry.queued);reason comes from a fixed vocabulary rather than the underlying error text, so
it is stable enough to branch on; it is null on rows recorded before reasons
were captured.
retryFailed re-drives only the recipients whose send failed — nobody who
already received the campaign is mailed a second time, because each ledger row is
claimed before it is touched and a row whose email exists already is re-queued
rather than re-sent. The walk runs in the background, so the call resolves as
soon as it is queued, reporting how many failed rows it was started for. Only a
SENT campaign qualifies; a retry already running answers 409 conflict.
getGraph returns every step — including the TRIGGER entry node — plus the
directed transitions between them, and that body is accepted verbatim by
replaceGraph:
const graph = await sendly.workflows.getGraph(workflow.id);
graph.steps[0].config; // stored exactly as authored, camelCase keys and all
const updated = await sendly.workflows.replaceGraph(workflow.id, {
steps: graph.steps,
transitions: graph.transitions,
});replaceGraph is a PUT, and that is the point: a graph is nodes plus the
edges between them, so a partial edit to a step list has no meaning without the
transitions that reference it — half-applied, it would leave steps pointing at
steps that no longer exist. Ids decide the outcome per step: one you send is
updated in place, a fresh uuid creates a step, and an id you omit deletes that
step and its run history. Exactly one step must be a TRIGGER, every
transition must name steps in the same document, and no step may point at itself.
It is refused with 409 conflict while the workflow has running executions —
those runs are standing on the steps being replaced.
clone copies a workflow and its whole graph. The copy is always created
disabled, whatever the original was: a clone exists to be reviewed, and one
that started live would match the same trigger events as its original from the
moment it appeared.
const copy = await sendly.workflows.clone(workflow.id, { name: "Welcome (v2 test)" });pause and resume are deliberately asymmetric:
const paused = await sendly.workflows.pause(workflow.id);
console.log("cancelled", paused.cancelled_executions, "in-flight runs");
const resumed = await sendly.workflows.resume(workflow.id);
console.log(resumed.cancelled_executions); // always 0Pausing cancels every RUNNING/WAITING execution and reports how many —
that is what separates it from update(id, { enabled: false }), which only stops
new runs starting and leaves every in-flight contact walking the graph, next
delay still expiring, next email still sending. Resuming re-opens the workflow
to new runs and does not restore the cancelled ones. The cancellation is
terminal; there is no undo, so pause when you mean to stop the sends already in
flight and disable when you only mean to close the door. resume is refused with
422 validation_error while any step is still unconfigured.
events.track is the legacy POST /api/track endpoint and is unchanged — its
payload field is still data. events.record is the same capability on
/api/v1/events, named differently only because track was taken, and its
payload field is payload. 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 lifecycle is what stays out of reach — not the mailbox resource as a
whole. The three reads (mailboxes.list, mailboxes.get,
mailboxes.listAppPasswords) have a conditional membership check, and
mailboxes.sendMessage / mailboxes.draftMessage publish ApiKeyAuth outright,
so a key really can call all five.
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.
Note that 404 resource_not_found is an ordinary answer from
suppression.getV1, not a failure: it is how that route says "this address is
not suppressed". Catch it rather than logging it.
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.
Nothing added in 1.1 takes a key. The v1 creates (contacts.createV1,
lists.createV1, templates.createV1, domains.createV1,
webhooks.createV1, suppression.createV1, topics.create,
snippets.create) are all either naturally idempotent on their own key or cheap
to repeat, and campaigns.retryFailed is guarded by a 409 on a retry already
running rather than by a replay ledger.
Two v1 writes deliberately take no key for reasons of their own. 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