Skip to content

Sending from your app

By now you have a SESKit instance running somewhere. This page is about the other half: calling it from the application that actually wants to send mail.

your application                   your SESKit server
┌─────────────────────┐           ┌────────────────────────┐
│  POST /v1/emails    │  ─HTTP→   │ API → queue → worker   │ ──→ SES or Mailpit
│  Bearer sk_...      │           │                        │
└─────────────────────┘           └────────────────────────┘

Usually a different machine, often a different codebase. Nothing about your application needs to know SESKit exists beyond a base URL and an API key.

Python, with the SDK

from seskit import SesKit

client = SesKit(api_key="sk_live_...", base_url="https://seskit.internal.example.com")

sent = client.emails.send(
    from_="hello@example.com",
    to=["user@example.com"],
    subject="Welcome",
    html="<h1>Welcome!</h1>",
)

pip install seskit. It retries safely, raises a class per error type, and has an async client for handlers that must not block. Full surface: Python SDK.

Python, without it

The examples below use no dependency beyond an HTTP client you almost certainly already have. Everything the SDK does, it does over these same calls — it can never do anything a curl command cannot.

import httpx

SESKIT_URL = "https://seskit.internal.example.com"
SESKIT_KEY = "sk_live_..."


def send_welcome(address: str) -> str:
    response = httpx.post(
        f"{SESKIT_URL}/v1/emails",
        headers={"Authorization": f"Bearer {SESKIT_KEY}"},
        json={
            "from": "hello@example.com",
            "to": [address],
            "subject": "Welcome to Acme",
            "html": "<h1>Welcome!</h1>",
            "text": "Welcome!",
        },
        timeout=10,
    )
    response.raise_for_status()
    return str(response.json()["id"])

Send text alongside html whenever you can. Some clients prefer it, some recipients insist on it, and a message with only an HTML part is more likely to be scored as spam.

Making retries safe

If your own job runner can retry — and it can — send an Idempotency-Key derived from what the message is, not from when it was sent:

headers = {
    "Authorization": f"Bearer {SESKIT_KEY}",
    "Idempotency-Key": f"welcome:{user.id}",
}

A retry then returns the original email id instead of sending a second copy. A key containing a timestamp or a random value defeats the entire point, because the retry generates a different one.

Handling errors

Failures come back in one shape:

{ "error": { "type": "domain_not_verified", "message": "..." } }

The type is stable and worth branching on; the message is for humans and may be reworded. The full list is in Errors.

if response.status_code >= 400:
    error = response.json()["error"]
    if error["type"] == "domain_not_verified":
        ...  # a configuration problem: alert someone, do not retry
    raise RuntimeError(error["message"])

Rate limits

Limits are per project, not per key, and every response carries them:

X-RateLimit-Limit       100
X-RateLimit-Remaining   87
X-RateLimit-Reset       1756800060

Issuing a second API key does not buy more headroom — see Configuration for API_RATE_LIMIT_PER_MINUTE.

Knowing what happened next

The response tells you SESKit accepted the message. It cannot tell you the mail arrived, because at that moment nobody knows.

  • Poll GET /v1/emails/{id} for one message.
  • Or, far better, have SESKit tell you: webhooks push a signed event when a message is delivered, bounces, or is reported as spam.

Polling every message you have ever sent is the thing webhooks exist to stop you doing.