Reference

SDKs

6 official packages: 5 language SDKs for Node, Python, Go, PHP and Ruby, plus @sendaramail/react-email, which renders a React component to email HTML. The API clients generate idempotency keys, provide message pagination helpers, and verify webhook signatures.

Sendara publishes 6 official packages against the same API. 5 are language SDKs, for Node, Python, Go, PHP and Ruby. The sixth is @sendaramail/react-email, which renders a React component to email HTML. Each package carries its own version number, so pin each package on its own:

  • sendara (Node / TypeScript): npm install sendara. First-class types (dist/index.d.ts), ESM-only, for Node 18 and newer. The package uses Node's crypto APIs and is not an edge-runtime package.
  • sendara (Python): pip install sendara. A synchronous Sendara client and an AsyncSendara client that share one typed model layer.
  • sendara-go (Go): go get github.com/sendaramail/sendara-go. Idiomatic, context-aware, functional options for config, a typed *sendara.Error, and cursor iterators.
  • sendaramail/sendara (PHP): composer require sendaramail/sendara. Zero-dependency, PSR-friendly, with a Laravel adapter.
  • sendara (Ruby): gem install sendara. Standard-library only, with a Rails railtie.
  • @sendaramail/react-email: write an email as a React component, then render it to HTML. Pass that HTML to emails.send.
You can also call the raw HTTP API, in any language. Every SDK is a wrapper over the same endpoints. See the API reference. Authentication is always Authorization: Bearer sk_live_…, or sk_test_… in the sandbox.

Install

install.sh
npm install sendara
# or: pnpm add sendara · yarn add sendara · bun add sendara

Quickstart

Construct a client with your API key. Then send. Every SDK generates an idempotency_key when you omit one. Node, Python and Go reuse that generated key for automatic send retries. PHP and Ruby do not automatically retry POST sends; pass a deterministic key when your application may call the send again.

send.ts
import { Sendara } from "sendara";

const sendara = new Sendara(process.env.SENDARA_API_KEY!);

const { id } = await sendara.emails.send({
  from: "hello@yourdomain.com",
  to: "user@acme.com",
  subject: "Welcome to Acme",
  html: "<h1>Welcome 🎉</h1>",
});

console.log(id); // msg_a1b2c3
Python reserves from, so the email helper takes from_ (keyword-only). It maps to the from_email the API expects. The sender must be on a verified domain. In Go, params are passed by value (sendara.EmailSendParams{…}), and every call takes a context.Context first.

Client configuration

Every client accepts the same 3 concepts: a base URL, a timeout and a retry ceiling, with names that follow the language. The default timeout is 30 seconds. The default retry count is 2 in Node, PHP and Ruby, and 3 in Python and Go. Change these values for a test or for a gateway of your own.

client.ts
import { Sendara } from "sendara";

const sendara = new Sendara(process.env.SENDARA_API_KEY!, {
  baseUrl: "https://api.sendara.dev", // override for self-host / proxy
  timeout: 30_000,                     // per-request, milliseconds
  maxRetries: 2,                       // 429 + 5xx + network, exponential backoff
});
apiKeystringRequired
Your secret key. Use sk_live_… in production and sk_test_… for the sandbox. This is the first positional argument to every client constructor.
baseUrlstringOptional
API origin. Defaults to https://api.sendara.dev. Point it at a proxy or gateway in front of the API if you have one. (Go: WithBaseURL, Python: base_url.)
timeoutnumberOptional
Per-request timeout, and the unit follows the language. Node takes milliseconds (default 30000). Python takes float seconds, and PHP and Ruby take integer seconds (default 30). Go takes a time.Duration, so write WithTimeout(30*time.Second). WithTimeout(30) is 30 nanoseconds, because an untyped constant becomes a Duration in nanoseconds. A timeout can be retried when that SDK marks the request idempotent.
maxRetriesnumberOptional
Retry ceiling for requests the SDK marks idempotent. Node retries 429, 5xx and network failures; Python also retries 408 and 409; Go retries 429, 5xx and network failures with full jitter. PHP and Ruby retry safe HTTP methods but not POST sends. Defaults: 2 in Node, PHP and Ruby; 3 in Python and Go. Set 0 to disable retries. (Go: WithMaxRetries.)
Retry behavior is SDK-specific and only applies when that client marks a request idempotent. Node and Go retry 429, 5xx, and network failures; Python also retries 408 and 409. There are three jitter schemes. Go uses full jitter, so a delay falls anywhere in [0, backoff). Node, PHP and Ruby use the same equal-jitter formula, so a delay falls in [backoff/2, backoff). Python adds up to 25% on top of the backoff instead. PHP and Ruby retry safe HTTP methods, but not POST sends. A limiter-generated 429 honors Retry-After. See rate limits.

Typed errors

A failed request raises a typed exception that mirrors the { "error": { "code", "message" } } envelope of the API. Every error carries status, code and message. Branch on code.

Each client also exposes a requestId field. It reads the X-Request-Id response header. The Sendara API does not set that header today, so requestId is always empty. Quote the code and the message in a support ticket instead.
errors.ts
import {
  Sendara,
  SendaraError,
  AuthenticationError,
  PermissionDeniedError,
  ValidationError,
  ConflictError,
  RateLimitError,
  ServerError,
} from "sendara";

try {
  await sendara.emails.send({ from, to, subject, html });
} catch (err) {
  if (err instanceof RateLimitError) {
    // err.retryAfter is seconds from the Retry-After header (may be undefined).
    await sleep((err.retryAfter ?? 1) * 1000);
  } else if (err instanceof ConflictError) {
    // 409: idempotency-key reuse, a suppressed recipient, or similar conflict.
    // Branch on err.code to tell them apart:
    if (err.code === "recipient_suppressed") return;
  } else if (err instanceof PermissionDeniedError) {
    // 403: missing scope or another authorization-policy block
  } else if (err instanceof ValidationError) {
    // 400 / 422: bad input
  } else if (err instanceof SendaraError) {
    // Base class. Every API error extends this.
    console.error(err.status, err.code, err.message, err.requestId);
  } else {
    throw err; // not from Sendara
  }
}

Node and Python map status codes onto subclasses you can instanceof / except on: AuthenticationError (401), PermissionDeniedError / PermissionError_ (403), ValidationError (400/422), NotFoundError (404), ConflictError (409), RateLimitError (429, exposing retryAfter/retry_after), and ServerError (5xx). All extend SendaraError. Sub-cases within a status (a suppressed recipient vs. an idempotency conflict, both 409) are distinguished by the string code. See the full list on the errors page.

Go takes a different shape: a single *sendara.Error with a Code string field, classifier methods (IsRateLimited(), IsConflict(), IsValidation(), …), and errors.As to unwrap. There are no per-status Go error types.

Common operations

The same task across the SDKs. Each block is real, runnable code against the current packages. Copy the tab for your language. The examples below show Node, Python, and Go. PHP and Ruby provide the equivalent operations with call shapes that follow their languages.

Send an email

The ergonomic emails.send helper takes a flat object and returns the created message (id, status).

send.ts
const { id, status } = await sendara.emails.send({
  from: "hello@yourdomain.com",
  to: "user@acme.com",
  subject: "Your receipt",
  html: "<h1>Thanks for your order</h1>",
  text: "Thanks for your order",
});

Send a batch

One request, many sends. Each item is processed independently, so partial success is normal. The result preserves request order with per-item success or error. Give every item its own idempotency_key.

batch.ts
// One request, many sends. Each item is processed independently.
const results = await sendara.sendBatch([
  {
    channel: "email",
    idempotency_key: "welcome-101",
    destination: { email: "a@acme.com" },
    payload: { subject: "Hi", body_html: "<p>Hi a</p>" },
    metadata: { from_email: "hello@yourdomain.com" },
  },
  {
    channel: "email",
    idempotency_key: "welcome-102",
    destination: { email: "b@acme.com" },
    payload: { subject: "Hi", body_html: "<p>Hi b</p>" },
    metadata: { from_email: "hello@yourdomain.com" },
  },
]);

for (const r of results) {
  if (r.success) console.log("sent", r.response?.id);
  else console.error("failed", r.error?.code, r.error?.message);
}

Send using a saved template

Pass templateId + templateVars instead of an inline body to render a stored template at send time. Use templates.render to preview the resolved payload without sending.

template-send.ts
// Render a stored template at send time by passing template_id + variables.
await sendara.emails.send({
  from: "hello@yourdomain.com",
  to: "user@acme.com",
  subject: "Your receipt", // The stored template subject wins when it is set.
  templateId: "tmpl_receipt",
  templateVars: { name: "Ada", total: "$42.00", order_id: "1099" },
});

// Preview without sending. render returns the resolved channel payload.
const preview = await sendara.templates.render("tmpl_receipt", {
  name: "Ada",
  total: "$42.00",
});
console.log(preview); // { subject, body_html, body_text }

List messages with auto-pagination

List endpoints are cursor-paginated (limit up to 100, an opaque cursor, and a next_cursor in the response). The iterators hide the cursor plumbing and fetch each page lazily as you go.

paginate.ts
// Async-iterate every message. The SDK follows next_cursor for you.
for await (const message of sendara.messages.list({ channel: "email" })) {
  console.log(message.id, message.status);
}

// Or take one page at a time when you need the raw cursor.
const page = await sendara.messages.page({ limit: 100 });
console.log(page.messages.length, page.next_cursor);
if (page.next_cursor) {
  const next = await sendara.messages.page({ cursor: page.next_cursor });
}
The names differ per language: Node iterates messages.list(…) directly (and messages.page(…) for a single page), Python uses messages.iter(…) to auto-paginate and messages.list(…) for one page, and Go uses Messages.Iterator(…) with Next(ctx)/Message()/Err(). Sendara orders a page by created_at descending, with a keyset cursor, so the order is stable as new messages arrive. See pagination.

Add and verify a domain

Register a sending domain. Publish the 6 DNS records that the API returns. Then check the verification again. fully_verified becomes true after DKIM, SPF, the MAIL FROM record and DMARC all pass. See domains.

domains.ts
// Add a sending domain. The response carries the DNS records to publish.
const domain = await sendara.domains.create("yourdomain.com");
for (const r of domain.dns_records) {
  console.log(r.type, r.name, r.value);
}

// After publishing the records, re-check verification.
const result = await sendara.domains.verify("yourdomain.com");
console.log(result.fully_verified); // true once DKIM + SPF + DMARC pass

Create and rotate an API key

Key management needs an admin-scoped key or a dashboard session. The plaintext key is returned exactly once on create and rotate. Store it immediately.

keys.ts
// Create a send-scoped key. The plaintext key is returned exactly once.
const created = await sendara.apiKeys.create({ scope: "send" });
console.log(created.key); // sk_live_… shown once. Store it now.

// Rotate it later. This revokes the old key and returns the replacement plaintext.
const newKey = await sendara.apiKeys.rotate(created.id);
console.log(newKey); // Store it now; the old key ID is already revoked.

Verify a webhook

Each SDK gives you a verify helper, so you write no HMAC code. Give it the raw request body, the request headers, and your subscription's signing secret. It checks the Sendara-Signature against HMAC-SHA256(secret, "<Sendara-Timestamp>.<rawBody>") in constant time, enforces a five-minute timestamp tolerance to defeat replays, and returns the parsed, typed event. On a mismatch it raises a verification error.

route.ts
import { verifyWebhook, WebhookVerificationError } from "sendara";

// Next.js Route Handler. Read the RAW body, not a parsed object.
export async function POST(req: Request) {
  const raw = await req.text();
  try {
    const event = verifyWebhook(
      raw,
      Object.fromEntries(req.headers),
      process.env.SENDARA_WEBHOOK_SECRET!,
    );
    // event is typed: event.event_type, event.message_id, event.payload …
    switch (event.event_type) {
      case "bounced":
        // event.event_id is stable across retries. Dedupe on it.
        break;
    }
    return new Response(null, { status: 200 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return new Response("bad signature", { status: 401 });
    }
    throw err;
  }
}
The signature covers the exact bytes of the body. Capture the raw payload before any JSON middleware parses it (Next.js await req.text(), Express express.raw(), Flask request.get_data(), Go io.ReadAll(r.Body)) or verification will always fail. Mind the helper shapes: verifyWebhook(rawBody, headers, secret) in Node, webhooks.verify(secret, payload, headers) in Python, and sendara.VerifyWebhookRequest(secret, header, body, tolerance) in Go. Full scheme and retry semantics live on the webhooks page.

Idempotency

Every send carries an idempotency_key. Retrying with the same key returns the original result instead of sending again, so network retries never double-send. The SDKs generate one automatically for emails.send. For batch sends, set a deterministic key per item so a retried job is a no-op. Reusing a key with a different payload returns 409 idempotency_key_reused.

idempotency.ts
// Pass your own key to make a specific send safely retriable end-to-end.
await sendara.emails.send({
  from: "hello@yourdomain.com",
  to: "user@acme.com",
  subject: "Your receipt",
  html: "<p>Thanks!</p>",
  idempotencyKey: `receipt-${orderId}`, // same order ⇒ at most one email
});

Write an email with React

@sendaramail/react-email lets you write transactional emails as React components and render them to email-safe HTML you pass straight to emails.send. Components compile to inline-styled, table-based markup that survives the major mail clients, so you keep JSX ergonomics without fighting Outlook.

emails/Welcome.tsx
import {
  Html,
  Head,
  Body,
  Container,
  Heading,
  Text,
  Button,
} from "@sendaramail/react-email";

export function Welcome({ name, url }: { name: string; url: string }) {
  return (
    <Html>
      <Head />
      <Body style={{ backgroundColor: "#f6f6f6" }}>
        <Container>
          <Heading>Welcome, {name} 🎉</Heading>
          <Text>Thanks for joining Acme. Confirm your address to get going.</Text>
          <Button href={url}>Confirm email</Button>
        </Container>
      </Body>
    </Html>
  );
}

Render the component to HTML and send it. render is async and resolves to a string of inlined HTML. Pass it as the html field. Use renderEmail when you want a multipart message with a plain-text part too.

send-welcome.tsx
import { Sendara } from "sendara";
import { renderEmail } from "@sendaramail/react-email";
import { Welcome } from "./emails/Welcome";

const sendara = new Sendara(process.env.SENDARA_API_KEY!);

const { html, text } = await renderEmail(
  <Welcome name="Ada" url="https://acme.com/confirm?t=abc" />,
);

await sendara.emails.send({
  from: "hello@yourdomain.com",
  to: "ada@acme.com",
  subject: "Welcome to Acme",
  html,
  text, // most inboxes prefer a multipart message
});
To get one part only, call render(component), which returns the HTML, and renderText(component), which returns the plain text. Both helpers are asynchronous. The React Email guide covers the prebuilt branded templates.

Test with the SDKs

Pass a test key (sk_test_…) and every SDK talks to the sandbox: sends are simulated and never billed, but still drive webhooks. Address the simulator inbox to force an outcome: delivered@, bounced@, or complained@ on any domain.

sandbox.ts
const sandbox = new Sendara(process.env.SENDARA_TEST_KEY!); // sk_test_…

await sandbox.emails.send({
  from: "hello@yourdomain.com",
  to: "bounced@example.com", // emits synthetic sent, then bounced webhooks
  subject: "Sandbox check",
  html: "<p>Not really sent.</p>",
});

To send a real email to one of your own verified test recipients (free, capped per day), set testSend. The SDK forwards it as test_send: true:

test-send.ts
await sendara.emails.send({
  from: "hello@yourdomain.com",
  to: "you@yourcompany.com", // must be a verified test recipient
  subject: "UAT real delivery",
  html: "<p>This one actually arrives.</p>",
  testSend: true,
});
A test-send guard returns one of two codes, and you branch on code. An address that is not a verified test recipient returns recipient_not_verified with status 403. A recipient that already took its 10 test sends today returns test_send_daily_limit with status 429. See sandbox & test sends for the full flow.
Source, changelogs, and issues for all six packages live on GitHub. The packages version independently, so pin each to its own latest version.

Common questions

Where does each package come from?
Node and React Email come from npm, as sendara and @sendaramail/react-email. Python comes from PyPI as sendara. Ruby comes from RubyGems as sendara. PHP comes from Packagist as sendaramail/sendara. Go comes from github.com/sendaramail/sendara-go, and go get takes the current version.
Do I have to generate the idempotency key myself?
Not for a single SDK call: the SDK generates a key before the request leaves your process. For a retry started by your own application, pass and reuse an explicit key so the second call collapses onto the first. Node, Python and Go automatically retry sends with the generated key; PHP and Ruby do not automatically retry POST sends. The raw HTTP API always requires the key.
Python reserves from. Which parameter do I pass?
Pass from_, which is keyword-only. It maps to the from_email field that the API expects. In Go, params go by value as sendara.EmailSendParams, and every call takes a context.Context first.
What are the client defaults?
Every client takes a base URL, a timeout and a retry ceiling, and the option name follows the language. Node and PHP take baseUrl, timeout and maxRetries. Python and Ruby take base_url, timeout and max_retries. Go takes the functional options WithBaseURL, WithTimeout and WithMaxRetries. The default timeout is 30 seconds. The default retry count is 3 in Python and Go, and 2 in Node, Ruby and PHP. A retry runs only on a request that the SDK marks idempotent.
Can I call an SDK from the browser?
No. Treat an API key as a password. Never commit a key, and never put a key in client-side code. Call Sendara from your backend, and pass the result to your frontend.