Skip to main content

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.

Attachments

Pass the file's bytes; the SDK does the base64.

from pathlib import Path
from announcer import Attachment

announcer.send(
    from_="billing@acme.com",
    to="customer@example.com",
    subject="Invoice 1042",
    text="Your invoice is attached.",
    attachments=[Attachment("invoice-1042.pdf", Path("invoice.pdf").read_bytes())],
)

At most 20 files and 10 MB per message, text and HTML included. Add a content_id to show an image inline with <img src="cid:logo">. Attachments are sent and never stored, and executables are refused.

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

Metadata

Release files for announcer-sdk 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for announcer-sdk 0.2.0
File Size Uploaded Provenance Actions
announcer_sdk-0.2.0.tar.gz 31.3 kB With provenance Details for announcer_sdk-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for announcer-sdk 0.2.0
File Interpreter ABI Platform Actions
announcer_sdk-0.2.0-py3-none-any.whl Python 3 none any Details for announcer_sdk-0.2.0-py3-none-any.whl

Total release size: 58.5 kB

Release files / announcer_sdk-0.2.0.tar.gz

Download URL announcer_sdk-0.2.0.tar.gz
Size 31.3 kB
Tags Source
SHA-256 checksum
How to use checksums
ce958f62debace9b29fe47628b7425f297a4cea9799dc764eb942bfaa3e3d805
BLAKE2b-256 checksum
How to use checksums
138dc150f7697dcef2f7445e01731009d99b8fb231e526dc4b94c8b0e6d98b29
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 Oct 5, 2026.

Transparency log

Release files / announcer_sdk-0.2.0-py3-none-any.whl

Download URL announcer_sdk-0.2.0-py3-none-any.whl
Size 27.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d27d86d95f9a4187c68160eb3501175102b5c4d9e797cbe8fb3836d22e33f8d9
BLAKE2b-256 checksum
How to use checksums
a37316490017031620c85a27b7dccf7406b9ec433f4de1faa61153c6993d2340
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 Oct 5, 2026.

Transparency log

Security for announcer-sdk 0.2.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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for announcer_sdk-0.2.0.tar.gz

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 Oct 5, 2026.

Transparency log

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.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page