# SDKs (/sdks)



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](/api-reference/overview) 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.

<Cards>
  <Card title="sendly-js" href="https://github.com/DevinoSolutions/sendly-js">
    Official TypeScript / JavaScript SDK. Type-safe access to every endpoint an API key can call — emails, contacts, campaigns, segments, workflows, analytics, mailboxes, and more.
  </Card>

  <Card title="sendly-python" href="https://github.com/DevinoSolutions/sendly-python">
    Official Python SDK with full type hints. The same resources, with a Pythonic API.
  </Card>
</Cards>

<Callout type="info">
  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](/migrate).
</Callout>

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

## TypeScript / JavaScript [#typescript--javascript]

Install from npm:

```bash
npm install sendly-sdk
```

Or install the latest `main` directly from GitHub:

```bash
npm install github:DevinoSolutions/sendly-js
```

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

```ts
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 [#python]

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

```bash
pip install sendly-python
```

Or install the latest `main` directly from GitHub:

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

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

```python
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 [#the-apiv1-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.

```ts
// 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" });
```

```python
# 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 [#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):

```ts
for await (const contact of sendly.segments.listContactsAll("seg_123")) {
  console.log(contact.email);
}
```

```python
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 [#typed-errors-on-both-dialects]

Legacy `/api/*` endpoints report failures as a `{ success, error }` envelope; `/api/v1/*` endpoints
answer with [RFC 9457 problem documents](/api-reference/errors). 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 [#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](/guides/webhooks) for the delivery contract.

### Sending on `/api/v1` [#sending-on-apiv1]

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.

```ts
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 [#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 [#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](/api-reference/mailboxes/listMailboxes).
* **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](/guides/guided-dns-setup).

{/*
  Every `**x.y.z**` on this page is checked against sdk-versions.json at the repo root —
  the one record of what the standalone SDK repos have actually published — by
  scripts/ci/check-image-sdk-parity.mjs. A version stated here that is not released, or a
  release this page never mentions, fails CI. Update sdk-versions.json when you publish,
  and this page in the same change.
  */}

<Callout type="info">
  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.
</Callout>

## Operations the SDKs deliberately don't expose [#operations-the-sdks-deliberately-dont-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:

| Operation                                 | Use instead                                  |
| ----------------------------------------- | -------------------------------------------- |
| Create or delete a mailbox                | Dashboard, or an agent connected over OAuth  |
| Create or revoke a mailbox app password   | Dashboard — there is no agent tool for these |
| List, create, rotate or revoke an API key | Dashboard, or an agent connected over OAuth  |
| Create a project                          | Dashboard, 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.

<Callout type="warn" title="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.
</Callout>

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

## Authentication [#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](/guides/api-keys) for where to find your keys and how the two differ.

## Staying in sync with the API [#staying-in-sync-with-the-api]

The [API Reference](/api-reference/overview) 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.
