Documentation menu

Libraries and tools

Node.js SDK

@tsmart/sdk is the Node.js client for tsmartSend. It covers every endpoint of the API, retries rate-limited requests, verifies webhook signatures and ships with TypeScript types. It has no dependencies and runs on Node.js 18 or later.

Install

The package is published on tsmart.ai. Install it from its URL:

Shell
npm install https://tsmart.ai/sdk/node/tsmart-sdk-1.1.1.tgz

The package is ESM only: use import, in a project with "type": "module" or in .mjs files. SHA-256 checksums of the published files are in /sdk/node/SHA256SUMS.

Configure

Create an API key in the console under tsmartSend → API keys (see API keys) and keep it in the TSMART_API_KEY environment variable.

Node.js
import { Tsmart } from "@tsmart/sdk";

// Reads TSMART_API_KEY (and TSMART_BASE_URL, if set)
const tsmart = new Tsmart();

// Or pass the key and options yourself
const custom = new Tsmart("ts_xxxxxxxx_…", {
  timeout: 10_000,
  maxRetries: 3,
});
new Tsmart(apiKey?, options?)
Name Type Description
apiKey string The API key ts_…. Default: the TSMART_API_KEY environment variable. Without a key the constructor throws a TypeError.
baseUrl string Default: the TSMART_BASE_URL environment variable, else https://send.tsmart.ai. It must use https (plain http only for localhost, 127.0.0.1 and [::1]) and contain no credentials, query or fragment; anything else throws a TypeError, so the API key never travels unencrypted. A trailing slash is removed.
timeout number Milliseconds per request attempt, including reading the response. Default 30000.
maxRetries number Retries of rate-limited requests and of GET requests that failed on the network. Default 2.
fetch function A fetch implementation to use instead of the global fetch. It is called with redirect: "manual"; redirects are not followed.

new Tsmart(options) with an options object alone also works; it may contain apiKey.

Send an email

Node.js
const { data, error } = await tsmart.emails.send({
  from: "Acme <hello@acme.com>",
  to: ["jane@example.com"],
  subject: "Welcome to Acme",
  html: "<p>Thanks for signing up.</p>",
});

if (error) {
  console.error(error.statusCode, error.name, error.message);
} else {
  console.log("Email id:", data.id);
}

The from address must be on a domain your company has verified (see Domains and DNS). Fields use the API's names, so everything in Sending email and the Emails API reference applies as written.

Responses

Every method returns a promise of { data, error, headers }. It does not throw when the API answers with an error:

  • On success, data is the API's JSON response and error is null.
  • On failure, data is null and error is the API's error object { statusCode, name, message }.
  • headers holds the response headers with lower-case names, such as ratelimit-remaining, or null when no response arrived.

The client throws only for programming errors: a missing API key, or a missing required argument such as an id. Those throw a TypeError before anything is sent.

Emails

Node.js
import { readFile } from "node:fs/promises";

const { data, error } = await tsmart.emails.send(
  {
    from: "Acme Billing <billing@acme.com>",
    to: "jane@example.com",
    cc: ["accounts@example.com"],
    reply_to: "support@acme.com",
    subject: "Your invoice INV-1042",
    html: "<p>Your invoice is attached.</p>",
    text: "Your invoice is attached.",
    headers: { "X-Entity-Ref-ID": "INV-1042" },
    tags: [{ name: "category", value: "invoice" }],
    attachments: [
      { filename: "INV-1042.pdf", content: await readFile("INV-1042.pdf"), content_type: "application/pdf" },
    ],
  },
  { idempotencyKey: "invoice-INV-1042" },
);
  • to, cc, bcc and reply_to take one address or an array. At most 50 recipients across to, cc and bcc.
  • html or text is required. Without text, a plain-text part is generated from the HTML; an empty text sends none.
  • Attachment content is base64. A Buffer, Uint8Array or ArrayBuffer is encoded for you. Up to 20 attachments and 20 MB in total; add content_id to show an image inline with cid: in the HTML.
  • The camelCase spellings replyTo, scheduledAt, and contentType and contentId on attachments, are converted to the API names.
  • idempotencyKey is sent as the Idempotency-Key header: a retry with the same key and body within 24 hours returns the first response and sends nothing again.

Scheduling

scheduled_at takes ISO 8601 with a time zone (a Date works too) or a phrase such as in 10 min, in 2 hours or in 1 day, at most 30 days ahead. A scheduled email can be moved or canceled until it is sent.

Node.js
const { data } = await tsmart.emails.send({
  from: "hello@acme.com",
  to: "jane@example.com",
  subject: "Your trial ends tomorrow",
  text: "Upgrade any time from your account page.",
  scheduled_at: "in 1 day",
});

await tsmart.emails.update(data.id, { scheduled_at: new Date("2026-10-20T09:00:00+03:00") });
await tsmart.emails.cancel(data.id);

Reading emails and events

Node.js
// One email, with its html and text while they are kept (3 days) and its tags
const { data: email } = await tsmart.emails.get(id);
console.log(email.last_event); // sent, delivered, bounced, opened …

// Its events in time order
const { data: events } = await tsmart.emails.events(id);
for (const e of events.data) console.log(e.created_at, e.type, e.recipient);

// Recent emails, newest first
const { data: recent } = await tsmart.emails.list({ limit: 50 });

Metrics

emails.metrics counts what happened to the emails sent in a time range. Parameters: start_date and end_date (ISO 8601 dates or datetimes, default the last 7 days, at most 366 days), timezone (an IANA name, default UTC), granularity (hourly, daily, weekly, monthly), metrics, dimensions (period, domain) and domain_id. Arrays are sent comma-separated.

Node.js
const { data } = await tsmart.emails.metrics({
  start_date: "2026-10-01",
  end_date: "2026-10-07",
  timezone: "Europe/Istanbul",
  granularity: "daily",
  metrics: ["sent", "delivered", "bounced", "delivery_rate", "open_rate"],
  dimensions: ["period"],
});

console.log(data.totals); // { sent: 1200, delivered: 1187, bounced: 4, delivery_rate: 0.9892, open_rate: 0.4103 }
for (const row of data.data) console.log(row.period, row.sent, row.delivered);

Batch

batch.send sends 1 to 100 emails in one request, without attachments. With validation: "strict" (the default) one invalid email refuses the whole batch; with "permissive" the valid ones are sent and errors lists the others by index. The option is sent as the x-batch-validation header.

Node.js
const { data, error } = await tsmart.batch.send(
  [
    { from: "hello@acme.com", to: "ana@example.com", subject: "Your weekly digest", html: "<p>…</p>" },
    { from: "hello@acme.com", to: "ben@example.com", subject: "Your weekly digest", html: "<p>…</p>" },
  ],
  { idempotencyKey: "digest-2026-41", validation: "permissive" },
);

console.log(data.data);   // [{ id: "…" }, { id: "…" }]
console.log(data.errors); // [] or [{ index: 1, message: "…" }]

Domains

Node.js
// Add a domain; records lists the DNS records to publish
const { data: domain } = await tsmart.domains.create({ name: "acme.com" });
for (const r of domain.records) console.log(r.type, r.name, r.value);

// After publishing them: prove ownership, then check the sending records
await tsmart.domains.verify(domain.id);
const { data: fresh } = await tsmart.domains.get(domain.id);
console.log(fresh.status); // not_started, pending, partially_verified or verified

await tsmart.domains.update(domain.id, { open_tracking: true, click_tracking: true });
await tsmart.domains.list();
await tsmart.domains.remove(domain.id);

API keys

Node.js
// A key that can only send, and only from one domain
const { data: key } = await tsmart.apiKeys.create({
  name: "Web shop",
  permission: "sending_access",
  domain_id: domain.id,
});
console.log(key.token); // shown only once

await tsmart.apiKeys.list();
await tsmart.apiKeys.update(key.id, { name: "Web shop (production)" });
await tsmart.apiKeys.remove(key.id); // revoked at once

The API has no endpoint that returns a single key, so the client has no apiKeys.get.

Webhooks

Node.js
const { data: hook } = await tsmart.webhooks.create({
  endpoint: "https://example.com/hooks/tsmart",
  events: ["email.delivered", "email.bounced"],
});
console.log(hook.signing_secret); // whsec_…, keep it with your receiver

await tsmart.webhooks.test(hook.id); // queues an example email.sent event
await tsmart.webhooks.update(hook.id, { events: ["email.bounced"], status: "enabled" });
await tsmart.webhooks.rotateSecret(hook.id); // the old secret stops working at once

// What was delivered, and how each attempt went
const { data: events } = await tsmart.webhooks.events.list(hook.id);
const eventId = events.data[0].id; // msg_…
await tsmart.webhooks.events.get(hook.id, eventId);
await tsmart.webhooks.events.attempts(hook.id, eventId);
await tsmart.webhooks.events.replay(hook.id, eventId);

await tsmart.webhooks.remove(hook.id);

Event types: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.failed, email.opened, email.clicked, email.scheduled, email.suppressed, email.complained (a recipient marked the email as spam and their mailbox provider reported it, see Spam complaints), domain.created, domain.updated and domain.deleted. See Webhooks for the payloads and the retry schedule.

Suppressions

Node.js
await tsmart.suppressions.create({ email: "jane@example.com" });
const { data } = await tsmart.suppressions.get("jane@example.com"); // by id or by address
console.log(data.reason); // bounce, complaint or manual

await tsmart.suppressions.list();
await tsmart.suppressions.remove("jane@example.com");

Logs

Node.js
// API requests of the last 30 days, newest first (no bodies are kept)
const { data } = await tsmart.logs.list({ limit: 20 });
for (const l of data.data) console.log(l.created_at, l.method, l.endpoint, l.response_status);

const { data: one } = await tsmart.logs.get(data.data[0].id);
console.log(one.error); // the error name of a failed request, or null

Mailboxes and alternate emails

Mailboxes are named by their address and can only be created in the company's verified domains. Every change follows the console's rules for the person who created the key; see Mailboxes for the privileges and Managing mailboxes, aliases and groups for a set-up script.

Node.js
const { data: box, error } = await tsmart.mailboxes.create({
  email: "jane@acme.com",
  first_name: "Jane", // or name: "Jane Doe"
  last_name: "Doe",
  quota_mb: 2048,     // optional: 256 to 51200, lowered to the plan's mailbox size
  org_unit_id: 12,    // optional: default the root unit
});
console.log(box.temporary_password); // shown only once; must be changed at the first sign-in

await tsmart.mailboxes.list({ domain: "acme.com", limit: 100 }); // ordered by address
await tsmart.mailboxes.get("jane@acme.com");
await tsmart.mailboxes.update("jane@acme.com", { name: "Jane Smith", quota_mb: 4096 });
await tsmart.mailboxes.update("jane@acme.com", { active: false }); // suspend; true restores
const { data: reset } = await tsmart.mailboxes.resetPassword("jane@acme.com"); // reset.temporary_password
await tsmart.mailboxes.remove("jane@acme.com"); // with its mail and alternate emails; company owners only

// Alternate emails: mail to them reaches the mailbox, which may also send as them
await tsmart.mailboxes.aliases.create("jane@acme.com", { address: "j.doe@acme.com" });
await tsmart.mailboxes.aliases.list("jane@acme.com");
await tsmart.mailboxes.aliases.remove("jane@acme.com", "j.doe@acme.com");

The temporary password of create and resetPassword is returned once; keep it out of your logs. No other answer contains a password.

Groups

Node.js
await tsmart.groups.create({
  address: "team@acme.com",
  name: "Team",
  members: ["jane@acme.com", "partner@example.org"], // mailboxes or outside addresses, up to 200
});
await tsmart.groups.get("team@acme.com");
await tsmart.groups.list({ domain: "acme.com" });

// members is the complete new list: read, change, send back
const { data: team } = await tsmart.groups.get("team@acme.com");
await tsmart.groups.update("team@acme.com", { members: [...team.members, "sam@acme.com"] });

await tsmart.groups.remove("team@acme.com"); // company owners only

Pagination

List methods take { limit, after, before }: limit from 1 to 100 (default 20), and after or before an id from the previous page (not both). They return { object: "list", has_more, data }. Mailboxes and groups are listed by address: their cursors are addresses (the email or address of the last item), and they also take domain.

Node.js
let after;
do {
  const { data, error } = await tsmart.emails.list({ limit: 100, after });
  if (error) throw new Error(error.message);
  for (const email of data.data) console.log(email.id, email.last_event);
  after = data.has_more ? data.data.at(-1).id : undefined;
} while (after);

All methods

Method Request data on success
Emails
emails.send(email, options?) POST /emails { id }
emails.get(id) GET /emails/{id} The email with html, text and tags
emails.list(page?) GET /emails List of emails, newest first
emails.update(id, { scheduled_at }) PATCH /emails/{id} { object, id }
emails.cancel(id) POST /emails/{id}/cancel { object, id }
emails.events(id, page?) GET /emails/{id}/events List of events, oldest first
emails.metrics(params?) GET /emails/metrics Totals, and rows per period or domain
Batch
batch.send(emails, options?) POST /emails/batch { data: [{ id }], errors? }
Domains
domains.create({ name }) POST /domains The domain with its DNS records
domains.get(id) GET /domains/{id} The domain with its DNS records
domains.list(page?) GET /domains List of domains
domains.update(id, { open_tracking, click_tracking }) PATCH /domains/{id} { object, id }
domains.remove(id) DELETE /domains/{id} { object, id, deleted }
domains.verify(id) POST /domains/{id}/verify { object, id }
API keys
apiKeys.create({ name, permission, domain_id }) POST /api-keys { id, object, token }
apiKeys.list(page?) GET /api-keys List of keys, without their tokens
apiKeys.update(id, { name }) PATCH /api-keys/{id} { object, id }
apiKeys.remove(id) DELETE /api-keys/{id} { object, id, deleted }
Webhooks
webhooks.create({ endpoint, events }) POST /webhooks { object, id, signing_secret }
webhooks.get(id) GET /webhooks/{id} The webhook with its signing secret
webhooks.list(page?) GET /webhooks List of webhooks
webhooks.update(id, { endpoint, events, status }) PATCH /webhooks/{id} { object, id }
webhooks.remove(id) DELETE /webhooks/{id} { object, id, deleted }
webhooks.rotateSecret(id) POST /webhooks/{id}/signing-secret/rotate { object, id, signing_secret }
webhooks.test(id) POST /webhooks/{id}/test { object, id } of the queued event
webhooks.events.list(webhookId, page?) GET /webhooks/{id}/events List of events, newest first
webhooks.events.get(webhookId, eventId) GET /webhooks/{id}/events/{event_id} The event with its payload
webhooks.events.attempts(webhookId, eventId, page?) GET /webhooks/{id}/events/{event_id}/attempts List of attempts, newest first
webhooks.events.replay(webhookId, eventId) POST /webhooks/{id}/events/{event_id}/replay { object, id }
Suppressions
suppressions.create({ email }) POST /suppressions { object, id }
suppressions.get(idOrEmail) GET /suppressions/{id or email} The suppression
suppressions.list(page?) GET /suppressions List of suppressions
suppressions.remove(idOrEmail) DELETE /suppressions/{id or email} { object, id, deleted }
Logs
logs.list(page?) GET /logs List of API requests of the last 30 days
logs.get(id) GET /logs/{id} The request with its error name
Mailboxes
mailboxes.list({ limit, after, before, domain }?) GET /mailboxes List of mailboxes by address
mailboxes.get(email) GET /mailboxes/{email} The mailbox
mailboxes.create({ email, name, quota_mb, org_unit_id }) POST /mailboxes The mailbox with temporary_password
mailboxes.update(email, { name, quota_mb, active, force_password_change }) PATCH /mailboxes/{email} The mailbox
mailboxes.remove(email) DELETE /mailboxes/{email} { object, email, deleted }
mailboxes.resetPassword(email) POST /mailboxes/{email}/password { object, email, force_password_change, temporary_password }
mailboxes.aliases.list(email) GET /mailboxes/{email}/aliases List of alternate emails
mailboxes.aliases.create(email, { address }) POST /mailboxes/{email}/aliases The alias
mailboxes.aliases.remove(email, address) DELETE /mailboxes/{email}/aliases/{address} { object, address, mailbox, deleted }
Groups
groups.list({ limit, after, before, domain }?) GET /groups List of groups by address
groups.get(address) GET /groups/{address} The group with its members
groups.create({ address, name, members }) POST /groups The group
groups.update(address, { name, members }) PATCH /groups/{address} The group
groups.remove(address) DELETE /groups/{address} { object, address, deleted }

Errors

Check error.name; the message is meant for people and may change.

Node.js
const { data, error } = await tsmart.emails.send(email);
if (error) {
  switch (error.name) {
    case "validation_error":
    case "missing_required_field":
      throw new Error("Bad request: " + error.message);
    case "rate_limit_exceeded":
    case "network_error":
      // try again later, with the same idempotency key
      break;
    default:
      console.error(error.statusCode, error.name, error.message);
  }
}
Status Name Meaning
400 invalid_idempotency_key The idempotency key is not 1 to 256 characters.
401 missing_api_key No API key was sent.
401 restricted_api_key A sending-access key called an endpoint that needs full access.
403 invalid_api_key The key is malformed, unknown or revoked, or the company is suspended.
403 invalid_permission The person who created the key may not do this in the console.
403 invalid_from_address The from domain is not one of the company's verified domains (an added domain whose ownership is not yet proven counts as unverified), or not the key's domain.
403 domain_not_verified The SPF and DKIM records of the from domain are not verified.
403 service_disabled tsmartSend is turned off for the company.
403 ip_not_allowed The company only allows API calls from certain addresses.
403 account_inactive The company's subscription is not active.
404 not_found The object or the endpoint does not exist.
409 invalid_idempotent_request The idempotency key was used in the last 24 hours with a different body.
409 concurrent_idempotent_requests A request with the same idempotency key is still running.
409 resource_in_use The domain still has mailboxes.
409 already_exists The address is already used by a mailbox, a group or an alternate email.
413 payload_too_large The body is over the limit: 1 MB outside sending; a sending request over 30 MB gets a 413 without a JSON body (application_error).
422 validation_error The request is not valid; the message says why. Also used with 403 and 429 for some domain and webhook limits, and with 403 for the plan's user limit.
422 missing_required_field A required field is missing.
422 invalid_attachment An attachment is not valid.
429 rate_limit_exceeded Too many requests per second, an hourly sending limit, or another limit; the message says which.
429 monthly_quota_exceeded The plan's monthly sending quota is used up.
502 mail_server_error The mail server refused a mailbox, alternate email or group change; the message carries its error codes.
500 application_error An unexpected error. Also used when an error response is not JSON (the message is the HTTP status text) and for a redirect (statusCode is its 3xx status).
none network_error No response: the request timed out or the API could not be reached. statusCode is null.

More on limits and quotas in Limits and errors.

Retries and timeouts

  • An HTTP 429 means the request was not carried out, so the client retries it up to maxRetries times (default 2). It waits for the retry-after header, else ratelimit-reset, else 1 second, and never longer than 60 seconds. monthly_quota_exceeded and validation_error (too many domains waiting for verification) are not retried.
  • A timeout or network error is retried for GET requests only. A POST, PATCH or DELETE is never repeated, because it may have arrived; the result is a network_error.
  • Other errors, including 5xx responses, are returned at once.
  • Redirects are never followed, so the API key is only sent to baseUrl. The API does not redirect; a 3xx answer (from a proxy, for example) is returned as application_error with its status code.
  • Each attempt has its own timeout (default 30 seconds).

Verify webhooks

Each webhook request carries the headers webhook-id, webhook-timestamp and webhook-signature. verifyWebhook checks the signature with the webhook's signing secret, refuses timestamps more than 5 minutes from now, and returns the parsed event. It throws a WebhookVerificationError for a request that does not verify.

Node.js
import { createServer } from "node:http";
import { verifyWebhook, WebhookVerificationError } from "@tsmart/sdk";

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  try {
    const event = verifyWebhook({
      payload: Buffer.concat(chunks), // the raw body
      headers: req.headers,
      secret: process.env.TSMART_WEBHOOK_SECRET,
    });
    if (event.type === "email.bounced") {
      console.log("Bounced:", event.data.to, event.data.bounce.message);
    }
    res.writeHead(204).end();
  } catch (err) {
    if (!(err instanceof WebhookVerificationError)) throw err;
    res.writeHead(400).end();
  }
}).listen(3000);

With a server built on the web Request API, pass the body text and the headers object:

Node.js
export async function POST(request) {
  let event;
  try {
    event = verifyWebhook({
      payload: await request.text(),
      headers: request.headers,
      secret: process.env.TSMART_WEBHOOK_SECRET,
    });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
    throw err;
  }
  // handle event.type …
  return new Response(null, { status: 204 });
}
verifyWebhook(options)
Name Type Description
payload required string | bytes The raw request body as a string, Buffer, Uint8Array or ArrayBuffer.
headers required object The request headers: a Headers object or a plain object such as req.headers (names in any case).
secret required string The webhook's signing secret, whsec_…. A missing secret throws a TypeError.
tolerance number Accepted distance of webhook-timestamp from now, in seconds. Default 300.

Answer with a 2xx status quickly and do slow work afterwards. Failed deliveries are retried, and webhook-id stays the same on every retry and replay, so you can use it to skip duplicates.

TypeScript

The package includes types for every request and response. The result is a union, so checking error narrows data:

TypeScript
import { Tsmart, type Email, type TsmartResponse, type WebhookPayload } from "@tsmart/sdk";

const tsmart = new Tsmart();

const result: TsmartResponse<Email> = await tsmart.emails.get(id);
if (result.error) throw new Error(result.error.message);
console.log(result.data.subject); // result.data is an Email here

function handle(event: WebhookPayload) {
  switch (event.type) {
    case "email.bounced":
      console.log(event.data.email_id, event.data.bounce?.message);
      break;
    case "domain.updated":
      console.log(event.data.name, event.data.status);
      break;
  }
}

Next steps