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
pip install requests svixsvix is only needed to verify webhooks. Set your key in the environment:
export SOONER_API_KEY="sn_..."A small client keeps the base URL, the key and error handling in one place:
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
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
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:
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:
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.
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:
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", 200Use request.get_data(), not the parsed JSON, because verification needs the exact bytes. See Verify signatures.