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:
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.
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,
}); | 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
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,
datais the API's JSON response anderrorisnull. -
On failure,
dataisnullanderroris the API's error object{ statusCode, name, message }. -
headersholds the response headers with lower-case names, such asratelimit-remaining, ornullwhen 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
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,bccandreply_totake one address or an array. At most 50 recipients acrossto,ccandbcc. -
htmlortextis required. Withouttext, a plain-text part is generated from the HTML; an emptytextsends none. -
Attachment
contentis base64. ABuffer,Uint8ArrayorArrayBufferis encoded for you. Up to 20 attachments and 20 MB in total; addcontent_idto show an image inline withcid:in the HTML. -
The camelCase spellings
replyTo,scheduledAt, andcontentTypeandcontentIdon attachments, are converted to the API names. -
idempotencyKeyis sent as theIdempotency-Keyheader: 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.
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
// 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.
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.
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
// 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
// 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
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
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
// 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.
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
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.
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.
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
maxRetriestimes (default 2). It waits for theretry-afterheader, elseratelimit-reset, else 1 second, and never longer than 60 seconds.monthly_quota_exceededandvalidation_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 asapplication_errorwith 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.
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:
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 });
} | 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:
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;
}
}