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:
pip install https://tsmart.ai/sdk/python/tsmart-1.1.1-py3-none-any.whl In a requirements.txt, use a direct reference:
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.
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) | 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
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:
| 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
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,bccandreply_totake one address or a list. At most 50 recipients acrossto,ccandbcc. -
htmlortextis required. Withouttext, a plain-text part is generated from the HTML; an emptytextsends none. -
Attachment
contentis base64 text;bytesare encoded for you. Up to 20 attachments and 20 MB in total; addcontent_idto show an image inline withcid:in the HTML. -
headersmay containX-…headers,List-Unsubscribe,List-Unsubscribe-Post,In-Reply-ToandReferences. -
The camelCase spellings
replyTo,scheduledAt, andcontentTypeandcontentIdon attachments, are converted to the API names. Your dict is not changed. -
idempotency_keyis 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 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.
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
# 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.
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.
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
# 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
# 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
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
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
# 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.
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
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.
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.
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_retriestimes (default 2). It waits for theretry-afterheader, elseratelimit-reset, else 1 second, and never longer than 60 seconds.monthly_quota_exceededandvalidation_error(a 429 for too many domains waiting for verification) are not retried. Everyrate_limit_exceededis, 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
TsmartErrornamednetwork_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.
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.
| 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.