Skip to content
Claim your free business email

How to send email from Python with an API instead of smtplib

Published Updated 7 min readBy the Rasket team

Sending email from Python with an API: an envelope riding the crest of a long curve.

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.

Sending from Python with an API and with smtplib
An HTTP APIsmtplib
InstallationAn HTTP library such as requestsStandard library
Round tripsOneSeveral, by design
What you get backA message id to storeA queue acceptance line
MIME assemblyNot yoursYours, with the email package
Domain authenticationGenerated and checked for youYours to publish and watch
SuppressionsKept and enforced at send timeNone
BouncesTyped events on a webhookMessages you parse
Port44325, 465 or 587, often blocked
Best forProduct mail you have to account forA 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 an HTTP library

Coming soon: the rasket package is not published yet. Until it is, the send is an ordinary HTTPS request, and requests is all it takes; httpx works the same way if you need async.

pip install requests
# or, in a uv projectuv add requests
  1. Install an HTTP library — Run pip install requests, or uv add requests in a uv project. The API is ordinary HTTPS with a bearer token and a JSON body, so that is all a send needs.
  2. 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.
  3. 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.
  4. POST to /emails — Send from, to, subject and html as JSON with a bearer key, a User-Agent, an Idempotency-Key derived from whatever caused the send, and a timeout. The from address must be on the verified domain.
  5. Store the returned id — Keep the id from the response body against your own record. It is the identifier every later delivery event carries, and x-request-id is the header to quote in a support conversation.
  6. Handle the two kinds of failure — A non-2xx answer means the API said no; a connection error or timeout means nothing answered. Only the second is safe to retry blindly, and only with the same idempotency key.

A first send

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"]

Four 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 naming your application is what lets support tell your billing worker from your web process. The idempotency key is derived from the order rather than generated fresh. And 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.

What comes back

A 200 carries the message’s id in the JSON body. The headers say where you are in the rate limit window, and x-request-id is the value to quote in a support conversation. 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.

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 kinds of failure, and the distinction between them is the whole retry policy. A response that is not 2xx means the API answered and said no. A connection error or a timeout means nothing answered, so you do not know whether the message was sent.

import requests
try:    response = requests.post(URL, headers=headers(key), json=message, timeout=10)except (requests.ConnectionError, requests.Timeout):    # No answer arrived. Retry with the SAME Idempotency-Key.    retry_later(key)else:    if not response.ok:        error = response.json()        if error["name"] == "validation_error":            reject(error["errors"])        elif response.status_code == 429:            later(int(response.headers["retry-after"]))        else:            # error["message"]; quote response.headers["x-request-id"] to support            response.raise_for_status()

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 "", 200

The 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 emails API reference documents every field a send takes.

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.

Is there a Python client library?

Not yet: the rasket package for Python is coming soon and is not on PyPI today. The API is ordinary HTTP with a bearer token and a JSON body, so requests or httpx works fine, and every sample in this article uses requests.

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. Name your application in it, and set it in one place.

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

  1. smtplib — SMTP protocol client — Python Software Foundation, read 2026-09-16
  2. Requests: HTTP for Humans — Requests, read 2026-09-16
  3. svix on PyPI — Python Package Index, read 2026-09-16
  • 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.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.