SendlySendly

SDKs

Official TypeScript/JavaScript and Python client libraries for the Sendly API

Sendly maintains official client libraries so you can call the API from your own code without hand-writing HTTP requests. Both SDKs are hand-written for their language and contract-tested against the same OpenAPI specification that powers the API Reference on this site — a test suite in each repo fails if a spec operation is neither implemented nor explicitly recorded as one an API key cannot call, and if any SDK method targets an endpoint the spec does not declare — so the methods and types you use always match what the live API accepts and returns.

Already sending through Resend, SendGrid, Postmark, Mailgun, or Plunk? You don't need either SDK to try Sendly — the API speaks those providers' transactional-send dialects, so you can keep your existing vendor SDK and swap the base URL and API key. See Migrate from another provider.

Both SDK repositories are open source and welcome issues and pull requests.

TypeScript / JavaScript

Install from npm:

npm install sendly-sdk

Or install the latest main directly from GitHub:

npm install github:DevinoSolutions/sendly-js

Create a client with your secret API key and send an email:

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); // a real delivery state — poll emails.get(id)

Python

Requires Python 3.10 or newer. Install from PyPI (the distribution is sendly-python; the import name is sendly):

pip install sendly-python

Or install the latest main directly from GitHub:

pip install git+https://github.com/DevinoSolutions/sendly-python.git

The client reads your API key from the SENDLY_API_KEY environment variable:

from sendly import Sendly

sendly = Sendly()  # reads SENDLY_API_KEY

receipt = sendly.emails.send(
    {
        "from": "hello@yourdomain.com",
        "to": "customer@example.com",
        "subject": "Welcome aboard",
        "body": "<h1>Thanks for signing up!</h1>",
    }
)

# `status` is a real delivery state; poll `emails.get(receipt["id"])` for the
# events behind it.
print(receipt["id"], receipt["status"])

You can also pass the key explicitly with Sendly(api_key="sk_...").

The /api/v1 surface

From sendly-sdk 0.3.0 and sendly-python 0.2.0, both SDKs also cover the versioned /api/v1 surface: campaigns, segments, workflows, analytics, usage, and the v1 event methods — since joined by the test send and the project read described below, and, from 1.0.0, the default email send itself. The same client object serves both surfaces — no separate setup.

// Create a campaign, then send it — both calls accept an idempotency key,
// so a retried request can never create or send the campaign twice.
const campaign = await sendly.campaigns.create(
  {
    name: "October launch",
    subject: "We shipped something big",
    body: "<h1>It's here</h1>",
    from: "news@your-domain.com",
    audience_type: "ALL",
  },
  { idempotencyKey: "launch-oct-2026" },
);

await sendly.campaigns.send(campaign.id, undefined, { idempotencyKey: "launch-oct-2026-send" });
# The same two calls in Python.
campaign = sendly.campaigns.create(
    {
        "name": "October launch",
        "subject": "We shipped something big",
        "body": "<h1>It's here</h1>",
        "from": "news@your-domain.com",
        "audience_type": "ALL",
    },
    idempotency_key="launch-oct-2026",
)

sendly.campaigns.send(campaign["id"], idempotency_key="launch-oct-2026-send")

Pagination that walks itself

Every v1 list endpoint returns a cursor page — { data, has_more, next_cursor } — and every list method has a companion iterator that follows the cursor for you (listAll in TypeScript, iter_list in Python):

for await (const contact of sendly.segments.listContactsAll("seg_123")) {
  console.log(contact.email);
}
for campaign in sendly.campaigns.iter_list({"limit": 100}):
    print(campaign["name"], campaign["status"])

Keep the filters identical for the whole walk — the cursor encodes them, and changing them mid-pagination returns a 422 validation_error telling you to restart from the first page.

Typed errors on both dialects

Legacy /api/* endpoints report failures as a { success, error } envelope; /api/v1/* endpoints answer with RFC 9457 problem documents. Both land on the same exception classes, so one catch handles either surface. On a v1 failure two extra fields are populated: requestId (request_id in Python) — quote it in support requests — and fieldErrors (field_errors), the per-field breakdown of a 422 validation_error.

Verifying webhooks

Both SDKs ship a webhook signature verifier (verifySignature / verify_signature) that checks the X-Sendly-Signature and X-Sendly-Timestamp headers against the raw request body with a constant-time comparison. See Webhooks for the delivery contract.

Sending on /api/v1

From sendly-sdk 1.0.0 and sendly-python 1.0.0, emails.send is the versioned send. It posts to POST /api/v1/emails and answers 202 with { id, status, to, from }, where status is a real delivery state you can poll on. It takes a single recipient — use cc/bcc to copy others — instead of fanning an array out.

The pre-1.0 send is kept, unchanged, as emails.sendLegacy (send_legacy in Python): it posts to the legacy POST /api/emails, fans an array to out to several recipients, and answers { emails, timestamp } with row ids and no delivery status. Upgrading from 0.x is either a rename to sendLegacy (old shapes kept) or reading the receipt instead of the envelope; each repository's README has the details.

const receipt = await sendly.emails.send(
  { to: "user@example.com", subject: "Order confirmed", body: "<p>Thanks.</p>" },
  { idempotencyKey: `order-${orderId}` },
);
console.log(receipt.status);

Test sends

emails.sendTest (send_test) exercises rendering and the whole send path without reaching a live recipient. Two details are easy to get backwards:

  • The sandbox address is the sender, not the destination. It is resolved server-side, and a body that names a from is refused rather than ignored — so a request expecting a different sender never gets a success it would misread.
  • The mail lands in the project owner's own verified account email, the only address a sandbox send may reach. to is optional and defaults to it; any other value is refused.

The same content scan and the same daily and trust-tier caps apply as on a real send. Unlike send, a test send takes no idempotency key.

Mailboxes, projects, and guided domain setup

Three more capabilities are reachable with an API key:

  • Mailbox reads — mailboxes.list(), mailboxes.get(id) and mailboxes.listAppPasswords(id) (list_app_passwords in Python). get carries the IMAP and SMTP host, port, security and username a mail client needs; the mailbox password is never returned by any of them. App-password results are metadata only — lastFour identifies a credential without being enough to rebuild it — and cover only the passwords still active, not every one ever issued. These list the mailboxes themselves, never their contents: received messages are not part of the public API. See List mailboxes.
  • The current project — projects.get() takes no id and returns whichever project the key belongs to, including sandbox_address (the address a test send comes from).
  • Guided domain setup — domains.startSetup(id) (start_setup) begins the DNS hand-off and returns { token, connectUrl, expiresAt } exactly as the API returns it. Finishing setup means a person opening connectUrl and authorising the change at their registrar, so the SDKs hand back the link rather than modelling the flow behind it. See Guided DNS setup.

These, and the /api/v1 send methods above, ship in sendly-sdk 1.0.0 on npm and sendly-python 1.0.0 on PyPI. Earlier releases (0.4.0 / 0.2.0) have neither.

Operations the SDKs deliberately don't expose

A handful of endpoints resolve the acting project admin from a signed-in user before they read any permission at all. An API key carries no user, so they answer 401 to any key however broad its scopes. Rather than ship methods that could never succeed, both SDKs leave them out:

OperationUse instead
Create or delete a mailboxDashboard, or an agent connected over OAuth
Create or revoke a mailbox app passwordDashboard — there is no agent tool for these
List, create, rotate or revoke an API keyDashboard, or an agent connected over OAuth
Create a projectDashboard, or an agent connected over OAuth

This is a designed property, not a gap. Each repository's contract suite records these in a NOT_SDK_CALLABLE list and asserts it equals the set of operations the OpenAPI contract itself declares as session-only — in both directions, so an operation that becomes key-callable and a new key-refusing route that is missing from the list each fail the build.

Mailboxes are capped at 10 per project

Wherever you create one — the dashboard or an OAuth-connected agent — the eleventh is refused with 409 Conflict. The cap counts only mailboxes that hold, or are on their way to holding, a real account: PROVISIONING, ACTIVE and SUSPENDED. FAILED rows are excluded on purpose, so that a run of failed provisions cannot quietly spend a project's allowance and surface later as a limit error naming the wrong cause. Those failed rows are still returned by mailboxes.list(), so a project that has had failures can list more than ten.

The same rule shapes the agent surface: where a matching MCP tool exists at all, it is never offered to a connection made with an API key and is reachable only over OAuth.

Authentication

Both SDKs authenticate with a project API key sent as a bearer token. Use a secret key (sk_*) for full access, or a public key (pk_*) for sending-only clients such as browser code. See API Keys for where to find your keys and how the two differ.

Staying in sync with the API

The API Reference on this site is generated from the same OpenAPI specification the SDKs are tested against. When the API changes, the spec and these reference pages regenerate together, and each SDK re-syncs its committed copy of the spec — its contract suite then fails until every new operation is mapped, so an SDK release that passes CI covers exactly the endpoints documented here.

On this page