SDKs

Python

Use the official Lettermint Python SDK to send email from scripts, web applications, and workers. The package is fully typed and has synchronous and asynchronous clients with the same methods. See Integrations for the SDK capability overview.

This guide covers SDK 3.0. If your project uses 2.x, follow the Python SDK 3.0 upgrade guide on GitHub.

Requirements

Before you start you need:

If your domain is not verified yet, follow the domain setup guide first so your mail reaches the inbox instead of the spam folder.

1. Installation

Install the SDK via pip:

pip install lettermint

2. Send your first email

Create the client with your Project API token as the sending_token:

Code
import os from lettermint import Lettermint lettermint = Lettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"])

The client stores nothing about a message, so create it once and share it across your app, including across threads and concurrent requests. You can also pass the token as a string, as in Lettermint(token).

Send your first email:

Code
response = lettermint.emails.send({ "from": "John Doe <john@yourdomain.com>", "to": ["recipient@example.com"], "subject": "Hello from Lettermint", "text": "This is a test email sent using the Lettermint Python SDK.", }) print(f"Email sent with ID: {response['message_id']}")

emails.send() takes the message in the API's format, for example reply_to and scheduled_at. The to, cc, and bcc fields are lists.

The Team API uses a separate Team API token. Pass it as team_token, alone or next to sending_token. Each part of the client uses its own token and never falls back to the other. See the Team API quickstart.

3. Email features

The examples in this section use lettermint.emails.compose(), a fluent builder. Every setter returns a new builder and leaves the original unchanged, so one builder is safe to share and reuse. Keep the value that a setter returns when you build an email over several statements:

Code
draft = ( lettermint.emails.compose() .from_("John Doe <john@yourdomain.com>") .to("recipient@example.com") .subject("Your invoice") ) if copy_accountant: draft = draft.cc("accountant@yourdomain.com") draft.html("<p>Your invoice is ready.</p>").send()

Each builder example is equivalent to a plain dict passed to lettermint.emails.send(). Use whichever reads better.

Basic email

Send a simple text or HTML email:

Code
response = ( lettermint.emails.compose() .from_("John Doe <john@yourdomain.com>") .to("recipient@example.com") .subject("Your account is ready!") .html("<h1>Welcome!</h1><p>Thanks for signing up.</p>") .text("Welcome! Thanks for signing up.") .send() )

Multiple recipients

Send to multiple recipients using CC and BCC:

Code
response = ( lettermint.emails.compose() .from_("John Doe <john@yourdomain.com>") .to("user1@example.com", "user2@example.com") .cc("manager@yourdomain.com") .bcc("archive@yourdomain.com") .subject("Monthly Newsletter") .html("<h1>This Month's Updates</h1>") .send() )

Custom headers and reply-to

Add custom headers and set reply-to addresses:

Code
response = ( lettermint.emails.compose() .from_("support@yourdomain.com") .to("customer@example.com") .reply_to("help@yourdomain.com") .subject("Support Ticket #12345") .headers({ "X-Priority": "1", "X-Ticket-ID": "12345" }) .html("<p>Your support ticket has been updated.</p>") .send() )

Metadata

Add metadata for tracking and webhook payloads:

Code
response = ( lettermint.emails.compose() .from_("notifications@yourdomain.com") .to("user@example.com") .subject("Order Confirmation") .metadata({ "order_id": "12345", "customer_id": "cust_789", "campaign": "order_confirmation" }) .html("<p>Your order has been confirmed.</p>") .send() )

Metadata is included in webhook payloads but not added to the actual email headers. Use it for tracking and analytics purposes.

Tags

Categorize emails for filtering and analytics:

Code
response = ( lettermint.emails.compose() .from_("alerts@yourdomain.com") .to("admin@example.com") .subject("System Alert") .tags([ {"name": "category", "value": "system-alerts"}, {"name": "environment", "value": "production"}, ]) .html("<p>Critical system alert detected.</p>") .send() )

tags() accepts up to 20 name and value pairs. The SDK checks names and values before it sends the request and raises LettermintValidationError if one is invalid. tag() still sets the existing singular tag, which has no deprecation date. See Tags documentation.

Route selection

Direct emails to specific routes within your project:

Code
response = ( lettermint.emails.compose() .from_("notifications@yourdomain.com") .to("user@example.com") .subject("Welcome!") .route("transactional") .html("<p>Welcome to our platform.</p>") .send() )

File attachments

Attach files to your emails. content is base64 text or bytes. The SDK encodes bytes to base64 for you:

Code
from pathlib import Path response = ( lettermint.emails.compose() .from_("invoices@yourdomain.com") .to("customer@example.com") .subject("Your Invoice") .html("<p>Please find your invoice attached.</p>") .attach( "invoice.pdf", Path("/path/to/document.pdf").read_bytes(), content_type="application/pdf", ) .send() )

Inline images

Embed images directly in your HTML using Content-ID. Set content_id on the attachment and reference it as cid: in the markup:

Code
from pathlib import Path response = ( lettermint.emails.compose() .from_("marketing@yourdomain.com") .to("customer@example.com") .subject("Welcome to Our Platform") .html('<p>Welcome!</p><img src="cid:logo@yourdomain.com" alt="Logo">') .attach("logo.png", Path("/path/to/logo.png").read_bytes(), content_id="logo@yourdomain.com") .send() )

See Inline images for more.

Idempotency

Prevent duplicate emails with idempotency keys. The key is an argument of the call that sends the email:

Code
response = ( lettermint.emails.compose() .from_("notifications@yourdomain.com") .to("user@example.com") .subject("Order Confirmation") .html("<p>Your order has been confirmed.</p>") .send(idempotency_key="order-12345-confirmation") )

emails.send(message, idempotency_key=...) and emails.send_batch(messages, idempotency_key=...) take the same argument. The key applies only to that call, and the SDK never retries on its own.

Use one unique key for each logical email. For example, combine the order ID and the email type. A retry with the same key does not send a duplicate email. See the idempotency documentation for the full behaviour.

Batch sending

Send up to 500 emails in one request. The list can mix plain dicts and builders:

Code
base = ( lettermint.emails.compose() .from_("John Doe <john@yourdomain.com>") .subject("Your weekly summary") ) results = lettermint.emails.send_batch( [ { "from": "John Doe <john@yourdomain.com>", "to": ["user1@example.com"], "subject": "Hi", "text": "Hello", }, base.to("user2@example.com").text("Your summary is ready."), ], idempotency_key="weekly-summary-2026-10-05", )

4. Send an order confirmation

A real transactional send usually pulls several of these features together. This example sends a receipt with an HTML and plain-text body, tags it for analytics, attaches the order and customer IDs as metadata, and uses an idempotency key so a retry never emails the customer twice:

order_confirmation.py
import os from lettermint import Lettermint lettermint = Lettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) def send_order_confirmation(order): return ( lettermint.emails.compose() .from_("Acme Store <orders@yourdomain.com>") .to(order["customer_email"]) .reply_to("support@yourdomain.com") .subject(f"Order {order['number']} confirmed") .html(f"<h1>Thanks for your order</h1><p>Order {order['number']} totalling {order['total']} is confirmed.</p>") .text(f"Thanks for your order. Order {order['number']} totalling {order['total']} is confirmed.") .tags([{"name": "type", "value": "order-confirmation"}]) .metadata({"order_id": order["id"], "customer_id": order["customer_id"]}) .route("transactional") .send(idempotency_key=f"order-confirmation-{order['id']}") )

The metadata you attach here travels with every webhook event for the message, so a later delivery or bounce maps straight back to the order in your database.

5. Async support

AsyncLettermint has the same methods as Lettermint for use with asyncio or trio. Every method that talks to the API is a coroutine, so await it:

Code
import asyncio import os from lettermint import AsyncLettermint async def send_email(): async with AsyncLettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) as lettermint: response = await ( lettermint.emails.compose() .from_("John Doe <john@yourdomain.com>") .to("recipient@example.com") .subject("Hello from Lettermint") .text("This is a test email.") .send() ) print(f"Email sent with ID: {response['message_id']}") asyncio.run(send_email())

Use the async client in FastAPI, Starlette, or other async frameworks so a request does not block the event loop. Cancelling the task, for example with asyncio.timeout(), cancels the request.

6. Client configuration

Customize the client with optional arguments:

Code
lettermint = Lettermint( sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"], base_url="https://api.lettermint.co/v1", # Custom API URL timeout=60.0, # Request timeout in seconds, for the whole request )

timeout also works per call: every method takes a keyword-only timeout. To send requests through your own httpx client, for example with a proxy, pass it as http_client. The SDK never follows redirects with it and does not close it.

The client owns an HTTP connection pool. Close it when you are done, or use it as a context manager for automatic cleanup:

Code
with Lettermint(sending_token=os.environ["LETTERMINT_PROJECT_TOKEN"]) as lettermint: response = lettermint.emails.send({ "from": "sender@yourdomain.com", "to": ["recipient@example.com"], "subject": "Test", "text": "Hello!", })

7. Response

Code
response = lettermint.emails.send({ "from": "John Doe <john@yourdomain.com>", "to": ["recipient@example.com"], "subject": "Test", "text": "Hello!", }) print(response["message_id"]) # Unique email ID print(response["status"]) # "pending", or "scheduled" with a scheduled_at time

Responses are plain dicts typed as TypedDicts from lettermint.types, such as SendMailResponse.

8. Error handling

Every exception the SDK raises is a LettermintError. API errors are APIError, which carries status, code, and message. Catch the subclasses you want to handle:

Code
import time from lettermint import APIError, APITimeoutError, RateLimitError, ValidationError try: lettermint.emails.send(message, idempotency_key=key) except ValidationError as error: # 422: invalid parameters, daily limit exceeded, and so on print(error.message, error.errors) except RateLimitError as error: # Wait, then retry with the same idempotency_key time.sleep(error.retry_after or 1) except APITimeoutError: # The outcome is unknown. Retry with the same idempotency_key. ... except APIError as error: print(error.status, error.code, error.message)

The SDK never retries on its own. Reuse the same idempotency key when you retry so the customer gets one email. Other exceptions include AuthenticationError, PermissionDeniedError, NotFoundError, ConflictError, ServerError, APIConnectionError, LettermintConfigError for a missing token, and LettermintValidationError when the SDK rejects a request before it sends it. Network errors from httpx never escape the SDK.

Track delivery, opens, and bounces

Sending is only half of a transactional setup. To see what happens after a message leaves your app, combine the SDK with Lettermint's platform features:

  • Email tracking records opens and clicks, with bot filtering so your metrics stay accurate.
  • Webhooks push delivery, bounce, and complaint events to your server in real time, so you can update order records or suppress bad addresses. The SDK verifies each delivery with Webhook(secret).verify(raw_body, headers). See Webhook signatures.
  • Test addresses let you trigger a hard or soft bounce on demand while you build and verify your webhook handler.

Tracking is configured per route, so turn it on for the route your app sends through, then read the results from your dashboard or your webhook endpoint.

FAQ

Does the SDK support async?

Yes. Import AsyncLettermint and use it with asyncio or trio. It works well inside async frameworks such as FastAPI and Starlette, and it has the same methods as the synchronous client.

How do I stop the same email being sent twice?

Pass an idempotency_key argument to send() or send_batch(), derived from a stable ID such as an order number. A repeated request with the same key returns the original result instead of sending again. See the idempotency documentation.

Is Lettermint email GDPR compliant and EU-hosted?

Yes. Lettermint runs exclusively on European infrastructure and processes email in line with GDPR, so transactional mail from your Python app is handled inside the EU.

How do I track opens, clicks, and bounces?

Enable tracking on your route and subscribe to webhooks to receive delivery, open, and bounce events. Use the test addresses to simulate bounces while you build your handler.

Next steps

Tags

Organize and filter emails with tags.

Tracking

Track opens, clicks, and deliverability.

Webhooks

Receive delivery events through webhooks.

SMTP Alternative

Send via SMTP instead of the API.

GitHub Repository

Find the complete source code, report issues, or contribute on GitHub.