Python SDK
letterapp is the official Python client. It batches ingestion events on a
background thread, retries on transient failures, and generates idempotency
keys for you - so the common letter.track(...) call from a request handler is
non-blocking and safe to retry. It also sends transactional email with
letter.send(...).
If you’d rather call the HTTP API directly, see the Ingestion API and Transactional email.
| Package | letterapp on PyPI |
| License | MIT |
| Runtime | Python 3.8+ |
| Size | Zero runtime dependencies (standard library only) |
Install
pip install letterapp
The package has no runtime dependencies - HTTP goes over the standard
library urllib, threading over threading + queue. Works on any Python
3.8+: Django, Flask, FastAPI, Celery workers, scripts, Lambda.
You’ll need an API key from Dashboard - Settings - API keys before the SDK will do anything useful.
Quick start
import os
from letterapp import Letter
letter = Letter(api_key=os.environ["LETTER_API_KEY"])
letter.identify(
user_id=user.id,
email=user.email,
traits={"name": user.name, "plan": "free"},
)
letter.group(
user_id=user.id,
account_id=workspace.id,
name=workspace.name,
traits={"plan": workspace.plan, "mrr": 49},
)
letter.track(
user_id=user.id,
event="Workspace Created",
properties={"workspace_id": workspace.id},
)
# Required before the process exits on long-running servers.
letter.close()
Create the api_key in Dashboard - Settings - API keys. It’s shown once
on creation and never again - store it somewhere safe.
You can also use the client as a context manager, which flushes on exit:
with Letter(api_key=os.environ["LETTER_API_KEY"]) as letter:
letter.track(user_id=user.id, event="Workspace Created")
Constructor options
| Option | Default | What it does |
|---|---|---|
api_key | - | Required. lt_live_... from Settings. |
base_url | https://api.letter.app | Override for self-hosting. |
flush_at | 50 | Send a batch when this many items are queued. |
flush_interval | 0.1 (seconds) | Send queued items at most this often. |
max_retries | 3 | Retry attempts on 5xx and 429. |
timeout | 10.0 (seconds) | Per-request socket timeout. |
on_error | prints to stderr | Called when a background flush fails. |
Methods
-
identify(user_id, email=, traits=, timezone=, timestamp=, message_id=) -
group(user_id, account_id, name=, traits=, timestamp=, message_id=) -
track(user_id, event, properties=, timestamp=, message_id=)These enqueue and return immediately. Transport errors surface via
on_error. Fast path for long-running servers. -
send(to, subject, html=, text=, from_email=, from_name=, reply_to=, headers=, tag=, metadata=, idempotency_key=)- one transactional email, sent immediately. See below. -
flush()- send everything queued now; blocks until the request settles. -
close()- flush, stop the background thread, refuse new enqueues. Runs automatically at interpreter exit, but call it explicitly beforesys.exit()so no events are lost.
Transactional email
send() mails one person right now: a receipt, a password reset, a
verification link. It is never batched and unaffected by flush_at - batching
a password reset would be a bug, not an optimization.
result = letter.send(
to="user@example.com",
subject="Reset your password",
html="<p>Click <a href='https://...'>here</a> to reset.</p>",
tag="password-reset",
idempotency_key=f"password-reset:{token}",
)
result["messageId"] # provider id, appears in delivery events
result["replayed"] # True if an idempotency-key replay returned an earlier send
Only to, subject and one of html / text are required. The parameter is
from_email rather than from because from is a Python keyword; it maps to
the API’s from field and must be on a verified domain. A plain-text part is
derived from the HTML when you don’t supply one. The full option list and the
suppression rules are in Transactional email.
Failures raise LetterError carrying .status, .code and .reason. The
reason is what tells “this recipient is unreachable” apart from “our account is
blocked”:
from letterapp import LetterError
try:
letter.send(to=to, subject=subject, html=html)
except LetterError as err:
# Hard-bounced or reported spam. Nothing to retry, nothing to fix.
if err.reason != "suppressed":
raise
Retry behavior
429: waitRetry-Afterseconds, then retry (up tomax_retries).5xxor network errors: exponential backoff (0.25s x 2^attempt + jitter).4xxother than 429: raised immediately (viaon_error), no retry.
The SDK auto-generates a UUID message_id per ingestion call, so retries dedupe
at the server. See Idempotency for the underlying
guarantee.
send() is the exception: it only retries when you pass an idempotency_key.
Without one, a retry after a timeout could put a second copy of the email in
someone’s inbox, which is worse than failing the call.
Serverless mode
In serverless / function environments there’s no background time between
requests to drain the queue, so set flush_at=1 and call flush() at the end
of each handler:
letter = Letter(api_key=os.environ["LETTER_API_KEY"], flush_at=1)
def handler(event, context):
letter.track(user_id=user_id, event="Checkout Started")
letter.flush()
Errors
Configuration errors and non-retryable API responses raise LetterError
(carrying .status, .code, .reason and .body), which uses the same shape
as the HTTP API - see Error format. Background
transport errors are passed to on_error instead, since they can’t be raised
to the caller.
Versioning
The SDK follows semver. While we’re at 0.x:
- patch (
0.1.0 -> 0.1.1) - bug fixes only. - minor (
0.1.0 -> 0.2.0) - new options, new methods, behavior changes. - major (
0.x -> 1.0.0) - only once the HTTP API and signatures are stable. Until then, pin a minor range (letterapp~=0.1).
Every request sends a User-Agent: letterapp-python/<version> header so we can
spot outdated clients in server logs.