Documentation menu

Libraries and tools

Python SDK

tsmart is the Python client for tsmartSend. It covers every endpoint of the API, retries rate-limited requests, verifies webhook signatures and ships with type hints. It has no dependencies (it uses the standard library's urllib) and runs on Python 3.8 or later.

Install

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

Shell
pip install https://tsmart.ai/sdk/python/tsmart-1.1.1-py3-none-any.whl

In a requirements.txt, use a direct reference:

requirements.txt
tsmart @ https://tsmart.ai/sdk/python/tsmart-1.1.1-py3-none-any.whl

The source archive is tsmart-1.1.1.tar.gz. SHA-256 checksums of the published files are in /sdk/python/SHA256SUMS; add --hash=sha256:… to the requirement line to have pip check the download (pip then expects a hash for every requirement in the file).

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.

Python
from tsmart import Tsmart

# Reads TSMART_API_KEY (and TSMART_BASE_URL, if set)
client = Tsmart()

# Or pass the key and options yourself
custom = Tsmart("ts_xxxxxxxx_…", timeout=10, max_retries=3)
Tsmart(api_key=None, base_url=None, timeout=30, max_retries=2)
Name Type Description
api_key str The API key ts_…. Default: the TSMART_API_KEY environment variable. Without a key the constructor raises ValueError.
base_url str 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 raises ValueError, so the API key never travels unencrypted. A trailing slash is removed.
timeout float Seconds to wait for the connection and for each read of the response. Default 30.
max_retries int Retries of rate-limited requests and of GET requests that failed on the network. Default 2.

Send an email

Python
from tsmart import Tsmart, TsmartError

client = Tsmart()

try:
    sent = client.emails.send({
        "from": "Acme <hello@acme.com>",
        "to": ["jane@example.com"],
        "subject": "Welcome to Acme",
        "html": "<p>Thanks for signing up.</p>",
    })
    print("Email id:", sent["id"])
except TsmartError as e:
    print(e.status_code, e.name, e.message)

The from address must be on a domain your company has verified (see Domains and DNS). Request bodies are plain dicts with the API's field names, so everything in Sending email and the Emails API reference applies as written.

Responses and errors

Every method returns the API's JSON response as a dict, with the API's snake_case keys. When the API answers with an error, the method raises TsmartError:

TsmartError
Name Type Description
status_code int | None The HTTP status, or None when no response arrived (network_error).
name str The error name, such as validation_error or rate_limit_exceeded. Check this one.
message str A human-readable explanation. It may change; do not parse it.
headers dict The response headers with lower-case names, such as ratelimit-remaining. Empty when no response arrived.

Programming errors raise ValueError or TypeError before anything is sent: a missing API key, an empty id, or a body that is not a dict.

Emails

Python
from pathlib import Path

sent = client.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": Path("INV-1042.pdf").read_bytes(),
                "content_type": "application/pdf",
            },
        ],
    },
    idempotency_key="invoice-INV-1042",
)
  • to, cc, bcc and reply_to take one address or a list. 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 text; bytes are 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.
  • headers may contain X-… headers, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To and References.
  • The camelCase spellings replyTo, scheduledAt, and contentType and contentId on attachments, are converted to the API names. Your dict is not changed.
  • idempotency_key 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 timezone-aware datetime, or a phrase such as in 10 min, in 2 hours or in 1 day, at most 30 days ahead. A naive datetime raises ValueError. A scheduled email can be moved or canceled until it is sent.

Python
from datetime import datetime, timedelta, timezone

sent = client.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",
})

istanbul = timezone(timedelta(hours=3))
client.emails.update(sent["id"], {"scheduled_at": datetime(2026, 10, 20, 9, 0, tzinfo=istanbul)})
client.emails.cancel(sent["id"])

Reading emails and events

Python
# One email, with its html and text while they are kept (3 days) and its tags
email = client.emails.get(email_id)
print(email["last_event"])  # sent, delivered, bounced, opened …

# Its events in time order
for e in client.emails.events(email_id)["data"]:
    print(e["created_at"], e["type"], e["recipient"])

# Recent emails, newest first
recent = client.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, or date and datetime objects; 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. Lists are sent comma-separated.

Python
metrics = client.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"],
})

print(metrics["totals"])  # {'sent': 1200, 'delivered': 1187, 'bounced': 4, 'delivery_rate': 0.9892, 'open_rate': 0.4103}
for row in metrics["data"]:
    print(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.

Python
result = client.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>"},
    ],
    idempotency_key="digest-2026-41",
    validation="permissive",
)

print(result["data"])    # [{'id': '…'}, {'id': '…'}]
print(result["errors"])  # [] or [{'index': 1, 'message': '…'}]

Domains

Python
# Add a domain; records lists the DNS records to publish
domain = client.domains.create({"name": "acme.com"})
for r in domain["records"]:
    print(r["type"], r["name"], r["value"])

# After publishing them: prove ownership, then check the sending records
client.domains.verify(domain["id"])
print(client.domains.get(domain["id"])["status"])  # not_started, pending, partially_verified or verified

client.domains.update(domain["id"], {"open_tracking": True, "click_tracking": True})
client.domains.list()
client.domains.remove(domain["id"])

API keys

Python
# A key that can only send, and only from one domain
key = client.api_keys.create({
    "name": "Web shop",
    "permission": "sending_access",
    "domain_id": domain["id"],
})
print(key["token"])  # shown only once

client.api_keys.list()
client.api_keys.update(key["id"], {"name": "Web shop (production)"})
client.api_keys.remove(key["id"])  # revoked at once

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

Webhooks

Python
hook = client.webhooks.create({
    "endpoint": "https://example.com/hooks/tsmart",
    "events": ["email.delivered", "email.bounced"],
})
print(hook["signing_secret"])  # whsec_…, keep it with your receiver

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

# What was delivered, and how each attempt went
events = client.webhooks.events.list(hook["id"])
event_id = events["data"][0]["id"]  # msg_…
client.webhooks.events.get(hook["id"], event_id)
client.webhooks.events.attempts(hook["id"], event_id)
client.webhooks.events.replay(hook["id"], event_id)

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

Python
client.suppressions.create({"email": "jane@example.com"})
suppression = client.suppressions.get("jane@example.com")  # by id or by address
print(suppression["reason"])  # bounce, complaint or manual

client.suppressions.list()
client.suppressions.remove("jane@example.com")

Logs

Python
# API requests of the last 30 days, newest first (no bodies are kept)
page = client.logs.list(limit=20)
for entry in page["data"]:
    print(entry["created_at"], entry["method"], entry["endpoint"], entry["response_status"])

one = client.logs.get(page["data"][0]["id"])
print(one["error"])  # the error name of a failed request, or None

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.

Python
box = client.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
})
print(box["temporary_password"])    # shown only once; must be changed at the first sign-in

client.mailboxes.list(domain="acme.com", limit=100)          # ordered by address
client.mailboxes.get("jane@acme.com")
client.mailboxes.update("jane@acme.com", {"name": "Jane Smith", "quota_mb": 4096})
client.mailboxes.update("jane@acme.com", {"active": False})    # suspend; True restores
reset = client.mailboxes.reset_password("jane@acme.com")       # reset["temporary_password"]
client.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
client.mailboxes.aliases.create("jane@acme.com", {"address": "j.doe@acme.com"})
client.mailboxes.aliases.list("jane@acme.com")
client.mailboxes.aliases.remove("jane@acme.com", "j.doe@acme.com")

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

Groups

Python
client.groups.create({
    "address": "team@acme.com",
    "name": "Team",
    "members": ["jane@acme.com", "partner@example.org"],   # mailboxes or outside addresses, up to 200
})
client.groups.get("team@acme.com")
client.groups.list(domain="acme.com")

# members is the complete new list: read, change, send back
team = client.groups.get("team@acme.com")
client.groups.update("team@acme.com", {"members": team["members"] + ["sam@acme.com"]})

client.groups.remove("team@acme.com")       # company owners only

Pagination

List methods take limit (1 to 100, default 20) and after or before, an id from the previous page (not both). They return a dict with object ("list"), has_more and 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.

Python
after = None
while True:
    page = client.emails.list(limit=100, after=after)
    for email in page["data"]:
        print(email["id"], email["last_event"])
    if not page["has_more"]:
        break
    after = page["data"][-1]["id"]

All methods

Method Request Returns
Emails
emails.send(payload, idempotency_key=None) POST /emails {'id'}
emails.get(email_id) GET /emails/{id} The email with html, text and tags
emails.list(limit=None, after=None, before=None) GET /emails List of emails, newest first
emails.update(email_id, {'scheduled_at': …}) PATCH /emails/{id} {'object', 'id'}
emails.cancel(email_id) POST /emails/{id}/cancel {'object', 'id'}
emails.events(email_id, limit=None, after=None, before=None) GET /emails/{id}/events List of events, oldest first
emails.metrics(params=None) GET /emails/metrics Totals, and rows per period or domain
Batch
batch.send(emails, idempotency_key=None, validation='strict') POST /emails/batch {'data': [{'id'}], 'errors'?}
Domains
domains.create({'name': …}) POST /domains The domain with its DNS records
domains.get(domain_id) GET /domains/{id} The domain with its DNS records
domains.list(limit=None, after=None, before=None) GET /domains List of domains
domains.update(domain_id, {'open_tracking': …, 'click_tracking': …}) PATCH /domains/{id} {'object', 'id'}
domains.remove(domain_id) DELETE /domains/{id} {'object', 'id', 'deleted'}
domains.verify(domain_id) POST /domains/{id}/verify {'object', 'id'}
API keys
api_keys.create({'name': …, 'permission': …, 'domain_id': …}) POST /api-keys {'id', 'object', 'token'}
api_keys.list(limit=None, after=None, before=None) GET /api-keys List of keys, without their tokens
api_keys.update(api_key_id, {'name': …}) PATCH /api-keys/{id} {'object', 'id'}
api_keys.remove(api_key_id) DELETE /api-keys/{id} {'object', 'id', 'deleted'}
Webhooks
webhooks.create({'endpoint': …, 'events': […]}) POST /webhooks {'object', 'id', 'signing_secret'}
webhooks.get(webhook_id) GET /webhooks/{id} The webhook with its signing secret
webhooks.list(limit=None, after=None, before=None) GET /webhooks List of webhooks
webhooks.update(webhook_id, {'endpoint', 'events', 'status'}) PATCH /webhooks/{id} {'object', 'id'}
webhooks.remove(webhook_id) DELETE /webhooks/{id} {'object', 'id', 'deleted'}
webhooks.rotate_secret(webhook_id) POST /webhooks/{id}/signing-secret/rotate {'object', 'id', 'signing_secret'}
webhooks.test(webhook_id) POST /webhooks/{id}/test {'object', 'id'} of the queued event
webhooks.events.list(webhook_id, limit=None, after=None, before=None) GET /webhooks/{id}/events List of events, newest first
webhooks.events.get(webhook_id, event_id) GET /webhooks/{id}/events/{event_id} The event with its payload
webhooks.events.attempts(webhook_id, event_id, limit=None, after=None, before=None) GET /webhooks/{id}/events/{event_id}/attempts List of attempts, newest first
webhooks.events.replay(webhook_id, event_id) POST /webhooks/{id}/events/{event_id}/replay {'object', 'id'}
Suppressions
suppressions.create({'email': …}) POST /suppressions {'object', 'id'}
suppressions.get(id_or_email) GET /suppressions/{id or email} The suppression
suppressions.list(limit=None, after=None, before=None) GET /suppressions List of suppressions
suppressions.remove(id_or_email) DELETE /suppressions/{id or email} {'object', 'id', 'deleted'}
Logs
logs.list(limit=None, after=None, before=None) GET /logs List of API requests of the last 30 days
logs.get(log_id) GET /logs/{id} The request with its error name
Mailboxes
mailboxes.list(limit=None, after=None, before=None, domain=None) 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.reset_password(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=None, after=None, before=None, domain=None) 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 e.name; the message is meant for people and may change.

Python
from tsmart import TsmartError

try:
    client.emails.send(email, idempotency_key=key)
except TsmartError as e:
    if e.name in ("validation_error", "missing_required_field"):
        raise ValueError("Bad request: " + e.message) from e
    elif e.name in ("rate_limit_exceeded", "network_error"):
        pass  # try again later, with the same idempotency key
    else:
        print(e.status_code, e.name, e.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, 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.
405 method_not_allowed The endpoint does not support this HTTP method.
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 or other application_error An unexpected error. A response that is not JSON is raised under this name too, and status_code keeps its real HTTP status (for example 502 from a gateway).
none network_error No response: the request timed out or the API could not be reached. status_code is None.

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 max_retries 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 (a 429 for too many domains waiting for verification) are not retried. Every rate_limit_exceeded is, including limits that do not clear within seconds, such as the hourly sending limit: the client then raises the same error after the retries (about 2 seconds later with the defaults).
  • A timeout or network error is retried, after 1 second, for GET requests only. A POST, PATCH or DELETE is never repeated, because it may have arrived; the client raises a TsmartError named network_error.
  • Other errors, including 5xx responses, are raised at once.
  • timeout (default 30 seconds) applies to connecting and to each read of the response, on every attempt.
  • Redirects are never followed, so the API key is never sent to another address.

Verify webhooks

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

Python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from tsmart import WebhookVerificationError, verify_webhook

SECRET = os.environ["TSMART_WEBHOOK_SECRET"]


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # the raw body
        try:
            event = verify_webhook(body, self.headers, SECRET)
        except WebhookVerificationError:
            self.send_response(400)
            self.end_headers()
            return
        if event["type"] == "email.bounced":
            print("Bounced:", event["data"]["to"], event["data"]["bounce"]["message"])
        self.send_response(204)
        self.end_headers()


HTTPServer(("", 8000), Receiver).serve_forever()

In a web framework, pass the request body as bytes (before anything parses it) and the request's headers object; any mapping works, and header names are matched in any case.

verify_webhook(payload, headers, secret, tolerance=300, now=None)
Name Type Description
payload required str | bytes The raw request body.
headers required mapping The request headers: any mapping (names in any case) or a list of (name, value) pairs.
secret required str The webhook's signing secret, whsec_…. A missing or malformed secret raises ValueError.
tolerance float Accepted distance of webhook-timestamp from now, in seconds. Default 300.
now float The current time as a Unix timestamp, in place of the clock (for tests).

A payload that is not str or bytes (parsed JSON, say) raises TypeError: like a missing secret, that is a mistake in the receiver, not a bad request. 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.

Type hints

The package is typed and ships a py.typed marker. Responses are Dict[str, Any]; request bodies can be any mapping.

Next steps