Skip to content
Claim your free business email
Email API for Python

Send email from Python with an email API

Send email from Python over HTTPS with httpx: post one message, read the id back and follow every delivery event on a signed webhook.

Pythonpip install httpx
# pip install httpximport os
import httpx
response = httpx.post(    "https://api.rasket.com/emails",    headers={        "Authorization": "Bearer "        + os.environ["RASKET_API_KEY"],        # Required on every request.        "User-Agent": "acme-billing/1.0",        # A retry with this key cannot send twice.        "Idempotency-Key": "order-1042",    },    json={        "from": "Acme <orders@send.acme.example>",        "to": ["ronald.williams@example.com"],        "subject": "Your order has shipped",        "html": "<p>Order 1042 shipped today.</p>",    },)response.raise_for_status()
print(response.json()["id"])

What sending from Python actually involves

To send email from Python you can reach for smtplib and run your own relay, or you can make one HTTPS request and let an email API own the delivery. Rasket is the second. The sample below uses httpx; requests works the same way, because the whole send is one POST with a JSON body and four headers. A typed Python client, the rasket package, is coming soon.

Last updated 2026-10-13. Every field, header and limit below is the one the emails reference carries.

What you get with the key

Five things that arrive with the first request rather than with a later plan.

  • Idempotent sends

    Put an Idempotency-Key on a send and repeat it as often as you like. For 24 hours the same key and payload return the first response instead of a second email, and a different payload under that key is refused rather than sent.

  • Signed webhooks you can replay

    Delivery, bounce, complaint, open and click posted to your endpoint with a timestamp and a signature, retried on failure and replayable from the dashboard.

  • Inbound mail on your own domain

    Receive at your domain on every plan. Each message arrives on a signed webhook with its headers, text and HTML, and its attachments behind signed links.

  • Templates with typed variables

    Publish a template, then send it by ID or alias with the values it declares. Every email records the exact version it was rendered from.

  • Campaigns and automations

    Contacts with typed properties, topics people subscribe to, and per-contact workflows that wait, branch and send — through the same pipeline as the rest of your mail.

How to send email from Python

Six steps, in this order. Everything before the fifth is done once, and only the fifth is about the language you write in.

  1. 1

    Create an API key

    Open API keys in the dashboard and create one. It is shown once and stored hashed, so copy it then. Put it in your environment as RASKET_API_KEY and read it from there; a key committed to a repository is a key you have to rotate.

  2. 2

    Add a sending domain

    POST /domains with a domain you control. A subdomain such as send.acme.example is the usual choice, because it keeps this mail's reputation separate from the address your people write from. The response carries a records array.

  3. 3

    Publish the DNS records

    Every row of records goes into your DNS: the DKIM TXT record at rasket._domainkey, and the CNAME that gives the domain its own return path. Nothing sends from the domain until they resolve.

  4. 4

    Verify the domain

    POST /domains/{domain_id}/verify. The domain comes back with a status on each record, and once it reads verified you can send from any address on it. An unverified sender is refused rather than quietly dropped.

  5. 5

    Send your first email from Python

    Post the message with the sample above. Carry an Idempotency-Key — yours or your queue's retry cannot then turn into a second email — and a User-Agent, which every request has to have. A 200 with an id means we have taken responsibility for the message.

  6. 6

    Subscribe a webhook and check its signature

    POST /webhooks with an https endpoint and the events you care about. Delivery, bounce, complaint, open and click arrive there with svix-id, svix-timestamp and svix-signature; verify against the raw body before anything parses it.

Verify webhooks in Python

Read the raw body before anything parses it — request.get_data() in Flask, await request.body() in an async framework — and pass it with the headers to the svix package's Webhook(secret).verify(). request.get_json() has already parsed, and the signature is over what arrived rather than over what a parser reconstructed.

The five steps a verifier performs, in seven languages, are on the webhooks reference.

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

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.

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

Questions about Python

Do I need an SMTP server to send email from Python?

No, and that is the point of using an API instead of smtplib: no port 587 to open outbound and no credentials in a config file beyond one key. If a library you depend on can only speak SMTP, Rasket's SMTP relay takes it, with the key as the password.

Where do I keep the API key in a Python app?

In the environment, read as os.environ["RASKET_API_KEY"] so a missing key raises at import rather than at the first send. Send it as a bearer token in the Authorization header on every request, and never log the headers or write the key anywhere.

How do I stop a retry from sending the same email twice?

Send an Idempotency-Key header and reuse the same string for every retry of the same work. For 24 hours a repeat returns the first response instead of sending again, and a different payload under that key is refused rather than sent.

How do I verify a Rasket webhook in Python?

pip install svix, read the raw body first, then Webhook(secret).verify(raw_body, dict(request.headers)). It checks svix-id, svix-timestamp and svix-signature and raises WebhookVerificationError when any of them is wrong. Only parse the JSON after that call returns.

Can I send a template or a batch from Python?

Yes. A send can carry "template": {"id": …, "variables": {…}} instead of html or text, and the batch route, POST /emails/batch, takes up to a hundred messages in one request, validated all or none while naming the index that failed.

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 this page 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.

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 guide 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.

Make your first send

Create a key, verify a domain, and post your first message from Python. The free plan does not expire.