Send email with FastAPI by calling Volanea’s REST API from an async route handler. This guide provides a complete FastAPI application you can copy, run locally, and adapt for receipts, password resets, invitations, alerts, and other transactional messages.

This integration uses the standard HTTP interface rather than a fictional FastAPI-specific email SDK. FastAPI handles your application endpoint, httpx makes the outbound HTTPS request, and Volanea receives a JSON message at its send endpoint. That keeps the integration explicit: you can inspect the request, control timeouts, return useful application errors, and replace the sending logic without coupling the rest of your service to a provider package.

What you will build

The finished example exposes a POST /send-welcome-email endpoint. When your application calls it with a recipient address and name, FastAPI validates the incoming JSON, builds an HTML and plain-text transactional email, and sends it to Volanea with an API key loaded from an environment variable.

The sample intentionally uses one message and one route. That is the right starting point for a transactional flow because it makes the unit of work clear: a user action happens, your backend decides an email is appropriate, then it submits a single message to the provider. Bulk notifications, campaign sends, and scheduled workflows should be designed separately so their retry, consent, and monitoring behavior cannot accidentally affect critical transactional messages.

Before starting, make sure you have:

  • A Volanea account and an API key.
  • A sending domain or sender address that is configured for your account.
  • Python 3.10 or later.
  • A FastAPI application, or a new directory where you can create one.
  • A safe recipient address you control for testing.

The Volanea send endpoint is POST https://api.volanea.com/v1/send. It accepts JSON, and the request must include an authorization header. Keep the API key on the server only; do not expose it in browser JavaScript, mobile apps, public repositories, screenshots, or client-facing configuration.

Install FastAPI and the HTTP client

Install FastAPI with its standard server dependencies and install httpx for asynchronous HTTP requests:

pip install "fastapi[standard]" httpx

fastapi[standard] gives you the FastAPI command-line tooling used in this guide, while httpx provides AsyncClient for non-blocking requests to Volanea. An asynchronous client is a good fit for a FastAPI async def route because the application can wait for the remote API response without using a synchronous HTTP call inside the event loop.

Create a project folder and a file named main.py:

mkdir volanea-fastapi
cd volanea-fastapi
touch main.py

You can use a virtual environment if your project does not already have one:

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" httpx

On Windows PowerShell, activate it with:

.venv\Scripts\Activate.ps1

Do not add API keys to source code just to make a local test convenient. Environment variables are a safer default because they separate deploy-time secrets from the application artifact. Your deployment platform’s secret manager, CI environment, container runtime, or local shell can provide the values without committing them to Git.

Configure Volanea environment variables

Set these variables before running the application:

  • VOLANEA_API_KEY: your Volanea secret API key.
  • VOLANEA_FROM_EMAIL: an approved sender address, such as notifications@example.com.

For macOS or Linux:

export VOLANEA_API_KEY="sk_test_replace_with_your_key"
export VOLANEA_FROM_EMAIL="notifications@example.com"

For Windows PowerShell:

$env:VOLANEA_API_KEY="sk_test_replace_with_your_key"
$env:VOLANEA_FROM_EMAIL="notifications@example.com"

Use a test key while developing when one is available for your account. Test credentials are useful because they let you verify that your application constructs a valid request without risking a real send to an unintended recipient. When you move to production, configure the live key only in your production environment and rotate it if it is ever exposed.

The sender address matters as much as the API key. A request can be technically valid but still be rejected if the from address is not authorized for the account or does not belong to a verified sending domain. Use a stable, recognizable sender such as notifications@, receipts@, or support@ rather than changing the sender address dynamically for every user or tenant.

Complete FastAPI email sending example

Paste the following code into main.py. It is a complete FastAPI application. It validates the incoming request, loads secrets at startup, uses httpx.AsyncClient to send JSON to Volanea, handles common upstream failures, and returns the Volanea response body to the caller.

import os
from contextlib import asynccontextmanager
from typing import Any

import httpx
from fastapi import FastAPI, HTTPException, Request, status
from pydantic import BaseModel, EmailStr, Field

VOLANEA_SEND_URL = "https://api.volanea.com/v1/send"


class WelcomeEmailRequest(BaseModel):
    email: EmailStr
    name: str = Field(min_length=1, max_length=100)


class SendEmailResponse(BaseModel):
    message: str
    provider_response: dict[str, Any]


def get_required_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value


@asynccontextmanager
async def lifespan(app: FastAPI):
    api_key = get_required_env("VOLANEA_API_KEY")
    from_email = get_required_env("VOLANEA_FROM_EMAIL")

    app.state.volanea_api_key = api_key
    app.state.volanea_from_email = from_email
    app.state.http_client = httpx.AsyncClient(timeout=httpx.Timeout(15.0))

    yield

    await app.state.http_client.aclose()


app = FastAPI(
    title="Volanea FastAPI email example",
    lifespan=lifespan,
)


@app.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}


@app.post(
    "/send-welcome-email",
    response_model=SendEmailResponse,
    status_code=status.HTTP_202_ACCEPTED,
)
async def send_welcome_email(
    payload: WelcomeEmailRequest,
    request: Request,
) -> SendEmailResponse:
    first_name = payload.name.strip()

    email_payload = {
        "from": request.app.state.volanea_from_email,
        "to": payload.email,
        "subject": f"Welcome, {first_name}",
        "text": (
            f"Hi {first_name},\n\n"
            "Welcome! Your account is ready to use.\n\n"
            "If you did not expect this email, you can ignore it."
        ),
        "html": f"""
        <!doctype html>
        <html lang="en">
          <body>
            <h1>Welcome, {first_name}</h1>
            <p>Your account is ready to use.</p>
            <p>If you did not expect this email, you can ignore it.</p>
          </body>
        </html>
        """,
    }

    headers = {
        "Authorization": f"Bearer {request.app.state.volanea_api_key}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

    try:
        response = await request.app.state.http_client.post(
            VOLANEA_SEND_URL,
            headers=headers,
            json=email_payload,
        )
    except httpx.TimeoutException as exc:
        raise HTTPException(
            status_code=status.HTTP_504_GATEWAY_TIMEOUT,
            detail="Timed out while submitting the email to Volanea.",
        ) from exc
    except httpx.RequestError as exc:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Could not connect to Volanea.",
        ) from exc

    try:
        response_body = response.json()
    except ValueError:
        response_body = {"raw_response": response.text}

    if response.status_code in (401, 403):
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail={
                "message": "Volanea rejected the API credentials or sender authorization.",
                "provider_response": response_body,
            },
        )

    if response.status_code == 429:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail={
                "message": "Volanea rate-limited the email request. Retry later.",
                "provider_response": response_body,
            },
        )

    if response.is_error:
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail={
                "message": "Volanea did not accept the email request.",
                "provider_status": response.status_code,
                "provider_response": response_body,
            },
        )

    return SendEmailResponse(
        message="Email accepted by Volanea.",
        provider_response=response_body,
    )

This sample uses both text and html content. The plain-text part is not merely a fallback for older mail clients. It also gives recipients who prefer text-only messages a readable version, makes the content easier to inspect in logs and tests, and reduces the chance that a malformed HTML template produces an empty-looking message.

The route returns 202 Accepted after Volanea accepts the request. That response means your FastAPI application successfully handed the message to the email provider; it should not be interpreted as proof that the recipient has opened the message or that it has reached an inbox. Delivery is an asynchronous process involving receiving servers, suppression rules, mailbox-provider decisions, and potentially retries. Use delivery events and webhooks for the final lifecycle state.

Run the application and send a test message

Start the local server:

fastapi dev main.py

FastAPI will serve the application locally. Open the interactive API documentation at the address printed in your terminal, or use curl to submit a request directly.

First, confirm the service is running:

curl http://127.0.0.1:8000/health

Expected response:

{"status":"ok"}

Then submit a welcome email request. Replace the recipient with an inbox you control:

curl -X POST "http://127.0.0.1:8000/send-welcome-email" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "you@example.com",
    "name": "Avery"
  }'

If Volanea accepts the request, FastAPI returns a JSON object containing a confirmation message and the provider response. Preserve the provider response in your structured logs when you move beyond a demo. It can contain identifiers or status information that helps correlate your application action with later delivery events.

Do not use the local endpoint as an unrestricted public email relay. In a real application, authenticate the caller, authorize the action, determine the recipient on the server, and restrict which message types can be sent. For example, a password-reset endpoint should derive the recipient from the account being recovered rather than accepting an arbitrary destination address from an unauthenticated request.

How the request is constructed

The important part of the integration is the email_payload dictionary passed through json=email_payload. httpx serializes that dictionary to JSON, and the Content-Type: application/json header tells the API how to parse it.

The basic transactional message fields are:

FieldPurposeExample
fromThe authorized sender address.notifications@example.com
toThe destination recipient.you@example.com
subjectThe recipient-visible subject line.Welcome, Avery
textPlain-text message content.Welcome! Your account is ready.
htmlHTML message content.<h1>Welcome</h1>

Keep business logic separate from message transport logic as the project grows. A route handler may decide that an order was paid, but a dedicated email function should be responsible for building the message and submitting it. That makes it easier to test the order flow without sending messages and to reuse the same send function for background jobs, command-line tasks, or event consumers.

A small refactor might look like this conceptually:

async def send_transactional_email(
    client: httpx.AsyncClient,
    api_key: str,
    message: dict[str, str],
) -> dict[str, Any]:
    response = await client.post(
        "https://api.volanea.com/v1/send",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
        json=message,
    )
    response.raise_for_status()
    return response.json()

Use this pattern when multiple routes need email. Do not instantiate a new AsyncClient for every request if you can avoid it. The complete application creates one client during the FastAPI lifespan and closes it during shutdown, which allows connection reuse and provides a clear place to configure shared timeout behavior.

Use async correctly in FastAPI

The route in the complete example is declared with async def because it awaits an asynchronous outbound HTTP request:

@app.post("/send-welcome-email")
async def send_welcome_email(...):
    response = await request.app.state.http_client.post(...)

The await is essential. Without it, you have a coroutine object rather than an HTTP response. Trying to read status_code, call .json(), or return that coroutine will lead to confusing runtime failures. await is also only valid inside an async def function.

Avoid using the synchronous requests library directly inside an async def route for outbound network calls. A synchronous request blocks the worker while it waits for the remote service. httpx.AsyncClient is designed for the asynchronous style used here, so the server can continue scheduling other work while the email API request is in flight.

There are two legitimate alternatives when designing a production flow:

  1. Send inline, as this guide does. Use this when the user should immediately learn whether the provider accepted the message, such as a manually triggered account email or a small administrative action.
  2. Queue the intent to send. Use this when a user-facing request should complete quickly, when delivery work may be retried independently, or when sending is triggered by background business events. Store an idempotent job or outbox record first, then let a worker submit the email.

The second approach adds operational complexity, but it can make failures safer. If a database transaction succeeds and the API call fails, an outbox record gives your system a durable item to retry. Without that design, you can end up with business state that says an email should have been sent but no durable record of the attempt.

Make transactional sends safe in production

A copy-pasteable send call is useful, but production email requires a few additional boundaries. The most important rule is to connect an email to a meaningful business event, not simply to an HTTP request that an untrusted client can invoke.

Validate application input

The example uses Pydantic’s EmailStr to reject malformed recipient addresses at your API boundary. That is useful validation, but it does not prove the inbox exists, can receive mail, or belongs to the intended user. For signup and lead-capture flows where address quality matters, use an email address verification tool before treating an address as a reliable contact method.

Also validate values interpolated into HTML. The simple demo inserts name into an HTML string to keep the example short. In a production template, escape user-controlled values before inserting them into markup, or use a templating system with automatic escaping. Email content is still HTML rendered in a client, so user input should never be treated as trusted markup.

Protect secrets and sender identity

Use a separate API key for each environment when possible. Development, staging, and production should not share a credential because a mistake in staging should not grant access to production sending. Store keys in a secret manager or environment configuration, restrict access to the smallest set of people and services, and rotate a key promptly if it appears in logs or source control.

Keep VOLANEA_FROM_EMAIL in configuration rather than accepting the sender address from a request body. If any API consumer can select the from value, they can cause failed sends, confuse recipients, or attempt to impersonate an address outside your approved sender policy.

Use deliberate retry behavior

Transient connection problems and server errors can justify retries, but retries can also produce duplicate messages. A recipient who receives two password-reset messages or two receipts may lose trust in the application even if both messages are technically valid.

Before adding automatic retries, answer these questions:

  • Can your application determine whether the first request was accepted before the connection failed?
  • Does the email API offer a documented idempotency mechanism for this request type?
  • Can you store a durable unique event ID for the business event?
  • Is a duplicate message acceptable, or should the event be manually reviewed instead?

For a receipt, use a stable order ID in your own email-outbox record and only schedule one notification per order event. For a password reset, generate and store the reset token before sending so a retry does not create multiple unrelated tokens. The application should be able to answer which business event produced a given send attempt.

Log safely

Log request outcomes, response status codes, message identifiers returned by the provider, and your own business event ID. Avoid logging API keys, authorization headers, password-reset URLs, full message bodies containing sensitive data, or more recipient data than your retention policy permits.

A useful structured log record might include:

{
  "event": "transactional_email_submitted",
  "email_type": "welcome",
  "user_id": "usr_123",
  "provider_status": 202,
  "provider_message_id": "provider-returned-id"
}

The exact shape of a provider response can vary, so capture only documented fields after you confirm them in your integration. Do not build monitoring around an assumed response field name. The Volanea API reference and setup guides should be your source of truth for endpoint-specific request and response details.

Common errors when sending email with FastAPI

401 Unauthorized or 403 Forbidden

These responses usually mean the API key is missing, malformed, revoked, from the wrong environment, or not authorized for the requested action. Confirm that VOLANEA_API_KEY is set in the same shell, container, or deployment environment that starts FastAPI.

Also inspect the authorization format. The sample sends:

"Authorization": f"Bearer {api_key}"

Do not accidentally send the literal placeholder string, include extra quote characters, or use a browser-side key. If credentials look correct, check whether the configured sender address belongs to a domain authorized for the account.

415 Unsupported Media Type or a JSON parsing error

Volanea expects JSON for this REST request. Send the payload through json=email_payload, not data=email_payload, and include:

"Content-Type": "application/json"

When testing your own FastAPI route with curl, also include -H "Content-Type: application/json". If you omit it, FastAPI may not parse the incoming body as the Pydantic model you expect, even before your application reaches the Volanea request.

422 Unprocessable Entity from your FastAPI endpoint

A 422 response generally means the client calling /send-welcome-email did not match the WelcomeEmailRequest model. The body must contain a valid email and a non-empty name:

{
  "email": "you@example.com",
  "name": "Avery"
}

Do not confuse this with an upstream Volanea validation error. A FastAPI 422 means your local endpoint rejected the request before attempting to send. Review the response’s validation details to identify the missing or invalid field.

RuntimeWarning: coroutine was never awaited

This occurs when asynchronous code is called without await. The fix is to await the httpx request inside an async def function:

response = await client.post(...)

Do not remove async merely to silence the warning. If you use AsyncClient, the call remains asynchronous and must be awaited. If a function cannot be asynchronous, redesign the boundary or use an appropriate synchronous client deliberately rather than mixing the two styles accidentally.

RuntimeError: Missing required environment variable

The example raises this during application startup when VOLANEA_API_KEY or VOLANEA_FROM_EMAIL is absent. This is intentional: failing at startup is safer than accepting live traffic with an incomplete configuration and discovering the problem only after a user triggers an important email.

Set the variables before running fastapi dev main.py. If you use Docker, Kubernetes, a process manager, or a cloud deployment, configure the values in that platform rather than relying on the variables from your laptop shell.

Timeout or connection errors

A timeout does not always mean Volanea rejected the message. It means your application did not receive a complete response within the configured time. The original request may or may not have reached the provider, which is why blind retries can create duplicates.

Start with a finite timeout, log the failure with your business event ID, and decide whether the workflow can safely retry. Investigate DNS, outbound firewall rules, proxy configuration, and the provider’s status information before increasing timeouts indefinitely.

The request succeeds but no email appears in the inbox

An accepted API request is not equivalent to inbox placement. First check the recipient’s spam, promotions, and quarantine areas. Then confirm that the sending domain and sender address are correctly configured, that the recipient is not suppressed, and that the message content is appropriate for the transactional action.

Use delivery events to distinguish between an API submission, a provider acceptance, a receiving-server delivery event, and recipient engagement. Treat those as separate states in dashboards and incident reports. A message can be accepted by an API but later bounce, be rejected by a receiving server, or be filtered by a mailbox provider.

Testing the integration without sending surprises

Test the integration at more than one level. A successful manual request proves the basic plumbing, but it does not prove your application sends the right email only once, under the right conditions, to the right recipient.

Unit-test message construction

Move message construction into a small function, pass it known input, and assert on the returned dictionary. Test the subject, sender, recipient, text content, and HTML content separately. This catches regressions such as a missing order number, an unescaped name, or an accidental change to a sender address without making a network call.

Test the route with a mocked HTTP transport

httpx supports mock transports that let tests return a controlled upstream response. This allows you to verify that the route sends Authorization, Content-Type, and the expected JSON without storing a real API key in a test suite. Also test timeout, authentication failure, rate-limit, and malformed-provider-response branches so your application returns predictable errors during incidents.

Use an owned inbox for end-to-end tests

For the final integration test, send to an inbox your team controls. Confirm the visible sender, subject, plain-text fallback, HTML rendering, reply behavior, and links. If the email represents an account or order event, confirm that a duplicate action does not produce duplicate messages unless that is the intended behavior.

Avoid repeatedly sending test mail to real customer addresses. Transactional email testing should have an explicit test recipient policy, especially when developers, support agents, and automated test environments all have access to the same production-like data.

Next steps: webhooks and templates

Once the first send works, add webhooks so your application can receive lifecycle events after submission. A webhook endpoint should verify the provider’s documented signature, parse the event, record an idempotent event identifier, and return a fast success response. Process expensive work after receipt rather than holding the webhook connection open while you update multiple systems.

Webhooks are especially useful for tracking delivery, bounces, complaints, and other events that should affect your application. For example, a hard bounce may require marking an address as unreachable, while a complaint should stop future non-essential mail according to your sending policy. Never assume a send request alone is the final state of a message.

Templates are the next useful step when the same content structure is used repeatedly. A template lets you manage reusable markup and variables separately from FastAPI route code, which reduces duplication across welcome messages, receipts, and account alerts. Keep ownership clear: application code should provide trusted data and choose the correct template, while content changes should be reviewed with the same care as application changes.

When you introduce templates, preserve a plain-text alternative and test rendering with realistic data. Include edge cases such as long names, missing optional fields, international characters, and values that contain punctuation. Template systems reduce repetition, but they can also hide mistakes until a real message is rendered.

Operational checklist for a production FastAPI sender

Use this checklist before relying on the endpoint for customer-facing email:

  1. Store VOLANEA_API_KEY in a secret manager or deployment environment, never in the repository.
  2. Configure VOLANEA_FROM_EMAIL as an approved sender on a domain your organization controls.
  3. Validate caller authorization before allowing a route to trigger an email.
  4. Derive sensitive recipients and email content on the server instead of trusting browser input.
  5. Send both text and html parts for important transactional messages.
  6. Set a finite HTTP timeout and log upstream errors with a business event ID.
  7. Design retries to avoid duplicate sends, ideally with durable event or outbox records.
  8. Test authentication errors, validation failures, timeouts, and rate limits.
  9. Add webhook processing for downstream delivery and suppression outcomes.
  10. Monitor send acceptance separately from delivery and inbox outcomes.

The minimal FastAPI route in this guide is intentionally straightforward, but these controls are what turn a successful API call into a reliable email capability. Start with the working endpoint, then add safeguards in the order that matches the risk of the messages you send. Password resets, login alerts, receipts, and billing notices deserve stronger authorization, auditing, and retry discipline than a low-risk internal notification.

FAQ

Can I use a Volanea SDK with FastAPI?

This guide uses Volanea’s REST API directly with httpx, so it does not depend on a FastAPI-specific SDK. Direct REST usage is a good option when you want explicit control over HTTP headers, timeouts, errors, logging, and dependency management.

Why does the example use httpx.AsyncClient instead of requests?

The route is asynchronous, and httpx.AsyncClient can be awaited from async def. That avoids placing a synchronous outbound network request directly in the async route handler and fits naturally with FastAPI’s concurrency model.

Should I send email directly inside my FastAPI route?

For a simple transactional action, sending inline is reasonable. For high-volume, retry-sensitive, or business-critical workflows, persist an email job or outbox event and let a background worker submit it so the send can be retried and audited independently.

Does a successful send response mean the recipient received the email?

No. It means the provider accepted the API request. Delivery, bounce, complaint, and engagement outcomes happen later and should be tracked through the provider’s documented event and webhook mechanisms.

Can I put the Volanea API key in a frontend application?

No. Treat the key as a server-side secret. Send email from FastAPI or another trusted backend service so browser users and mobile clients never receive the credential.