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 synchronousSendaraclient and anAsyncSendaraclient 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.
Authorization: Bearer sk_live_…, or sk_test_… in the sandbox.Install
npm install sendara
# or: pnpm add sendara · yarn add sendara · bun add sendaraQuickstart
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.
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_a1b2c3from, 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.
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
});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.
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.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.
*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).
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.
// 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.
// 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.
// 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 });
}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.
// 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 passCreate 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.
// 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.
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;
}
}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.
// 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.
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.
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
});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.
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:
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,
});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.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.