Python SDK

Official Python SDK for the Tratto Email API

The official SDK for Python: tratto-email. No external dependencies, built on urllib. Requires Python 3.10+.

Every example on this page typechecks with mypy against the published 2.3.0. Where a feature needs a particular version, the page names the minimum one that has it.

Installation

pip install tratto-email

Initialization

from tratto import Tratto

client = Tratto('tratto_live_...')

timeout sets how long each request may wait, in seconds, for the connection and for each read. It requires 2.3.0 or newer and defaults to 60. A request that takes longer raises TrattoError with code == 'timeout' and status_code == 0:

client = Tratto('tratto_live_...', timeout=10)

A send that timed out may still have reached the API. Retry it with the same idempotency key, so it cannot go out twice.

From 2.3.0 the SDK percent-encodes every id it puts in a URL path, so an id containing /, ? or # cannot change the route called.

Send Email

from tratto import SendEmailOptions

result = client.emails.send(SendEmailOptions(
    from_='hello@yourdomain.com',
    to='user@example.com',
    subject='Hello!',
    html='<h1>Welcome!</h1>',
    text='Welcome to Tratto',
))

print(result['id'])  # email_abc123

from_ can be omitted from 2.0.0 or newer: Tratto then uses the workspace transactional sender, or the general default sender if there is none. See Senders.

From 2.0.0, SendEmailOptions and the other options classes accept keyword arguments only: SendEmailOptions('hello@yourdomain.com', ...) raises TypeError.

Send with Template

result = client.emails.send(SendEmailOptions(
    from_='hello@yourdomain.com',
    to='user@example.com',
    subject='Welcome to Acme!',
    template_id='tmpl_abc123',
    variables={
        'firstName': 'Alice',
        'code': 'SECRET123',
    },
))

Send with Markdown

The markdown field requires 1.1.0 or newer. A plain pip install tratto-email takes the newest release, so a fresh install has it; a pin below 1.1.0 does not.

Send Markdown and let the server render responsive email HTML — markdown is mutually exclusive with html:

result = client.emails.send(SendEmailOptions(
    from_='hello@yourdomain.com',
    to='user@example.com',
    subject='Welcome!',
    markdown='# Welcome, {{firstName}}!',
    variables={'firstName': 'Alice'},
))

The same release added markdown to templates.create / templates.update (creating format: 'emailmd' templates), with format, source and renderWarnings in the template responses.

Schedule Email

result = client.emails.send(SendEmailOptions(
    from_='hello@yourdomain.com',
    to='user@example.com',
    subject='Reminder',
    text='See you tomorrow!',
    scheduled_at='2025-07-01T09:00:00Z',
))

While the email is still scheduled you can move it to another date or cancel it. Both methods require 2.3.0 or newer:

client.emails.reschedule('email_abc123', '2025-07-02T09:00:00Z')
client.emails.cancel('email_abc123')  # its quota goes back to the month

Once the email has left scheduled, both raise TrattoError with code CONFLICT (409). Moving it to another UTC month can raise QUOTA_EXCEEDED (429) when that month is full, and RATE_LIMITED (429) is safe to retry.

Send a Batch

emails.send_batch() sends up to 100 emails in one request and requires 2.1.0 or newer. Each item is a SendEmailOptions without scheduled_at: an item with scheduled_at gets a 422 whose details names the item. The idempotency key covers the whole batch, so it goes on the call: an item with its own idempotency_key raises ValueError.

import uuid

result = client.emails.send_batch(
    [
        SendEmailOptions(from_='hello@yourdomain.com', to='alice@example.com', subject='Your receipt', text='Thanks, Alice.'),
        SendEmailOptions(from_='hello@yourdomain.com', to='bob@example.com', subject='Your receipt', text='Thanks, Bob.'),
    ],
    idempotency_key=str(uuid.uuid4()),
)

print(result['accepted'], result['failed'])
for item in result['emails']:  # same order as the request
    if item['status'] == 'failed':
        print(item['to'], item['error']['code'])

A batch where some emails failed still returns the 202 payload, it is not raised: a failed item has no id. Send only those again, in a new batch with a new key. After a 503 or 500 TrattoError nothing was sent, and retrying with the same key is safe. Quota, limits and errors are in Send a Batch.

Attachments

Requires tratto-email 2.2.0 or newer, and a paid plan: the Free plan answers 403 PLAN_UPGRADE_REQUIRED. Upload the file first, then reference its id from emails.send() or emails.send_batch().

with open('invoice.pdf', 'rb') as f:
    file = client.attachments.upload(
        f, filename='invoice.pdf', content_type='application/pdf'
    )

client.emails.send(SendEmailOptions(
    from_='billing@yourdomain.com',
    to='alice@example.com',
    subject='Your invoice',
    html='<p>Attached.</p>',
    attachments=[{'id': file['id']}],
))

meta = client.attachments.get(file['id'])  # metadata only, never the bytes
client.attachments.delete(file['id'])

The content can be bytes or a binary file object. Limits, expiry and storage are in Attachments.

Get Email Status

email = client.emails.get('email_abc123')
print(email['data']['status'])  # 'delivered'

List Emails

response = client.emails.list(limit=50)
for email in response['data']:
    print(f"{email['id']}: {email['status']}")

Register a Domain

domain = client.domains.add('hello.yourdomain.com')
for record in domain['data']['records']:
    print(record['type'], record['host'], record['value'])

Verify Domain

result = client.domains.verify('dom_abc123')
print(result['data']['status'])  # 'pending', 'verified' or 'failed'

Create Contact

from tratto import CreateContactOptions

contact = client.contacts.create(CreateContactOptions(
    email='user@example.com',
    first_name='Alice',
    last_name='Smith',
    tags=['vip', 'newsletter'],
    custom_fields={'plan': 'pro'},
))

Create Campaign

from tratto import CreateCampaignOptions

campaign = client.campaigns.create(CreateCampaignOptions(
    name='Q3 Sale',
    template_id='tmpl_abc123',
    audience_id='aud_abc123',
    from_name='Acme Newsletter',
    from_email='newsletter@yourdomain.com',
    subject_a='Our top picks for Q3',
))

print(campaign['data']['id'])

Send Campaign

sent = client.campaigns.send('camp_xyz789')
print(sent['data']['status'])  # 'sending'

Stop a Campaign

client.campaigns.pause('camp_xyz789')
client.campaigns.unschedule('camp_xyz789')

unschedule requires 1.2.0 or newer. It applies only while the campaign is scheduled and nothing has left: on a campaign that is sending, already paused, or waiting out its probe wave it raises TrattoError with code CONFLICT, because part of the list may already have the email. Pause it instead.

Get Campaign Stats

stats = client.campaigns.get_stats('camp_xyz789')
print(stats['data']['stats']['delivered'])   # 4950
print(stats['data']['rates']['openRate'])    # 50 → 50%, rates are percentages

Register Webhook

from tratto import CreateWebhookOptions

webhook = client.webhooks.create(CreateWebhookOptions(
    url='https://yourapi.com/webhooks/tratto',
    events=['sent', 'delivered', 'opened', 'clicked', 'bounced'],
))

print(webhook['data']['secret'])  # whsec_xyz789... (returned only once)

Verify Webhooks

verify_webhook checks the X-Tratto-Signature header of a delivery and requires 2.3.0 or newer. Pass the raw body exactly as received, before any JSON parsing. With Flask:

import json
import os

from flask import Flask, request
from tratto import verify_webhook

app = Flask(__name__)


@app.post('/webhooks/tratto')
def tratto_webhook():
    raw_body = request.get_data()  # bytes, before any parsing
    if not verify_webhook(
        raw_body,
        request.headers.get('X-Tratto-Signature'),
        os.environ['TRATTO_WEBHOOK_SECRET'],
    ):
        return {'error': 'Invalid signature'}, 401

    event = json.loads(raw_body)
    return {'ok': True}, 200

It returns False for a missing, malformed, stale or non-matching header, and raises ValueError only on an empty secret or a negative tolerance. The tolerance is 5 minutes either way; change it with tolerance_seconds=600. How the signature is built is in Webhook Signature Verification.

List Flows

flows = client.flows.list(limit=20)
active = [f for f in flows['data'] if f['status'] == 'active']

The status argument of flows.list is deprecated from 2.3.0: the API ignores it and returns flows of every status, so passing it now emits a DeprecationWarning. Filter on each flow's status (draft, active or inactive) in the response instead, as above.

Error Handling

from tratto import TrattoError

try:
    result = client.emails.send(SendEmailOptions(
        from_='hello@unverified.com',
        to='user@example.com',
        subject='Hello',
        text='Test',
    ))
except TrattoError as e:
    if e.code == 'FORBIDDEN':
        # The message names the domain that is not verified.
        print(e)

An error with code == 'timeout' and status_code == 0 is the client timeout, not an API response.

Besides code and status_code, TrattoError carries two fields from the API response, both from 1.2.0 or newer: suggestion, the API's own words on what to do about the error, and docs, a link to the page that explains the code. Both are None when the response does not include them.

On a 422 VALIDATION_ERROR, details lists what is wrong and where: for the fourth email of a batch, e.details[0]['path'] is '/emails/3/to'. It requires 2.1.0 or newer and is None when the response has none.

except TrattoError as e:
    print(e.code, e.status_code)
    if e.suggestion:
        print(e.suggestion)

suggestion is the field to read where one code covers several situations and only the text tells them apart. The CONFLICT on campaigns.send and campaigns.unschedule is the case to keep in mind: see Error Codes.



Edit this page on GitHub

Last updated on