announcer-sdk
Python SDK for Announcer — send transactional email from your own domain, DKIM-signed.
Sync and async clients, full type hints, one dependency (httpx).
pip install announcer-sdk
Send an email
from announcer import Announcer
announcer = Announcer() # reads ANNOUNCER_API_KEY
announcer.send(
from_="Acme <billing@acme.com>",
to="customer@example.com",
subject="Your receipt",
text="Thanks for your order.",
html="<p>Thanks for your order.</p>",
)
That's the whole integration. from_ has a trailing underscore because from
is a Python keyword; if you already have a dict, splat it and use the plain
spelling:
announcer.send(**{"from": "billing@acme.com", "to": "customer@example.com", "text": "Hi"})
to, cc and bcc each take one address or a list, and reply_to sets the
reply address — see Several recipients.
Async
Same surface, awaited:
from announcer import AsyncAnnouncer
async with AsyncAnnouncer() as announcer:
await announcer.send(
from_="billing@acme.com",
to="customer@example.com",
subject="Your receipt",
text="Thanks!",
)
Before your first send
You need a registered, verified sending domain — Announcer will not let you send
From: a domain you have not proved you control.
domain = announcer.domains.create("acme.com")
for record in domain.dns:
print(record.type, record.name, record.value)
# TXT mail._domainkey.acme.com v=DKIM1; k=rsa; p=MIIBIjANBg...
# Publish that record, wait for DNS, then:
announcer.domains.verify(domain.id)
One TXT record is the entire ask. SPF and MX stay on Announcer's own bounce domain, so your root domain's DNS is untouched.
What the SDK does for you
Retries are safe. Every send carries an Idempotency-Key, generated per call
when you do not supply one. A timeout, a 500, or a 429 gets retried with
exponential backoff and jitter — and because the key travels with the retry, the
API recognises it as the same operation instead of sending twice.
Supply your own key to extend that guarantee across process restarts:
announcer.send(
from_="billing@acme.com",
to="customer@example.com",
subject="Your receipt",
text="Thanks!",
idempotency_key=f"receipt-{order.id}", # this order mails exactly once, ever
)
A replay tells you so rather than pretending it sent again:
result = announcer.send(..., idempotency_key="receipt-4711")
if result.idempotent_replay:
... # already sent earlier; nothing went out a second time
Errors are typed. Catch the case you can actually handle:
from announcer import SuppressedRecipientError, RateLimitError, PermissionDeniedError
try:
announcer.send(from_=sender, to=recipient, subject=subject, text=body)
except SuppressedRecipientError:
# They hard-bounced or complained before. Don't retry; mark them inactive.
deactivate(recipient)
except RateLimitError as exc:
print(f"Slow down for {exc.retry_after}s")
except PermissionDeniedError:
# Domain not registered, not verified, or this key is send-scoped.
...
The full set: ValidationError (with a per-field .errors dict),
AuthenticationError, PermissionDeniedError, NotFoundError,
ConflictError, UnprocessableEntityError, SuppressedRecipientError,
RateLimitError, InternalServerError, APIConnectionError,
APITimeoutError. All subclass AnnouncerError.
Results are dataclasses, not dicts. Timestamps arrive as datetime
objects, date fields as date. The API mixes camelCase and snake_case
depending on the endpoint; the SDK normalises everything to Python's
convention and derives the booleans you actually want (domain.verified,
key.revoked, endpoint.disabled). Free-form payload and detail dicts
pass through untouched — those keys are your data.
Several recipients
to, cc and bcc each take one address or a list. Everything in to and
cc is one email whose recipients see each other; bcc recipients see
nobody, not even each other:
announcer.send(
from_="billing@acme.com",
to=["customer@example.com", "partner@example.com"],
cc="accounting@acme.com",
bcc="archive@acme.com",
reply_to="support@acme.com",
subject="Your receipt",
text="Thanks!",
)
At most 50 addresses across the three. reply_to is a header only — it costs
nothing and cannot bounce.
Recipients are the billable unit. That call counts four against your quota,
not one. It is also what keeps monthly_hard_cap meaningful: otherwise a leaked
key could send fifty times your ceiling by padding the list.
One email, or many?
For anything list-shaped — a newsletter, a digest, a fan-out — you want
send_many, not a list:
results = announcer.emails.send_many(
["a@example.com", "b@example.com", "c@example.com"],
from_="news@acme.com",
subject="September update",
html=body,
)
for r in results:
if not r.ok:
print(f"{r.to} failed: {r.error}")
send(to=[a, b]) |
send_many([a, b], …) |
|
|---|---|---|
| Emails sent | one | two |
| Do they see each other? | yes, in To: |
no |
| API requests | one | two |
| Idempotency key | one | one each, derived |
| One address fails | the send reports it | the others are unaffected |
Suppressed recipients
A recipient on your suppression list is dropped and the rest still goes out:
result = announcer.send(
from_="billing@acme.com",
to=["good@example.com", "bounced-before@example.com"],
subject="Your receipt",
text="Thanks!",
)
result.recipients # 1 — what actually went out and what you were billed
result.suppressed # ['bounced-before@example.com']
SuppressedRecipientError is raised only when every recipient is suppressed
(or every to recipient — a message with no visible primary recipient is
refused rather than sent). Its .suppressed list names them all.
Webhooks
Register an endpoint, store the secret, verify every delivery:
endpoint = announcer.webhooks.create("https://acme.com/hooks/announcer")
print(endpoint.secret) # whsec_... — shown once, store it now
Flask:
import os
from flask import Flask, request
from announcer import verify_webhook, SignatureVerificationError
app = Flask(__name__)
@app.post("/hooks/announcer")
def announcer_webhook():
try:
event = verify_webhook(
request.get_data(), # raw bytes, NOT request.get_json()
request.headers.get("X-Announcer-Signature"),
os.environ["ANNOUNCER_WEBHOOK_SECRET"],
)
except SignatureVerificationError:
return "", 400
if event.event == "delivered":
mark_delivered(event.message.id)
elif event.event in ("bounced", "complained"):
deactivate(event.message.to)
return "", 200
Django is the same with request.body.
Verification checks the HMAC and the timestamp, rejecting anything more than
five minutes old so a captured delivery cannot be replayed at you. Tune it with
tolerance=.
Events: sent, delivered, bounced, complained, suppressed.
API reference
Client
Announcer(api_key=None, *, base_url=None, timeout=30.0, max_retries=2,
user_agent=None, headers=None, http_client=None)
AsyncAnnouncer(...) # same arguments
| Argument | Default |
|---|---|
api_key |
ANNOUNCER_API_KEY |
base_url |
ANNOUNCER_BASE_URL, then https://mail.misralo.com |
timeout |
30.0 seconds, per attempt |
max_retries |
2 extra attempts after a failure |
user_agent |
appended to the SDK's own — name your app |
headers |
added to every request |
http_client |
bring your own httpx.Client / httpx.AsyncClient |
Methods
| Call | Does |
|---|---|
announcer.send(**msg) |
Shorthand for emails.send. |
announcer.usage() |
Quota consumption plus a 14-day sending series. |
emails.send(**msg) |
Sends one email. to/cc/bcc take one address or many. |
emails.send_many(recipients, **msg) |
Separate emails, one per recipient. |
emails.list(limit=, status=, search=) |
Send history. |
emails.events(message_id) |
A message's audit trail. |
domains.create(domain) |
Registers a domain, returns the DNS record. |
domains.list() |
Every domain on the account. |
domains.dns(id) |
The records again, for a domain you already registered. |
domains.verify(id) |
Resolves DNS and checks the published key. |
domains.delete(id) |
Removes the domain and its signing key. |
api_keys.create(name, scope="full") |
Issues a key. Secret shown once. |
api_keys.list() |
Every key, without secrets. |
api_keys.revoke(id) |
Revokes a key; history survives. |
webhooks.create(url) |
Registers an endpoint. Max 2 active. |
webhooks.list() |
Every endpoint. |
webhooks.delete(id) |
Disables an endpoint. |
webhooks.verify(body, header, secret) |
Verifies a delivery. |
suppressions.list(limit=) |
Addresses that bounced or complained. |
domains.*, api_keys.* and webhooks.* need a full-scoped key. Everything
else works with a send key too — give integrations send.
Scopes
Mint a send-scoped key for anything that only sends mail:
key = announcer.api_keys.create("production-worker", "send")
A leaked send key cannot register domains, mint successor keys, or touch billing. It is the difference between an incident and a catastrophe.
Contributing
pip install -e ".[dev]"
pytest # no network; everything runs against httpx.MockTransport
mypy
License
MIT
Release files for announcer-sdk 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| announcer_sdk-0.1.1.tar.gz | 29.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| announcer_sdk-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 55.7 kB
Release files / announcer_sdk-0.1.1.tar.gz
| Download URL | announcer_sdk-0.1.1.tar.gz |
|---|---|
| Size | 29.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b7c80bce69c6210e56eae7c306ccdc99b5bbcf9d6273f5f323dde476ee8e9cec
|
|
BLAKE2b-256 checksum How to use checksums |
f439579989761e05e6cf9e4c3a3a0c536ad338fc276ed4340f5b73ec605d4863
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency logRelease files / announcer_sdk-0.1.1-py3-none-any.whl
| Download URL | announcer_sdk-0.1.1-py3-none-any.whl |
|---|---|
| Size | 26.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5e31e384875f0fe1e3eb57af01b10d15e8757077952c94e92a7c2f29886df299
|
|
BLAKE2b-256 checksum How to use checksums |
cbfe2140f33d4a71fc7cfcca5880b7fab5bd7e59137f488717c6f4879009a348
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency log