Documentation menu

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
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" }]
  }'
Response
{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }
Node.js
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" },
);
Python
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",
)
CLI
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, cc and bcc together 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 to jane@example.com. Addresses are stored in lower case.
  • Recipients on your suppression list are skipped and get an email.suppressed event.

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 with 422 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.

Node.js
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" },
  ],
});
Python
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"},
    ],
})
CLI
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 after X-)
  • List-Unsubscribe and List-Unsubscribe-Post
  • In-Reply-To and References, to thread a reply into an existing conversation
JSON
"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.

JSON
"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.

FormatExamples
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.

cURL
# 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"
Node.js
await tsmart.emails.update("4ef9a417-02e9-4d39-ad75-9611e0fcc33c", { scheduled_at: "in 1 hour" });
await tsmart.emails.cancel("4ef9a417-02e9-4d39-ad75-9611e0fcc33c");
CLI
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
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." }
  ]'
Response (permissive)
{
  "data": [{ "id": "0d4f3c1e-8b0a-4a2e-9a55-2d7a3b6f1c90" }],
  "errors": [{ "index": 1, "message": "Recipients must be valid email addresses (at most 50)" }]
}
x-batch-validationBehaviour
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 429 and nothing is sent.
  • If one email cannot be handed to the mail server, its id is still returned and the email gets an email.failed event.
Node.js
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" },
);
Python
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",
)
CLI
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
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.ai is added to the end of the HTML body. Each load records email.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 http and https link in an <a href> of the HTML body is routed through send.tsmart.ai, which records email.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.

EventWhen
email.scheduledThe email was accepted with scheduled_at and is waiting.
email.sentOur mail server accepted the email and is delivering it.
email.deliveredThe recipient's mail server accepted it. One event per recipient.
email.delivery_delayedA temporary problem at the receiving side; delivery is retried. Per recipient.
email.bouncedThe receiving side rejected the email permanently, or delivery gave up after retrying. Per recipient.
email.failedThe email could not be handed to the mail server, or a scheduled email could not go out (the reason is included).
email.suppressedA recipient was skipped because it is on the suppression list. Per recipient.
email.openedThe tracking image was loaded (open tracking on).
email.clickedA tracked link was followed (click tracking on).
email.complainedA 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.canceledA 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
curl https://send.tsmart.ai/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c/events \
  -H "Authorization: Bearer $TSMART_API_KEY"
Response
{
  "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.