How to send email from Python with an API instead of smtplib
Published Updated 7 min readBy the Rasket team

The two ways to send email from Python
To send email from Python you either speak SMTP yourself with smtplib from the standard library, or you make an HTTPS request to a Python email API client. Both put a message in front of a mail server. They differ in what happens around that.
| An HTTP API | smtplib | |
|---|---|---|
| Installation | One package | Standard library |
| Round trips | One | Several, by design |
| What you get back | A message id to store | A queue acceptance line |
| MIME assembly | Not yours | Yours, with the email package |
| Domain authentication | Generated and checked for you | Yours to publish and watch |
| Suppressions | Kept and enforced at send time | None |
| Bounces | Typed events on a webhook | Messages you parse |
| Port | 443 | 25, 465 or 587, often blocked |
| Best for | Product mail you have to account for | A relay you already run, or mail that never leaves the network |
There is nothing wrong with smtplib as an SMTP client. It is simply a client: it will not verify your domain, remember that an address bounced, or tell you what happened after the handoff. With a bare relay those are yours to build. The API versus SMTP comparison goes through the trade in full.
Install the client
pip install rasket
# or, in a uv projectuv add rasket- Install the client — Run pip install rasket, or uv add rasket in a uv project. The client is generated from the same OpenAPI document the API validates itself against.
- Read the key from the environment — Create an API key in the dashboard and export it as RASKET_API_KEY. Read it with os.environ so that a missing key fails loudly at start-up rather than silently at send time.
- Verify a sending domain — Add the domain the mail will come from and publish the DNS records it generates. A send from a domain that has not passed verification is refused before anything leaves the building.
- Call emails.send — Pass a dictionary with from, to, subject and html, and an idempotency_key derived from whatever caused the send. The from address must be on the verified domain.
- Store the returned id — Keep result.body id against your own record. It is the identifier every later delivery event carries and the one to quote in a support conversation.
- Handle the two error types — Catch RasketApiError for an answer the API gave you and RasketConnectionError for no answer at all. Only the second is safe to retry blindly, and only with the same idempotency key.
A first send
import os
from rasket import Rasket
rasket = Rasket( api_key=os.environ["RASKET_API_KEY"], user_agent="acme-billing/1.0",)
result = rasket.emails.send( { "from": "Acme <receipts@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your receipt", "html": "<p>Thanks for your order.</p>", }, idempotency_key="receipt-1042",)
print(result.body["id"])Three details are load-bearing. The key is read from the environment with os.environ rather than os.environ.get, so a missing key raises at start-up instead of sending None as a bearer token at three in the morning. The user agent is required — a request without one is refused — and what you pass is appended to the client’s own, which is what lets support tell your billing worker from your web process. And the idempotency key is derived from the order rather than generated fresh.
What comes back
Every call returns an envelope rather than a bare body: result.body is the parsed response typed per route, result.rate_limit is where you are in the window, result.request_id is the value to quote in a support conversation, and result.idempotent_replayed is true when this response was a replay of an earlier keyed request. Store the id against your own record — it is what every later event is about.
The from address has to be on a verified domain
A send from a domain that has not passed verification is refused before anything leaves. Publishing the records is a five-minute job and the verification walkthrough covers the panel behaviour that trips most people up.
The same send with requests
There is nothing in the client you cannot do with an HTTP library. The API is a bearer token, a JSON body and an optional idempotency header.
import os
import requests
response = requests.post( "https://api.rasket.com/emails", headers={ "authorization": f"Bearer {os.environ['RASKET_API_KEY']}", "content-type": "application/json", "user-agent": "acme-billing/1.0", "idempotency-key": "receipt-1042", }, json={ "from": "Acme <receipts@send.acme.example>", "to": ["ronald.williams@example.com"], "subject": "Your receipt", "html": "<p>Thanks for your order.</p>", }, timeout=10,)
response.raise_for_status()email_id = response.json()["id"]Note the timeout. A request without one can hang indefinitely, and in a worker that means a task that never finishes rather than an error you can act on. Note also that writing it by hand costs you the typed bodies, the retry policy that knows which requests are safe to repeat, and one place where the user agent is set.
Where the send belongs in Django and FastAPI
The framework matters less than the placement. A send is a network call to another service, and doing it inline inside a request handler makes your response time depend on theirs — and your request fail when theirs does.
- Django. Write the row that records the mail is owed inside the same transaction as whatever caused it, and send from a task afterwards. A task queue you already run is the right home; the row is what makes the send retryable.
- FastAPI. A background task is enough for low volume and honest about its limits: it runs in the same process, so a restart loses it. For anything you cannot afford to lose, the row plus a real queue is still the answer.
In both cases the send function should take an idempotency key as an argument rather than making one up, so that the caller — which knows what the work is — decides what counts as the same send. The stack pages have a worked example per framework.
Errors, retries and the rate limit
Two error types, and the distinction between them is the whole retry policy. RasketApiError means the API answered and said no. RasketConnectionError means nothing answered, so you do not know whether the message was sent.
from rasket import RasketApiError, RasketConnectionError
try: result = rasket.emails.send(message, idempotency_key=key)except RasketApiError as error: if error.name == "validation_error": reject(error.errors) elif error.name == "rate_limit_exceeded" and error.rate_limit: later(error.rate_limit.retry_after_seconds) else: raise # error.status_code, error.message, error.request_idexcept RasketConnectionError: # No answer arrived. Retry with the SAME idempotency_key. retry_later(key)Retry the second with the same key. A repeat of a keyed request inside the 24-hour window returns the original response and sends nothing, which is what makes retrying a timeout safe. Retrying the first is usually wrong: a validation error will fail identically forever, and only rate_limit_exceeded is worth waiting on.
The rate limit is ten requests a second per team, across every endpoint, and every response carries the limit, what is left and when the window resets. Pace a bulk job off the remaining count rather than off a sleep you tuned once on a quiet afternoon; the rate limits guide has the headers and the error shape.
Verifying webhooks in Python
Delivery events arrive as signed HTTPS requests. The signature scheme is the Standard Webhooks one, so the svix package verifies it directly and you do not have to implement the HMAC yourself.
# pip install svixfrom flask import Flask, requestfrom svix.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
@app.post("/hooks/rasket")def rasket_webhook(): # get_data() is the raw body. Never get_json() first: it parses, # and the signature is over what arrived. raw_body = request.get_data()
try: event = Webhook(RASKET_WEBHOOK_SECRET).verify(raw_body, dict(request.headers)) except WebhookVerificationError: return "invalid signature", 400
handle(event) return "", 200The comment is the whole trap. Reading the parsed JSON first and re-serialising it produces a different string, and a different string has a different signature. Read the raw body, verify, then parse. The same rule applies in Django with request.body and in FastAPI with await request.body().
Answer quickly and do the work afterwards, deduplicating on the event id, because a handler that times out will be sent the same event again. The webhooks article covers retries, ordering and replay, and the Python SDK reference documents every method on the client.
Frequently asked questions
What is wrong with smtplib?
Nothing, as an SMTP client. The standard library will happily open a connection and deliver a message. What it does not do is verify your domain, keep a suppression list, sign the message, or tell you what happened after the handoff — those live on the other side of the socket, and with a relay that does not provide them they are yours to build.
Do I need the client library at all?
No. The API is ordinary HTTP with a bearer token and a JSON body, so requests or httpx works fine and the article shows it. What the client adds is typed responses, the rate limit state on every result, a retry policy that knows which requests are safe to repeat, and one place where the User-Agent is set.
Why does my request fail with no User-Agent?
Because a request without one is refused. It is not decoration: when something starts behaving oddly at three in the morning, the User-Agent is what distinguishes your billing worker from your web process in the logs. The client requires you to pass one and appends it to its own.
Should I send email inside a request handler?
Not in production. A send is a network call to another service, and doing it inline makes your response time depend on theirs. Put it in a background task — Celery, Django's task framework, a FastAPI background task, or a queue of your own — and keep the request handler to writing the row that says the mail is owed.
How do I retry safely?
Pass the same idempotency_key on every attempt at the same send. A repeat of a keyed request within 24 hours returns the original response instead of sending a second message. Without a key a retry is a new send, so a timeout you retry blindly is how one order becomes two receipts.
How do I verify a webhook signature in Python?
The signature scheme is the Standard Webhooks one, so the svix package verifies it directly. Read the raw body with request.get_data or its equivalent in your framework, pass those bytes and the headers to the verifier, and parse the JSON only after it returns.
Sources
- smtplib — SMTP protocol client — Python Software Foundation, read 2026-09-16
- Requests: HTTP for Humans — Requests, read 2026-09-16
- svix on PyPI — Python Package Index, read 2026-09-16
Related
- Python SDK — The rasket package on PyPI: the Node client's methods, in snake_case, over httpx.
- Email API by stack — Send email from Node.js, Next.js, Python, Django, FastAPI, Rails, Laravel, Go, Bun, Deno, Cloudflare Workers or Supabase: one key, one POST, one sample.
- Quickstart — Key, domain, first send — in that order.
- Rate limits — Ten a second per team, and the headers that tell you where you are.
- Send email from Node.js: API vs SMTP vs Nodemailer — Three ways to send email from Node.js — an HTTP API, SMTP and Nodemailer — what each one costs you, and a working send with retries and webhooks.