Skip to content
Back to Guides

Python

Updated

Send emails from Python with Lumail

Lumail has no Python package, and it does not need one: sending is a single authenticated POST. This guide wraps it in a small helper you can drop into any script, Django view, Flask route or FastAPI endpoint.

TL;DR

POST JSON to https://lumail.io/api/v2/emails with Authorization: Bearer <LUMAIL_API_KEY> and a body of from, to, subject and exactly one of html, markdown or tiptap. A 2xx response returns {"id": "eml_..."}. Errors return name, message and statusCode. Send an Idempotency-Key header on anything you might retry.

Prerequisites

  • Python 3.10 or later with requests (sync) or httpx (sync and async).
  • A Lumail API token that starts with lum_.
  • A sending domain verified in the same Lumail organization as the token.

1. Install the packages

Terminal
pip install requests # or: pip install httpx

2. Set your environment variables

Read the token from the environment with os.environ. Locally, export it in your shell or load a .env file with your framework's usual tool; in production, set it in your platform's secret settings.

Reading it with os.environ["LUMAIL_API_KEY"] rather than .get() makes a missing key fail at import time instead of producing 401s later.

.env
# Server-side only. Create it in Lumail under Settings > API Tokens. LUMAIL_API_KEY=lum_your_api_token

3. Write a small send helper

The helper sets the bearer token, adds the optional idempotency header, and turns error responses into an exception that carries Lumail's error name and status.

from is a Python keyword, so the payload is built as a dict rather than with keyword arguments.

lumail_client.py
import os import requests LUMAIL_API_KEY = os.environ["LUMAIL_API_KEY"] LUMAIL_EMAILS_URL = "https://lumail.io/api/v2/emails" class LumailError(Exception): def __init__(self, name: str, message: str, status_code: int): super().__init__(f"{name}: {message}") self.name = name self.status_code = status_code def send_email(payload: dict, idempotency_key: str | None = None) -> str: headers = {"Authorization": f"Bearer {LUMAIL_API_KEY}"} if idempotency_key: headers["Idempotency-Key"] = idempotency_key response = requests.post(LUMAIL_EMAILS_URL, json=payload, headers=headers, timeout=30) if not response.ok: try: body = response.json() except ValueError: body = {} raise LumailError( body.get("name", "application_error"), body.get("message", response.reason), response.status_code, ) return response.json()["id"]

4. Send an email

Pass one recipient in to. Leave text out and Lumail builds the plain-text part from your HTML.

send.py
from lumail_client import LumailError, send_email try: email_id = send_email( { "from": "Acme <[email protected]>", "to": "[email protected]", # fixture address: queued, never delivered "subject": "Hello from Python", "html": "<p>It works. This email was sent through the Lumail API.</p>", } ) print("Queued", email_id) except LumailError as error: print("Send failed", error.name, error.status_code)

5. Send from FastAPI with httpx

In an async framework, use httpx.AsyncClient so the request does not block the event loop. Create the client once with the app lifespan and reuse its connection pool.

main.py
import os from contextlib import asynccontextmanager import httpx from fastapi import FastAPI, HTTPException from pydantic import BaseModel LUMAIL_API_KEY = os.environ["LUMAIL_API_KEY"] @asynccontextmanager async def lifespan(app: FastAPI): app.state.lumail = httpx.AsyncClient( base_url="https://lumail.io/api/v2", headers={"Authorization": f"Bearer {LUMAIL_API_KEY}"}, timeout=30, ) yield await app.state.lumail.aclose() app = FastAPI(lifespan=lifespan) class Welcome(BaseModel): email: str user_id: str @app.post("/welcome") async def welcome(body: Welcome): response = await app.state.lumail.post( "/emails", json={ "from": "Acme <[email protected]>", "to": body.email, "subject": "Welcome to Acme", "markdown": "Your account is **ready**.", }, headers={"Idempotency-Key": f"welcome:{body.user_id}"}, ) if response.is_error: raise HTTPException(status_code=502, detail="Email could not be sent") return {"id": response.json()["id"]}

6. Send a batch

POST a JSON array of up to 100 email objects to /api/v2/emails/batch. The response is {"data": [{"id": ...}]} in the same order as your array.

batch.py
response = requests.post( "https://lumail.io/api/v2/emails/batch", json=[ {"from": "Acme <[email protected]>", "to": to, "subject": "Report ready", "markdown": "Your report is ready."} for to in ["[email protected]", "[email protected]"] ], headers={"Authorization": f"Bearer {LUMAIL_API_KEY}", "Idempotency-Key": "report:2026-03"}, timeout=30, ) response.raise_for_status() ids = [email["id"] for email in response.json()["data"]]

Handle errors

Error responses are JSON with a stable name, a human message and the statusCode. Branch on name, log both, and show your user a generic message.

Retry connection errors, timeouts and 5xx responses. Do not retry 4xx responses: a validation error will fail the same way, and a 429 from the recipient guard means that mailbox has already received enough email for now.

Lumail send errors
Statuserror.nameWhat to do
401missing_api_keyMissing or invalid token. Check the env var on the server that sends.
403missing_permissionThe token lacks the emails permission. Mint one that has it.
402plan limitThe organization used its plan's email volume. Upgrade or wait for the next period.
4xxvalidation_errorMissing field, more than one body format, or a from domain that is not verified.
429RECIPIENT_RATE_LIMITEDThat mailbox hit 5 sends in 10 minutes or 20 in 24 hours. Honor Retry-After.
nonenetwork_error / timeoutThe request never got an answer. Safe to retry with the same idempotency key.

Make retries safe with idempotency

Send an Idempotency-Key header built from whatever triggered the email, such as welcome:<user id> or a webhook event id. Repeating the key returns the email already queued instead of sending another. Keys can be up to 256 characters.

This is what makes retries safe in Celery, RQ or any job runner that may run a task twice.

tasks.py
send_email( {"from": "Acme <[email protected]>", "to": customer_email, "subject": "Your receipt", "html": receipt_html}, idempotency_key=f"receipt:{invoice_id}", )

Verify your sending domain

Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.

Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.

Production checklist

  • Every request sets a timeout. Neither requests nor a bare httpx call should wait forever on a network problem.
  • Background jobs pass an idempotency key so a task that runs twice sends once.
  • The from address is on a domain verified in the same organization as the token, with SPF, DKIM and a DMARC record.
  • LUMAIL_API_KEY is set on every environment that sends, never committed, never in a public or client-side variable. Use one token per environment.
  • Every send that can be retried (webhooks, queues, jobs) passes an idempotency key derived from the triggering event.
  • One-time codes and magic links set tracking: { links: false, open: false } so links are not rewritten.
  • End-to-end tests send to fixture addresses such as [email protected], which return an id without sending real email.
  • A failed email is logged with error.name and never fails the user's request on its own.

Frequently asked questions

Is there a Lumail Python SDK?

Not today. The official SDK is the lumail package on npm. From Python, call the REST API directly with requests or httpx, which takes about twenty lines including error handling, as shown in this guide.

Should I use requests or httpx?

Use requests in synchronous code such as scripts, Django views and Flask routes. Use httpx.AsyncClient in async frameworks like FastAPI or Starlette so the call does not block the event loop.

Can I use smtplib instead of the API?

Yes. Lumail exposes an SMTP relay at smtp.lumail.io on port 587 with STARTTLS, any non-empty username and your API token as the password. The HTTP API is preferred because it returns clearer errors and supports idempotency keys.

How do I send Markdown instead of HTML from Python?

Pass a markdown field instead of html. Lumail renders it to HTML on send. Provide exactly one of html, markdown or tiptap in each request.

What does a successful response look like?

A 2xx response with a JSON body like {"id": "eml_abc123"}. The email is queued at that point. Fetch GET /api/v2/emails/{id} later to read its last delivery event.

How do I add a subscriber to my list from Python?

POST to /api/v2/subscribers with the same bearer token. It upserts by email and accepts tags and custom fields, which is how a signup in your app enters your marketing workflows.

Keep building

Send your first email from code today.

3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.