avelto
The official Python SDK for Avelto, the email API for developers who want it to just work.
- Python 3.9+
- Sync and asyncio clients with the same surface
- Typed: every response is a plain dict with a
TypedDictdescribing its keys - One dependency,
httpx
Install
pip install avelto
Quickstart
import os
from avelto import Avelto
avelto = Avelto(os.environ["AVELTO_API_KEY"])
email = avelto.emails.send(
from_="Acme <hello@mail.acme.com>",
to="jane@example.com",
subject="Welcome to Acme",
html="<p>Thanks for signing up.</p>",
text="Thanks for signing up.",
)
print(email["id"]) # "5b3d…"
from_ is the sender, because from is a Python keyword. to, cc and bcc take one address or a list. Addresses can be a@b.com or Name <a@b.com>. Provide html, text, or both. You can also pass reply_to, headers, tags (up to 10), attachments (dicts with filename and base64 content or an http(s) url, up to 10 files and 7 MB in total), scheduled_at and unsubscribe_url. To send a stored template instead of a body, pass template_id or template_slug with variables and leave subject, html and text unset.
Every method returns the API's JSON as a dict, exactly as the reference documents it, so email["id"] rather than email.id.
asyncio
from avelto import AsyncAvelto
async with AsyncAvelto(os.environ["AVELTO_API_KEY"]) as avelto:
email = await avelto.emails.send(from_="hello@mail.acme.com", to="jane@example.com", subject="Hi", text="Hello")
Same options, same methods, each one awaited.
Options
avelto = Avelto(
api_key,
base_url="https://api.avelto.dev", # also read from AVELTO_BASE_URL
timeout=30.0, # per request, seconds
max_attempts=3, base_delay=0.3, max_delay=5.0, # the retry defaults
)
Avelto holds a connection pool. Use it as a context manager or call close() when you are done (aclose() on the async client).
Idempotency
Every emails.send carries an Idempotency-Key header: a fresh random UUID per call by default, so the SDK's own retries can never create two emails. Pass your own key (an order id, say) to make retries at your level safe too. A replay returns the id of the email that was already created and does not send again.
email = avelto.emails.send(
from_="hello@mail.acme.com", to="jane@example.com", subject="Receipt #1042", text="…",
idempotency_key="receipt-1042",
)
Retries
The SDK makes up to three attempts at a failed request (the call plus two retries) with exponential backoff and jitter; a 429 waits for the server's Retry-After instead. 429, 502 and 503 are retried for every request. Network errors, timeouts and 504 (where the request may have been processed) are retried only for GET, DELETE, emails.send, emails.send_batch and emails.validate, never for the other POSTs or for PATCH. Pass max_attempts=1 to turn retries off. After the last attempt the AveltoError is raised; on 429 it carries retry_after_seconds.
Scheduled send and cancel
scheduled_at is an ISO 8601 timestamp, in the future and at most 30 days ahead. A scheduled email can be cancelled until it is sent.
from datetime import datetime, timedelta, timezone
email = avelto.emails.send(
from_="hello@mail.acme.com",
to="jane@example.com",
subject="Your trial ends tomorrow",
text="…",
scheduled_at=(datetime.now(timezone.utc) + timedelta(days=1)).isoformat(),
)
cancelled = avelto.emails.cancel(email["id"]) # cancelled["status"] == "cancelled"
Cancelling an email that is no longer scheduled raises an AveltoError with code not_scheduled.
Validate an address
Ask whether an address is worth a send before you spend one on it, for example on every submit of a signup form. Nothing is sent or stored.
check = avelto.emails.validate("jane@gmial.com")
check["result"] # "deliverable" | "risky" | "undeliverable"
check["reason"] # None | "invalid_syntax" | "suppressed" | "no_mail_server" | "disposable" | "possible_typo" | "role_address"
check["suggestion"] # "jane@gmail.com"
check["normalized"] # the lower-cased address to store, or None when the syntax is invalid
check["checks"] # {"syntax", "mail_server", "disposable", "role", "free_provider", "suppressed"}
undeliverable means a send would fail or be refused: bad syntax, a domain with no mail server, or an address on your own suppression list. risky means it may well deliver but deserves a look: a throwaway domain, a role address such as info@, or a domain within a typo of a well-known one, in which case suggestion is the corrected address to offer the user. It cannot tell you whether the mailbox exists; nobody can without sending.
Batch send
emails.send_batch sends up to 100 messages in one call. Each message is a dict with the same keys as send ("from", or "from_" if you prefer to avoid the keyword in a dict literal too). Each is accepted or refused on its own, so read results rather than assuming the call either worked or did not; results[i] lines up with messages[i].
batch = avelto.emails.send_batch([
{"from": "billing@mail.acme.com", "to": "jane@example.com", "subject": "Receipt #1042", "text": "…"},
{"from": "billing@mail.acme.com", "to": "sam@example.com", "subject": "Receipt #1043", "text": "…"},
], idempotency_key="receipts-2026-09-24")
for r in batch["results"]:
if r["ok"]:
print(r["index"], r["id"])
else:
print(r["index"], r["error"]["code"], r["error"]["message"])
Fetch and list emails
email = avelto.emails.get(email_id)
print(email["status"]) # "queued" | "scheduled" | "sent" | "delivered" | "bounced" | "complained" | "failed" | "cancelled"
print([e["type"] for e in email["events"]]) # ["email.queued", "email.sent", "email.delivered"]
Lists are newest first and cursor-paginated. Filter by status, tag, mode and q, a substring search over recipient, subject and message id. A live key defaults to live and may ask for test; a test key only ever sees test emails, whatever mode says, and a live email is a 404 to it.
cursor = None
while True:
page = avelto.emails.list(status="bounced", tag="onboarding", limit=100, cursor=cursor)
for e in page["data"]:
print(e["id"], e["to"], e["subject"])
cursor = page["next_cursor"]
if not cursor:
break
Domains
Add a domain, publish the DNS records it returns, then poll get until it is verified. domains.get re-checks verification on every call.
import time
domain = avelto.domains.create("mail.acme.com")
for r in domain["dns_records"]:
print(r["type"], r["name"], r["value"], f"({r['purpose']})", sep="\t")
# Publish the records, then:
status = domain["status"]
while status == "pending":
time.sleep(30)
status = avelto.domains.get(domain["id"])["status"]
print(status) # "verified" (or "failed")
avelto.domains.list()
avelto.domains.delete(domain["id"])
Templates
tpl = avelto.templates.create(name="Welcome", subject="Hi {{name}}", html="<p>Welcome, {{name}}.</p>")
avelto.emails.send(from_="hello@mail.acme.com", to="jane@example.com", template_slug=tpl["slug"], variables={"name": "Jane"})
avelto.templates.update(tpl["id"], subject="Hello {{name}}") # saves a new version
avelto.templates.update(tpl["id"], text=None) # None clears a body part; leaving it out keeps it
versions = avelto.templates.versions(tpl["id"])["data"]
avelto.templates.restore(tpl["id"], versions[-1]["version"])
Webhooks
Create an endpoint. The signing secret is returned once, on creation. Store it.
endpoint = avelto.webhooks.create(
"https://acme.com/hooks/avelto",
events=["email.delivered", "email.bounced", "email.complained"], # optional; defaults to all eight
)
print(endpoint["secret"])
Every delivery is a JSON POST with an Avelto-Signature header of the form t=<unix seconds>,v1=<hex>. Verify it against the raw body before you trust the payload. Use the event id to de-duplicate.
Flask
import json, os
from flask import Flask, request, abort
from avelto import verify_webhook_signature
app = Flask(__name__)
@app.post("/hooks/avelto")
def avelto_hook():
body = request.get_data() # raw bytes, before any parsing
if not verify_webhook_signature(os.environ["AVELTO_WEBHOOK_SECRET"], body, request.headers.get("Avelto-Signature")):
abort(400)
event = json.loads(body)
if event["type"] == "email.bounced":
print("bounced", event["data"]["email_id"], event["data"]["details"])
return "", 200
Django
import json, os
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from avelto import verify_webhook_signature
@csrf_exempt
@require_POST
def avelto_hook(request):
if not verify_webhook_signature(os.environ["AVELTO_WEBHOOK_SECRET"], request.body, request.headers.get("Avelto-Signature")):
return HttpResponseBadRequest("invalid signature")
event = json.loads(request.body)
print(event["type"], event["data"]["email_id"])
return HttpResponse(status=200)
Signatures older than five minutes are rejected; pass tolerance_seconds= to change that.
Deliveries
endpoints = avelto.webhooks.list()["data"]
endpoint = avelto.webhooks.get(endpoint_id)
page = avelto.webhooks.list_deliveries(endpoint_id, limit=50)
failed = next((d for d in page["data"] if d["status"] == "failed"), None)
if failed:
avelto.webhooks.retry_delivery(endpoint_id, failed["id"])
avelto.webhooks.delete(endpoint_id)
Suppressions
Addresses that hard-bounce or complain are suppressed automatically and sends to them are rejected. You can manage the list yourself.
page = avelto.suppressions.list(limit=100)
for s in page["data"]:
print(s["email_address"], s["reason"]) # "bounce" | "complaint" | "manual"
avelto.suppressions.create("unsubscribed@example.com")
avelto.suppressions.delete("unsubscribed@example.com")
Account
account.get() returns the key's mode and scopes, the sandbox domain with every recipient a sandbox send may reach, and the domains, webhook endpoints and templates that exist. Read it first when wiring up a new project.
Error handling
Every failed request raises AveltoError with the HTTP status, the API code, the message, optional details, the request_id the API answered with, and on a 429 the retry_after_seconds. A request that got no response at all has status 0 and code network_error, with the underlying exception as __cause__; is_network_error is true for exactly those.
from avelto import AveltoError
try:
avelto.emails.send(from_="hello@mail.acme.com", to="jane@example.com", subject="Hi", text="Hi")
except AveltoError as err:
if err.code == "domain_not_verified": # 403: verify mail.acme.com first
...
elif err.code == "sandbox_recipient_not_allowed": # 403: the sandbox sender only delivers to your account's owner email
...
elif err.code == "recipient_suppressed": # 422: address is on your suppression list
...
elif err.code == "validation_error": # 400: err.details lists the failing fields
...
elif err.is_network_error: # status 0: no response (DNS, TLS, timeout)
...
else:
print(err.status, err.code, err.message, err.details)
| Code | Status | When |
|---|---|---|
validation_error |
400 | Request body or query failed validation. details lists the issues. |
unauthorized |
401 | Missing or invalid API key. |
forbidden |
403 | Key is not allowed to do this. |
not_found |
404 | No such email, domain, webhook or suppression. |
conflict |
409 | Resource already exists (for example the domain). |
not_scheduled |
409 | emails.cancel on an email that is not scheduled. |
sandbox_recipient_not_allowed |
403 | Sending from the sandbox domain to anyone but your account's owner email. |
domain_not_verified |
403 | from_ uses a domain that is not added and verified on your account. |
recipient_suppressed |
422 | A recipient is on your suppression list. |
plan_limit |
429 / 403 | Monthly or first-week sending cap reached (429), or the plan does not allow another domain (403). |
account_paused |
403 | Sending is paused on the account. Check the dashboard. |
account_suspended |
403 | The account is suspended; the message says why. Nothing sends until it is lifted. |
rate_limited |
429 | Too many requests for this key; honour retry_after_seconds. |
internal_error |
5xx | Something went wrong on our side. |
network_error |
0 | The request never got a response. err.__cause__ holds the underlying exception. |
Test mode
Keys starting with av_test_ never send real email. They run the full pipeline and emit the same events and webhooks, so you can build against them without touching an inbox.
Use these recipients on your sandbox domain to simulate outcomes, in both live and test mode:
delivered@<your sandbox domain>- deliveredbounced@<your sandbox domain>- hard bounce (never added to suppressions)complained@<your sandbox domain>- complaint (never added to suppressions)
Your sandbox domain is shown in the dashboard. Sending from it to any other address only works for your account's owner email; verify a domain to send to anyone.
Types
Every response shape is a TypedDict in avelto.types, so an editor knows the keys:
from avelto.types import Email, EmailSummary, Domain, WebhookEndpoint, WebhookPayload, Suppression, ValidateEmailResponse
License
MIT
Metadata
Release files for avelto 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| avelto-0.1.0.tar.gz | 19.5 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| avelto-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 44.5 kB
Release files / avelto-0.1.0.tar.gz
| Download URL | avelto-0.1.0.tar.gz |
|---|---|
| Size | 19.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
64e8aea463cb138da1153c78154564a1a4c899d1aae15451620a78607c54818d
|
|
BLAKE2b-256 checksum How to use checksums |
caf65716a2266107bc0c78d43c82c5b8f1234ccd676e5e2c2472899e8a46d061
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / avelto-0.1.0-py3-none-any.whl
| Download URL | avelto-0.1.0-py3-none-any.whl |
|---|---|
| Size | 24.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6ba5c7d1a2218147e4dafb6ca36b1e0eeeb313e9a42cb50b78ecb4025d70fe7b
|
|
BLAKE2b-256 checksum How to use checksums |
a0ed6afa43c09da6fae8a7b1fe478a3aef55bf851071d4d935f7eeb70d65c5e3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for avelto 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.