Mailcheer for Python
The official Python SDK for Mailcheer: transactional emails, subscribers, campaigns and signed webhooks — blocking or asyncio, fully typed.
- A blocking client and an
asyncioclient, with the same methods. - Typed responses (
TypedDict) and typed errors with a stablecode. - Safe automatic retries:
Retry-Afterrespected, and a send is only retried with an idempotency key. - Your monthly quota read from every response.
- Webhook signatures verified in one line.
pip install mailcheer
Send an email
Create a key under Settings → API in your workspace, then:
from mailcheer import Mailcheer
mailcheer = Mailcheer("mch_live_…") # or set MAILCHEER_API_KEY and call Mailcheer()
email = mailcheer.emails.send({
"from": "Acme <billing@acme.com>",
"to": "jane@example.com",
"subject": "Your September invoice",
"html": "<p>Here it is.</p>",
"text": "Here it is.",
})
print(email["id"]) # accepted (202), on its way
from must be on a domain verified in your workspace. mailcheer.me() lists your verified domains and senders.
Responses are plain dictionaries, shaped exactly like the API's JSON and typed for your editor.
Errors
A refusal raises a MailcheerError. Read error.code: it is stable and never translated.
from mailcheer import MailcheerError, QuotaExceededError
try:
mailcheer.emails.send(email)
except QuotaExceededError as error:
print("Quota reached; resets", error.details["resets_at"])
except MailcheerError as error:
print(error.code, error.message, error.status_code, error.details)
| Class | When |
|---|---|
AuthenticationError |
401 — missing, unknown or revoked key |
QuotaExceededError |
402 — quota_exceeded, free_plan_domain_used |
PermissionDeniedError |
403 — missing permission, sending blocked |
NotFoundError |
404 |
ConflictError |
409 — idempotency key reused with another body… |
ValidationError |
422 — faulty field, unverified domain, suppressed recipient |
SendingPausedError |
423 — workspace under review: send the same call later |
RateLimitError |
429 — retry_after tells how long to wait |
ServerError |
5xx |
MailcheerConnectionError |
no answer: network_error or timeout |
Messages are in English by default; Mailcheer(language="fr") asks for French.
Retries and idempotency
Pass an idempotency_key — an invoice number, an order id — and a send can be retried as often as needed: it goes out once. Mailcheer replays the first response for 24 hours.
mailcheer.emails.send(email, idempotency_key=f"invoice-{invoice.id}")
The SDK retries on its own (2 retries by default) after a network failure, a timeout, a 429 or a 5xx, waiting for Retry-After when the API gives it. It only retries a call that changes something when that call carries an idempotency key — or when the API refused it before reading it (rate_limit_exceeded). A retry never sends an email twice.
Your quota, on every response
mailcheer.emails.send(email)
mailcheer.last_quota # {"limit": 3000, "used": 2531, "remaining": 469, "reset_at": "2026-10-01T00:00:00.000Z"}
mailcheer.last_response # status, quota, rate_limit, idempotent_replay, retries
mailcheer.quota() # read it now, from GET /api/v1/me
limit and remaining are None on an unlimited plan.
A key with its own monthly limit (Settings → API) also reports it in mailcheer.last_key_quota (limit, used, remaining); beyond it a send raises QuotaExceededError with code == "key_quota_exceeded".
With a test key (mch_test_…), every call is checked as in production but nothing is sent and the quota is not touched: mailcheer.last_response.mode is "test", and mailcheer.me()["key"]["mode"] says so.
Subscribers and campaigns
mailcheer.subscribers.upsert({"email": "jane@example.com", "firstName": "Jane", "tags": ["customer"]})
for subscriber in mailcheer.subscribers.list_all(status="subscribed"):
print(subscriber["email"])
batch = mailcheer.subscribers.batch(contacts, idempotency_key="import-2026-09") # up to 500
changed = mailcheer.subscribers.list(updated_since=last_sync)
mailcheer.subscribers.update_tags("jane@example.com", add=["vip"], remove=["lead"])
mailcheer.tags.update_subscribers("vip", add=["joe@example.com"])
mailcheer.subscribers.erase("jane@example.com") # GDPR erasure, irreversible
draft = mailcheer.campaigns.create({"name": "October newsletter", "subject": "What's new", "text": "# Hello\n\nThree new things…"})
preview = mailcheer.campaigns.send(draft["id"], {"dry_run": True}) # checks everything, sends nothing
mailcheer.campaigns.send(draft["id"]) # irreversible
stats = mailcheer.campaigns.stats(draft["id"])
Also: subscribers.get(), subscribers.unsubscribe(), suppression.list(), suppression.add(), campaigns.update() (a draft), campaigns.list_recipients(), campaigns.preview_audience(to=[…]), tags.list(), tags.rename(), tags.delete(), and the webhooks resource.
asyncio
from mailcheer import AsyncMailcheer
async with AsyncMailcheer() as mailcheer:
email = await mailcheer.emails.send({...}, idempotency_key="invoice-42")
async for subscriber in mailcheer.subscribers.list_all():
...
Webhooks
Verify the signature on the raw body, before parsing:
# Flask
from mailcheer import WebhookSignatureError, construct_webhook_event, is_test_event
@app.post("/mailcheer")
def mailcheer_webhook():
try:
event = construct_webhook_event(
os.environ["MAILCHEER_WEBHOOK_SECRET"],
request.headers.get("Mailcheer-Signature"),
request.get_data(),
)
except WebhookSignatureError:
return "Invalid signature", 400
if is_test_event(event):
return "", 200
if event["type"] == "email.bounced":
mark_bounced(event["data"]["email"])
return "", 200
With Django, pass request.body; with FastAPI, await request.body(). A call signed more than five minutes ago is refused. event["id"] is the same on every attempt: use it to ignore duplicates. An event simulated for a test key carries event.get("test") is True, with the fields of a real event — is_test_event() only recognises the ping of the “Send a test” button.
Options
Mailcheer(
api_key,
base_url="https://mailcheer.com", # or MAILCHEER_BASE_URL
max_retries=2,
timeout=60.0, # per attempt, in seconds
max_retry_after=60.0, # the longest Retry-After waited for on its own
language="en", # "fr" for French messages
headers={}, # added to every request
http_client=None, # your own httpx.Client (proxy, transport…)
)
For a route not wrapped here, mailcheer.request("GET", "/api/v1/…", query=…, body=…) gives the same retries and errors.
Python 3.9 or later.
Links
License
MIT
Release files for mailcheer 0.2.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 | |
|---|---|---|---|
| mailcheer-0.2.0.tar.gz | 25.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mailcheer-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 49.6 kB
Release files / mailcheer-0.2.0.tar.gz
| Download URL | mailcheer-0.2.0.tar.gz |
|---|---|
| Size | 25.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8e1909ff7f8cf58618bdeab0c81bc3191218853003c42d390685bf4ba322f416
|
|
BLAKE2b-256 checksum How to use checksums |
ab49cfd4f7b6d2d9dc5e4494b40e9785d547b324bee3bf9038317e51b028e451
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / mailcheer-0.2.0-py3-none-any.whl
| Download URL | mailcheer-0.2.0-py3-none-any.whl |
|---|---|
| Size | 24.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5df056332162cda66eb8d16f77e28946c0838cf34fe5533d86605c51f2dd4589
|
|
BLAKE2b-256 checksum How to use checksums |
ad1d8c225657af1a9310fbf9d68659f2c523ecec7d2eea7cf444efe8ba52e37e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|