SDKs

Python

Call the Sooner API from Python with the requests library, handle errors and retries, and verify webhooks with the svix package.

Sooner is a plain JSON API, so Python needs nothing more than an HTTP client. This page uses requests. It does not cover Resend's own Python package, because we have not verified how that package sets a custom base URL.

Install and configure

Shell
pip install requests svix

svix is only needed to verify webhooks. Set your key in the environment:

Shell
export SOONER_API_KEY="sn_..."

A small client keeps the base URL, the key and error handling in one place:

Python
import os
import time
import requests

BASE_URL = "https://api.sooner.dev"


class SoonerError(Exception):
    def __init__(self, status: int, name: str, message: str, retry_after: float | None = None):
        super().__init__(f"{name}: {message}")
        self.status = status
        self.name = name
        self.message = message
        self.retry_after = retry_after


session = requests.Session()
session.headers.update({
    "Authorization": f"Bearer {os.environ['SOONER_API_KEY']}",
    "Content-Type": "application/json",
})


def call(method: str, path: str, *, json=None, params=None, headers=None):
    res = session.request(method, BASE_URL + path, json=json, params=params, headers=headers, timeout=30)
    if res.ok:
        return res.json() if res.content else None
    try:
        body = res.json()
    except ValueError:
        body = {}
    retry_after = res.headers.get("retry-after")
    raise SoonerError(
        res.status_code,
        body.get("name", "unknown_error"),
        body.get("message", res.text),
        float(retry_after) if retry_after else None,
    )

Sooner keys start with sn_. See Authentication. Use a sending_access key if this code only sends email.

Send an email

Python
email = call(
    "POST",
    "/emails",
    json={
        "from": "Acme <orders@mail.yourcompany.com>",
        "to": ["ada@example.com"],
        "subject": "Your order is confirmed",
        "html": "<p>Thanks for your order.</p>",
        "text": "Thanks for your order.",
        "tags": [{"name": "category", "value": "order_confirmation"}],
    },
    headers={"Idempotency-Key": "order-1042-confirmation"},
)
print(email["id"])

The from domain must be verified in the key's project. Send html or text, because Sooner does not render React on the server. See Send an email.

Batch, schedule and cancel

Python
batch = call("POST", "/emails/batch", json=[
    {"from": "Acme <hello@mail.yourcompany.com>", "to": ["ada@example.com"], "subject": "Hi Ada", "html": "<p>Hi</p>"},
    {"from": "Acme <hello@mail.yourcompany.com>", "to": ["grace@example.com"], "subject": "Hi Grace", "html": "<p>Hi</p>"},
])
ids = [item["id"] for item in batch["data"]]

scheduled = call("POST", "/emails", json={
    "from": "Acme <hello@mail.yourcompany.com>",
    "to": ["ada@example.com"],
    "subject": "Your trial ends tomorrow",
    "html": "<p>Your trial ends tomorrow.</p>",
    "scheduled_at": "in 2 days",
})

call("PATCH", f"/emails/{scheduled['id']}", json={"scheduled_at": "2026-11-21T09:00:00Z"})
call("POST", f"/emails/{scheduled['id']}/cancel")

Retrieving, rescheduling and canceling need a full_access key. See Emails API.

Other endpoints

Every endpoint in the API reference works through the same call function:

Python
contact = call("POST", "/contacts", json={"email": "ada@example.com", "first_name": "Ada"})
call("PATCH", "/contacts/ada@example.com", json={"unsubscribed": True})

usage = call("GET", "/usage")
print(usage["emails"]["monthly"]["used"], "of", usage["emails"]["monthly"]["limit"])

Lists are paginated with limit and after:

Python
def all_emails():
    after = None
    while True:
        params = {"limit": 100, **({"after": after} if after else {})}
        page = call("GET", "/emails", params=params)
        yield from page["data"]
        if not page["has_more"]:
            return
        after = page["data"][-1]["id"]

See Pagination.

Handle errors and retry

Errors raise SoonerError with the error name from Errors. Retry rate limits, the daily quota and server errors. Do not retry validation errors or the monthly quota.

Python
RETRYABLE = {"rate_limit_exceeded", "daily_quota_exceeded", "internal_server_error"}


def send_with_retry(payload: dict, key: str, attempts: int = 5):
    for i in range(attempts):
        try:
            return call("POST", "/emails", json=payload, headers={"Idempotency-Key": key})
        except SoonerError as e:
            if e.name not in RETRYABLE or i == attempts - 1:
                raise
            time.sleep(e.retry_after or 2 ** i)
        except requests.RequestException:
            if i == attempts - 1:
                raise
            time.sleep(2 ** i)

The idempotency key makes the retry safe, even when the first request timed out after Sooner accepted it. See Idempotency.

Verify webhooks

Verify the signature with the svix package, using the webhook's signing secret and the raw request body. Here is a Flask example:

Python
import os
from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
wh = Webhook(os.environ["SOONER_WEBHOOK_SECRET"])


@app.post("/webhooks/sooner")
def sooner_webhook():
    try:
        event = wh.verify(request.get_data(), {
            "svix-id": request.headers["svix-id"],
            "svix-timestamp": request.headers["svix-timestamp"],
            "svix-signature": request.headers["svix-signature"],
        })
    except WebhookVerificationError:
        return "invalid signature", 400

    if event["type"] == "email.bounced":
        print("bounced", event["data"]["email_id"])
    return "ok", 200

Use request.get_data(), not the parsed JSON, because verification needs the exact bytes. See Verify signatures.