Docs Go to app →

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.

Packageletterapp on PyPI
LicenseMIT
RuntimePython 3.8+
SizeZero 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

OptionDefaultWhat it does
api_key-Required. lt_live_... from Settings.
base_urlhttps://api.letter.appOverride for self-hosting.
flush_at50Send a batch when this many items are queued.
flush_interval0.1 (seconds)Send queued items at most this often.
max_retries3Retry attempts on 5xx and 429.
timeout10.0 (seconds)Per-request socket timeout.
on_errorprints to stderrCalled 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 before sys.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: wait Retry-After seconds, then retry (up to max_retries).
  • 5xx or network errors: exponential backoff (0.25s x 2^attempt + jitter).
  • 4xx other than 429: raised immediately (via on_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.