Guides
Sending email
POST /emails sends one email, now or at a scheduled time. POST /emails/batch sends up to
100 in one request. Both accept any API key, including sending-access keys.
Send an email
curl -X POST https://send.tsmart.ai/emails \
-H "Authorization: Bearer $TSMART_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042/confirmation" \
-d '{
"from": "Acme <orders@acme.com>",
"to": ["jane@example.com"],
"reply_to": "support@acme.com",
"subject": "Order 1042 confirmed",
"html": "<h1>Thank you!</h1><p>Your order 1042 is confirmed.</p>",
"tags": [{ "name": "category", "value": "order_confirmation" }]
}' { "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" } const { data, error } = await tsmart.emails.send(
{
from: "Acme <orders@acme.com>",
to: ["jane@example.com"],
replyTo: "support@acme.com",
subject: "Order 1042 confirmed",
html: "<h1>Thank you!</h1><p>Your order 1042 is confirmed.</p>",
tags: [{ name: "category", value: "order_confirmation" }],
},
{ idempotencyKey: "order-1042/confirmation" },
); email = client.emails.send(
{
"from": "Acme <orders@acme.com>",
"to": ["jane@example.com"],
"reply_to": "support@acme.com",
"subject": "Order 1042 confirmed",
"html": "<h1>Thank you!</h1><p>Your order 1042 is confirmed.</p>",
"tags": [{"name": "category", "value": "order_confirmation"}],
},
idempotency_key="order-1042/confirmation",
) tsmart emails send \
--from "Acme <orders@acme.com>" \
--to jane@example.com \
--reply-to support@acme.com \
--subject "Order 1042 confirmed" \
--html-file confirmation.html \
--tag category=order_confirmation \
--idempotency-key order-1042/confirmation
A 200 response means the email was accepted and handed to our mail server (or stored for its scheduled
time), unless every recipient is on the suppression list: then nothing is sent and the email's
last_event is suppressed (see Bounces and suppressions).
Whether it reached the recipient is reported afterwards as delivery events.
Fields
| Name | Type | Description |
|---|---|---|
from required | string | Sender, "Name <address>" or a bare address. Exactly one address on one of your verified domains; the display name may have up to 100 characters. |
to required | string | string[] | One address or an array of up to 50. |
cc | string | string[] | Up to 50 addresses. |
bcc | string | string[] | Up to 50 addresses. |
reply_to | string | string[] | Where replies go. Up to 50 addresses. Also accepted as replyTo. |
subject required | string | One line, up to 998 characters. |
html | string | HTML body, up to 2,000,000 characters. Either html or text is required. |
text | string | Plain-text body, up to 2,000,000 characters. Left out: generated from html. Empty string: no plain-text part. |
attachments | object[] | Up to 20 files, 20 MB in total. Not available in batches. |
headers | object | Up to 30 extra headers from an allow-list. |
tags | object[] | Up to 20 name/value pairs returned with the email and its events. |
scheduled_at | string | Send later: ISO 8601 with a time zone, or a phrase like "in 10 min". Up to 30 days ahead. Also accepted as scheduledAt. |
Templates (template) and topics (topic_id) are not available and are refused with
422 validation_error. Text fields must not contain NUL characters.
Recipients
-
to,ccandbcctogether may have at most 50 addresses. Every address counts toward your hourly and monthly limits. - Put each address in its own array element. A single string must hold exactly one address: commas and semicolons are refused.
-
Recipients may be written as
"Jane Doe <jane@example.com>", but only the address is kept: the email is addressed tojane@example.com. Addresses are stored in lower case. - Recipients on your suppression list are skipped and get an
email.suppressedevent.
HTML and plain text
Send html, text or both. If you send only html, a plain-text version is
generated from it, which helps deliverability. Send "text": "" if you want no plain-text part at all.
Attachments
| Name | Type | Description |
|---|---|---|
content required | string | The file, base64-encoded. |
filename | string | File name shown to the recipient, up to 255 characters, without slashes or line breaks. |
content_type | string | Media type such as application/pdf. Also accepted as contentType. |
content_id | string | Makes the file an inline image referenced as cid:<content_id>. Letters, digits and . _ @ - only, up to 100 characters. Also accepted as contentId. |
- Up to 20 attachments per email, 20 MB in total after decoding. The whole request may be up to 30 MB.
-
Files must be sent as base64
content. Remote URLs and file paths (path,href) are refused with422 invalid_attachment. - Batches cannot carry attachments; send such emails one by one.
Inline images
Give the attachment a content_id and reference it from the HTML with cid:. The image is
shown in the body instead of as a separate attachment.
import { readFile } from "node:fs/promises";
const pdf = await readFile("invoice-1042.pdf");
const logo = await readFile("logo.png");
const { data, error } = await tsmart.emails.send({
from: "Acme Billing <billing@acme.com>",
to: "jane@example.com",
subject: "Invoice 1042",
html: '<p><img src="cid:logo" alt="Acme" width="120"></p><p>Your invoice is attached.</p>',
attachments: [
{ filename: "invoice-1042.pdf", content: pdf.toString("base64"), content_type: "application/pdf" },
{ filename: "logo.png", content: logo.toString("base64"), content_type: "image/png", content_id: "logo" },
],
}); import base64
def b64(path):
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("ascii")
email = client.emails.send({
"from": "Acme Billing <billing@acme.com>",
"to": "jane@example.com",
"subject": "Invoice 1042",
"html": '<p><img src="cid:logo" alt="Acme" width="120"></p><p>Your invoice is attached.</p>',
"attachments": [
{"filename": "invoice-1042.pdf", "content": b64("invoice-1042.pdf"), "content_type": "application/pdf"},
{"filename": "logo.png", "content": b64("logo.png"), "content_type": "image/png", "content_id": "logo"},
],
}) tsmart emails send --from "Acme Billing <billing@acme.com>" --to jane@example.com \
--subject "Invoice 1042" --text "Your invoice is attached." --attach invoice-1042.pdf Custom headers
headers is an object of header names and string values (up to 998 characters each, no line breaks).
Only headers that cannot change routing, authentication or the message structure are allowed:
X-…headers (letters, digits and dashes, up to 60 characters afterX-)List-UnsubscribeandList-Unsubscribe-PostIn-Reply-ToandReferences, to thread a reply into an existing conversation
"headers": {
"List-Unsubscribe": "<https://acme.com/unsubscribe?u=8812>, <mailto:unsubscribe@acme.com?subject=8812>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"X-Entity-Ref-ID": "order-1042"
}
Any other header is refused with 422 validation_error. Every email gets the Message-ID
<email id@your domain>, for example
<4ef9a417-02e9-4d39-ad75-9611e0fcc33c@acme.com>, so replies that quote it can be matched to the
email.
Tags
Tags label an email for your own bookkeeping, for example by category or customer. Each tag has a
name and a value of ASCII letters, digits, underscores and dashes, up to 256 characters
each; up to 20 tags per email.
"tags": [
{ "name": "category", "value": "password_reset" },
{ "name": "tenant", "value": "customer_8812" }
] GET /emails/{id} returns the tags as this array. In webhook events they arrive as an object,
"tags": { "category": "password_reset", "tenant": "customer_8812" }, so you can route events without
looking the email up.
Scheduling
Set scheduled_at to send later, at most 30 days ahead. The response is the same id; the
email gets an email.scheduled event and waits.
| Format | Examples |
|---|---|
ISO 8601 date and time with a time zone (Z or an offset); seconds and fractions are optional | 2026-10-14T09:00:00+03:00, 2026-10-14T06:00Z |
in, a number and a unit: s, sec, second(s),
m, min, minute(s), h, hr,
hour(s), d, day(s) | in 10 min, in 2 hours, in 1 day, in 90s |
-
A time without a time zone is refused. A time more than a minute in the past or more than 30 days ahead is refused
with
422 validation_error; a time within the next few seconds sends at once. - Scheduled emails are usually picked up within about 15 seconds of their time.
- Hourly limits are checked when a scheduled email goes out. If the limit is reached then, the email waits five more minutes and is tried again. A company can have at most 20,000 recipients waiting in scheduled emails.
-
Attachments of scheduled emails are kept until the email is sent. Revoking the API key that scheduled an email
cancels it; if the domain is no longer ready when it comes due, it fails with
email.failed.
Reschedule or cancel
While an email is still scheduled, a full-access key can move it with PATCH /emails/{id} or
cancel it with POST /emails/{id}/cancel. Both answer 422 validation_error once
the email has been sent or canceled.
# Move it
curl -X PATCH https://send.tsmart.ai/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
-H "Authorization: Bearer $TSMART_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scheduled_at": "in 1 hour"}'
# Cancel it
curl -X POST https://send.tsmart.ai/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/cancel \
-H "Authorization: Bearer $TSMART_API_KEY" await tsmart.emails.update("4ef9a417-02e9-4d39-ad75-9611e0fcc33c", { scheduled_at: "in 1 hour" });
await tsmart.emails.cancel("4ef9a417-02e9-4d39-ad75-9611e0fcc33c"); tsmart emails reschedule 4ef9a417-02e9-4d39-ad75-9611e0fcc33c --scheduled-at "in 1 hour"
tsmart emails cancel 4ef9a417-02e9-4d39-ad75-9611e0fcc33c Batch sending
POST /emails/batch takes a JSON array of 1 to 100 emails with the same fields as POST /emails,
except attachments. Each email can have its own recipients, content, tags and scheduled_at.
curl -X POST https://send.tsmart.ai/emails/batch \
-H "Authorization: Bearer $TSMART_API_KEY" \
-H "Content-Type: application/json" \
-H "x-batch-validation: permissive" \
-d '[
{ "from": "Acme <hello@acme.com>", "to": "jane@example.com", "subject": "Hi Jane", "text": "Welcome aboard." },
{ "from": "Acme <hello@acme.com>", "to": "not-an-address", "subject": "Hi", "text": "Welcome aboard." }
]' {
"data": [{ "id": "0d4f3c1e-8b0a-4a2e-9a55-2d7a3b6f1c90" }],
"errors": [{ "index": 1, "message": "Recipients must be valid email addresses (at most 50)" }]
} x-batch-validation | Behaviour |
|---|---|
strict (default) |
If any email is invalid, nothing is sent. The answer is that email's error, with the message prefixed by its
position, for example Email at index 1: …. On success the response is
{ "data": [{ "id": … }, …] } in the order of the request.
|
permissive |
Valid emails are sent; invalid ones are listed in errors with their index and
message. If none is valid, the response is { "data": [], "errors": [ … ] }.
|
-
Limits are checked for all valid emails of the batch together. If they would exceed an hourly or monthly limit, the
whole request is refused with
429and nothing is sent. -
If one email cannot be handed to the mail server, its id is still returned and the email gets an
email.failedevent.
const { data, error } = await tsmart.batch.send(
[
{ from: "Acme <hello@acme.com>", to: "jane@example.com", subject: "Hi Jane", text: "Welcome aboard." },
{ from: "Acme <hello@acme.com>", to: "omar@example.com", subject: "Hi Omar", text: "Welcome aboard." },
],
{ validation: "permissive", idempotencyKey: "welcome-batch/2026-10-12" },
); result = client.batch.send(
[
{"from": "Acme <hello@acme.com>", "to": "jane@example.com", "subject": "Hi Jane", "text": "Welcome aboard."},
{"from": "Acme <hello@acme.com>", "to": "omar@example.com", "subject": "Hi Omar", "text": "Welcome aboard."},
],
validation="permissive",
idempotency_key="welcome-batch/2026-10-12",
) tsmart batch send --file welcome-emails.json Idempotency
Networks fail. To retry a send without risking a duplicate email, send an Idempotency-Key header with a
value that identifies the email in your system, such as order-1042/confirmation. It works on
POST /emails and POST /emails/batch.
- The key is 1 to 256 characters; anything else is refused with
400 invalid_idempotency_key. - Keys are kept for 24 hours, separately per company and per endpoint.
-
A retry with the same key and the same body gets the original response again, with the header
idempotent-replayed: true. Nothing is sent a second time. - The same key with a different body is refused with
409 invalid_idempotent_request. -
A retry while the first request is still running is refused with
409 concurrent_idempotent_requests; wait a moment and retry. - If the first request failed (any non-2xx answer), the key is released, so a retry with it is processed normally.
Open and click tracking
Tracking is off by default and switched on per domain, in the console (tsmartSend → Domains) or with a full-access key:
curl -X PATCH https://send.tsmart.ai/domains/b7c1e0a2-5d0f-4a51-9a64-0f3c2a7d9e11 \
-H "Authorization: Bearer $TSMART_API_KEY" \
-H "Content-Type: application/json" \
-d '{"open_tracking": true, "click_tracking": true}' - Opens: a 1×1 image from
send.tsmart.aiis added to the end of the HTML body. Each load recordsemail.opened, at most once a minute per email. Mail apps that block images report no opens, and some load images automatically, so treat open counts as an estimate. - Clicks: every
httpandhttpslink in an<a href>of the HTML body is routed throughsend.tsmart.ai, which recordsemail.clicked(at most once a minute per email and link) and leads to the original address. The link is signed, so it can only lead to the link that was in your email, and works for 180 days. Links to your company's verified domains and their subdomains redirect at once. Links to any other website first show a short page that names the destination, so the shared tracking address can never be used to forward people unseen to a site of the sender's choosing. - The plain-text part is never changed. Settings apply to emails sent after the change.
- Open and click events belong to the email; they do not tell which of several recipients opened or clicked.
Switch tracking on only if your recipients have been told about it, for example in your privacy notice.
Delivery events
Each email collects events as it moves on. The email's last_event is the latest of them, and every event
can be posted to your webhooks.
| Event | When |
|---|---|
email.scheduled | The email was accepted with scheduled_at and is waiting. |
email.sent | Our mail server accepted the email and is delivering it. |
email.delivered | The recipient's mail server accepted it. One event per recipient. |
email.delivery_delayed | A temporary problem at the receiving side; delivery is retried. Per recipient. |
email.bounced | The receiving side rejected the email permanently, or delivery gave up after retrying. Per recipient. |
email.failed | The email could not be handed to the mail server, or a scheduled email could not go out (the reason is included). |
email.suppressed | A recipient was skipped because it is on the suppression list. Per recipient. |
email.opened | The tracking image was loaded (open tracking on). |
email.clicked | A tracked link was followed (click tracking on). |
email.complained | A recipient marked the email as spam and their mailbox provider reported it. Per recipient when the recipient is known (see When the provider hides the recipient). |
email.canceled | A scheduled email was canceled. Shown in the event history only; not sent to webhooks. |
A typical email goes sent → delivered, possibly with delivery_delayed in between, then
opened and clicked if tracking is on. Delivery results are read from the mail server about
every 20 seconds. If every
recipient is suppressed, nothing is sent and last_event is suppressed.
email.complained follows when a recipient marks the email as spam and their mailbox provider sends a
complaint report, usually within an hour or two, and the recipient is added to the suppression list when it is known.
Not every provider sends these reports; see Spam complaints.
The full history of one email is available with a full-access key:
curl https://send.tsmart.ai/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/events \
-H "Authorization: Bearer $TSMART_API_KEY" {
"object": "list",
"has_more": false,
"data": [
{
"object": "email_event",
"id": "18342",
"type": "email.sent",
"created_at": "2026-10-12 09:41:05.402113+00",
"recipient": null
},
{
"object": "email_event",
"id": "18347",
"type": "email.delivered",
"created_at": "2026-10-12 09:41:07.999000+00",
"recipient": "jane@example.com",
"dsn": "2.0.0",
"message": "250 2.0.0 Ok: queued"
}
]
}
Events come in time order and are paged like every list (limit, after,
before with an event id). Depending on the type they carry dsn and message
(the receiving server's status code and reply), reason, link, ip_address and
user_agent.
What is kept
The HTML and text of an email are kept for 3 days after it is sent (for a scheduled email, counted from its send
time); after that GET /emails/{id} returns them as null, while the email's
addresses, subject, tags and events stay available. Attachments are not kept after sending. API request logs
(GET /logs) cover the last 30 days and contain no bodies.