Skip to content
Docs menu

TypeScript SDK

The official zsender-sdk package: install it, construct a client, send an email, and know exactly what it throws and what it retries for you.

After this page you'll be able to install the zsender-sdk package, construct a client, send an email through it, and, the part worth getting right the first time, know exactly what it throws on failure and which of those failures it already retried for you before you ever see them.

zsender-sdk has zero runtime dependencies. It's built on web standards only (fetch, crypto.subtle, and AbortSignal.timeout) so the same package runs unmodified on Node 20+, Deno, Bun, and Cloudflare Workers. There is no Node-specific code path to fall back to and nothing to polyfill.

Install

Install
npm i zsender-sdk

Construct a client

A bare string is shorthand for { apiKey }. The longer form is available when you need to override a default: a self-hosted proxy in front of the API, a tighter per-attempt timeout, or your own retry policy:

Construct a client
import { Zsender } from 'zsender-sdk';

// A bare string is shorthand for `{ apiKey }`.
const zsender = new Zsender(process.env.ZSENDER_API_KEY!);

// The long form, with every default spelled out. A zst_... test-mode key works exactly
// like a live key here -- the SDK never branches on the key prefix; the server decides
// live vs. sandbox routing.
const configured = new Zsender({
  apiKey: process.env.ZSENDER_API_KEY!,
  baseUrl: 'https://api.zsender.io', // default
  timeoutMs: 30_000,                 // default -- applies PER ATTEMPT, not per call
  retry: { attempts: 3 },            // default -- pass `retry: false` to disable retries entirely
});

console.log(zsender.emails, configured.domains);

timeoutMs applies per attempt, not per call: a call that retries twice can take up to roughly three times that long end to end. A zst_... test-mode key works exactly like a live key here: the client never branches on the key prefix, because the server is what decides live vs. sandbox routing. See Test mode.

Send an email

Send an email
import { Zsender } from 'zsender-sdk';

const zsender = new Zsender(process.env.ZSENDER_API_KEY!);

const sent = await zsender.emails.send({
  to: ['recipient@example.com'],
  from: 'hello@yourverifieddomain.com',
  subject: 'Hello from zSender',
  text: 'Your first email through the API.',
});

console.log(sent.messageId);

Every field the SDK accepts here is documented on /docs/api/emails (that reference is generated straight from the same schema the server validates against, so it never drifts). This page covers only what's specific to calling it through the SDK rather than curl.

Every call to send() carries an automatic idempotency key: a fresh one generated per call, reused across that same call's own retries. A retry the SDK performs on your behalf (see below) can never turn into a duplicate email, because it's replaying the same key against the same request. Two separate calls to send(), on the other hand, get two different keys and are two separate emails: that's what calling it twice means. If you need to deduplicate across calls you make yourself (a retry from a queue worker, say, rather than one the SDK issued), pass your own: zsender.emails.send(input, { idempotencyKey: '...' }).

Errors and retries

Every method throws one of three error classes. Which one you catch tells you something real about what happened: don't collapse them into one generic handler:

  • ZsenderError, a known refusal: the server answered, using the error contract: code, status, message, details (optional), requestId (optional), retryable, and retryAfterMs (optional, a number). Branch on code, never on message, which may be reworded at any time. code is typed ApiErrorCode | (string & {}) rather than a closed union: a slug your SDK version predates still arrives with the server's own message instead of collapsing to internal_error, so a switch (err.code) needs a default branch: it will not fail to compile just because a slug is unhandled. retryAfterMs is the parsed Retry-After header the server sent, in milliseconds, present only when the response carried one. It matters specifically if you've passed retry: false and are driving your own backoff: it's the delay the SDK's built-in retry loop would have honored (up to its own 60-second ceiling) had it been left on, so your own handler can wait for the same interval instead of guessing one. One caveat on “known refusal”: a non-2xx whose body isn't the error contract at all (a load balancer's 502 text/html, say) arrives as internal_error with a generic message, since the body isn't ours to repeat. Nothing in such a response says whether it was generated before your request reached the API or after, so it is really an unknown outcome wearing this class, and it carries no idempotency key. Treat a 5xx with internal_error as “may or may not have happened” rather than as a refusal.
  • ZsenderConnectionError: no server response at all: a DNS failure, a refused connection, a client-side timeout.
  • ZsenderResponseError (something answered, but it isn't a zSender response this client can use: a proxy's HTML error page, an empty body, JSON that isn't the shape the endpoint declares, or a 2xx carrying no X-Request-Id header): every genuine zSender response carries one, so its absence is how the client tells a real response from an intermediary's.

ZsenderConnectionError and ZsenderResponseError share one property that ZsenderError does not: the outcome of the request is genuinely unknown: nothing told you whether the server actually acted on it, only that you can't tell from what came back. Both carry an idempotencyKey when the failing call had one (today, that's emails.send()). Retrying with that same key lets the API safely replay a completed send or perform one that never happened, instead of risking a duplicate, which is exactly why the SDK is conservative about retrying either of these two automatically (next section).

A ZsenderResponseError arrives from one of two moments, and it matters which, because only the first is inside the SDK's retry loop. While the response was being read (a proxy page, an empty body, a missing X-Request-Id) the retry rule below applies. After the response was read, when the body parsed as an object but is missing a field the endpoint declares, it is detected past the point where a retry is still possible and is never retried, in any mode. That is precisely why the idempotency key travels with it: emails.send() attaches its key to both, so a 2xx it can't use never leaves you guessing whether to mint a new one.

Errors and retries
import {
  Zsender,
  ZsenderError,
  ZsenderConnectionError,
  ZsenderResponseError,
} from 'zsender-sdk';

const zsender = new Zsender(process.env.ZSENDER_API_KEY!);

try {
  await zsender.emails.send({
    to: ['recipient@example.com'],
    from: 'hello@yourverifieddomain.com',
    subject: 'Hello from zSender',
    text: 'Errors and retries, demonstrated.',
  });
} catch (err) {
  if (err instanceof ZsenderError) {
    // The server answered using the error contract. Branch on `code`, never on `message`,
    // which may be reworded at any time. `code` is typed `ApiErrorCode | (string & {})`: a
    // slug newer than your SDK version still arrives with the server's own message rather
    // than collapsing to `internal_error`, so a `switch` on it needs a `default` branch.
    // `retryable` says whether this code is one the SDK retries automatically; seeing it
    // here with retries on means every attempt failed.
    console.error(err.code, err.status, err.retryable, err.message);
  } else if (err instanceof ZsenderConnectionError || err instanceof ZsenderResponseError) {
    // The two unknown-outcome classes. ZsenderConnectionError means nothing answered at all
    // (DNS failure, refused connection, timeout); ZsenderResponseError means something
    // answered and it is not a response this client can use -- a proxy's HTML, an empty
    // body, a 2xx missing X-Request-Id, or JSON that is not the shape the endpoint
    // declares. Either way you cannot tell whether the send happened, so DO NOT mint a new
    // idempotency key: retry with the one the failing call used and let the API decide.
    console.error('unknown outcome:', err.message, 'retry with:', err.idempotencyKey);
  } else {
    throw err;
  }
}

// Disable the built-in retry loop entirely and drive your own -- useful if you want to
// log each attempt yourself, or apply a different backoff policy than the default (3
// attempts, exponential backoff with full jitter, 500ms base / 8s per-attempt cap).
const noRetry = new Zsender({ apiKey: process.env.ZSENDER_API_KEY!, retry: false });
console.log(noRetry.domains);

Retries are on by default: 3 total attempts (the first try plus two retries), exponential backoff with full jitter, 500ms base delay, 8 second per-attempt cap. Only three slugs are ever retried automatically: rate_limited, service_unavailable, and bad_gateway. Everything else (including quota_exceeded and sandbox_unavailable, which each share an HTTP status with a slug that is retried but describe a condition a retry cannot clear) is deliberately left alone. A Retry-After header on a rate_limited response is honored up to a 60-second ceiling; past that, the SDK fails immediately rather than blocking your code for a minute or more.

An unknown-outcome error retries only where a repeat is safe

Whether a ZsenderConnectionError or a ZsenderResponseError gets retried is decided per endpoint, not per HTTP method: the question each call answers for itself is “is repeating this exact request actually safe?”, and an idempotency key is one way to make that true, not the only one. The rule scores both classes identically, because both mean the same thing: something came back unusable, or nothing came back at all. It governs errors raised during the request: a wrong-shape 2xx (above) is detected afterwards and is outside it. Every GET retries on either one (reading twice changes nothing), and so do PATCH and DELETE (both happen to be naturally idempotent here). For POST, only two calls retry, and they get there by different routes: emails.send(), because of the idempotency key above, and domains.verify(), because re-checking DNS reaches the same verdict whether you check it once or twice, backed by the server's own 15-second per-domain cooldown. Every other POST (creating a domain, adding a suppression, simulating a sandbox webhook) retries only on a status the server actually answered with; a timeout leaves the outcome unknown, and an unknown outcome isn't a license to repeat a write that might create a duplicate. Pass retry: false to the constructor to turn all of this off and drive retries yourself.

Cancelling a call

Every method that performs HTTP takes request options as its last argument, and signal cancels the call. It is composed with the client's own timeout, so whichever fires first wins.

A cancellation you asked for surfaces as a plain AbortError, exactly as it would from bare fetch. It is deliberately not wrapped in ZsenderConnectionError, because that class is marked retryable: wrapping it would make the retry loop re-issue a request you had just cancelled.

Cancel a call
import { Zsender } from 'zsender-sdk';

const zsender = new Zsender(process.env.ZSENDER_API_KEY!);

// Every method that performs HTTP takes request options as its last argument.
const controller = new AbortController();
setTimeout(() => controller.abort(), 2_000);

try {
  const page = await zsender.emails.list({ limit: 50 }, { signal: controller.signal });
  console.log(page.data.length);
} catch (err) {
  // A cancellation you asked for arrives as a plain AbortError. It is not turned into a
  // ZsenderConnectionError, and it is never retried.
  if (err instanceof Error && err.name === 'AbortError') {
    console.log('cancelled');
  } else {
    throw err;
  }
}

Verify a webhook

zsender.webhooks.verify() checks a delivery's Standard Webhooks signature and, only on success, hands back the parsed event: it never throws, since a forged or replayed request is the expected failure case for a receiver, not an exceptional one. The full mechanics (which headers are checked, the signed content, the five failure reasons, and the retry schedule on the sending side) are covered in Webhooks; this page covers only the one fact specific to using the SDK for it, below.

camelCase over a snake_case wire

The wire itself, every /v1 request and response body, is snake_case. The SDK's surface is camelCase (replyTo, inReplyTo, messageId, openTracking, and so on), and the translation happens entirely inside the SDK: you never see a snake_case key on a normal method call.

There is exactly one exception: the payload handed back by webhooks.verify(). That event, result.event, keeps the wire's own snake_case shape (event.event, event.message_id, event.uuid, event.timestamp, event.data) rather than being translated. This is deliberate, not an oversight: that payload is a delivery contract other services and languages consume too (see Webhooks for the full shape), so reshaping it into the SDK's own convention would make it a different contract than the one you signed for. Reach for event.messageId out of habit here and TypeScript catches it at compile time: TS2551: Property 'messageId' does not exist on type '...'. Did you mean 'message_id'?, not a silent undefined at runtime. The type of event genuinely has no messageId field, so this is one of the few places the SDK's two conventions colliding actually helps you: get the casing wrong and the compiler tells you before the code ships, instead of you discovering it from a dashboard that never received the field you thought you logged.

Next

For rate limits, monthly quotas, and the per-domain concurrency and retry behavior the server itself applies (independent of anything the SDK does on your side), see Limits and reliability.