For developers
API documentation
The short version
Register a domain, publish the one DNS record we hand back, and POST your message. No SDK required — if your language can make an HTTPS request, it can send. For Node.js, Python, Go and .NET there are official SDKs as well, and software that only speaks SMTP can send over SMTP with an API key as its password.
This page covers the surface you integrate against. The session and billing endpoints the dashboard uses are not a public contract and are left out on purpose.
SDKs
Everything on this page works over plain HTTPS, and the examples use
curl. If you would rather not write the HTTP yourself, there
are official SDKs for four languages. Every send carries an idempotency key, so a
retry after a timeout or a 5xx never sends twice. Each error arrives as a typed
exception with its detail and request ID. Field names follow
the language's own conventions, and webhook signatures are checked for you. All four
are MIT-licensed.
| Language | Install | Package | Source |
|---|---|---|---|
| Node.js 18+ | npm install announcer-sdk |
npm | GitHub |
| Python 3.9+ | pip install announcer-sdk |
PyPI | GitHub |
| Go 1.21+ | go get github.com/ZeldaIV/announcer-go |
pkg.go.dev | GitHub |
| .NET 8+ | dotnet add package Announcer |
NuGet | GitHub |
Each one reads your key from the ANNOUNCER_API_KEY environment
variable when you do not pass it in. Every example on this page has a tab for each of
them; pick your language once and the rest of the page follows.
Each README on GitHub covers the rest: registering a domain, several recipients, attachments, catching specific errors, and verifying webhooks.
Authenticating
Every request carries an API key as a bearer token. Issue one in the dashboard, or
with POST /v1/keys.
curl https://mail.misralo.com/v1/messages \ -H "Authorization: Bearer $ANNOUNCER_KEY"
import { Announcer } from 'announcer-sdk';
// Reads ANNOUNCER_API_KEY when no key is passed.
const announcer = new Announcer();
const messages = await announcer.emails.list();
from announcer import Announcer
# Reads ANNOUNCER_API_KEY when no key is passed.
announcer = Announcer()
messages = announcer.emails.list()
import "github.com/ZeldaIV/announcer-go"
// An empty key reads ANNOUNCER_API_KEY.
client, err := announcer.New("")
if err != nil {
log.Fatal(err)
}
messages, err := client.Emails.List(ctx, nil)
using Announcer;
// Reads ANNOUNCER_API_KEY when ApiKey is left unset.
var announcer = new AnnouncerClient(new AnnouncerOptions());
var messages = await announcer.Emails.ListAsync();
Keys carry a scope. A full key can do
everything; a send key can only send mail and read its own
messages. Give an integration the narrowest scope that works — a send key sitting in
someone's CI cannot then mint successors, register domains, or open your billing.
The full key is returned exactly once, when it is created. Only its hash is stored, so
we cannot show it to you again; the prefix is the visible
handle for identifying it later.
A command-line tool or an agent can get its own key without ever holding a password:
POST /v1/auth/device returns a short code, whoever owns the
account approves it in the dashboard, and the caller polls
/v1/auth/device/token until the key comes back. That is the
RFC 8628 device grant, the same
ceremony gh auth login uses. The whole procedure, written for
agents, is at auth.md.
The same surface is available over MCP
at https://mail.misralo.com/mcp — Streamable HTTP, protocol
revision 2026-07-28 or any 2025 revision from 2025-03-26 on, authenticated with the same bearer key. An MCP client with no key
signs in on its own: it follows the 401 to the OAuth metadata, registers, and sends you
to the dashboard to approve it — the authorization code grant with PKCE, which is what
every MCP client speaks. The server card is at
/.well-known/mcp/server-card.json.
Which tools it offers follows the key's scope, and there is no tool for issuing
keys, deleting a domain, or billing: MCP grants nothing the key does not already
grant, and a model holding one should not be a prompt away from minting its
successor.
The same nine operations are also an
A2A agent at
https://mail.misralo.com/a2a, for one agent delegating work
to another, with its card at
/.well-known/agent-card.json. It runs no
model: send a data part naming a skill and its arguments and it answers with a
completed task. Plain prose comes back rejected rather than guessed at.
Registering a domain
We generate a signing key that exists for your domain and nothing else. The private half never leaves our servers.
Register the domain, publish the record the response hands back in its
dns array, then ask us to check it:
# Returns the DNS record to publish. curl -X POST https://mail.misralo.com/v1/domains \ -H "Authorization: Bearer $ANNOUNCER_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "your-company.com"}' # Once the record is published: curl -X POST https://mail.misralo.com/v1/domains/$ID/verify \ -H "Authorization: Bearer $ANNOUNCER_KEY"
const domain = await announcer.domains.create('your-company.com');
for (const record of domain.dns) {
console.log(`${record.type} ${record.name} ${record.value}`);
}
// Once the record is published:
await announcer.domains.verify(domain.id);
domain = announcer.domains.create("your-company.com")
for record in domain.dns:
print(record.type, record.name, record.value)
# Once the record is published:
announcer.domains.verify(domain.id)
domain, err := client.Domains.Create(ctx, "your-company.com")
if err != nil {
log.Fatal(err)
}
for _, r := range domain.DNS {
fmt.Printf("%s %s %s\n", r.Type, r.Name, r.Value)
}
// Once the record is published:
_, err = client.Domains.Verify(ctx, domain.ID)
var domain = await announcer.Domains.CreateAsync("your-company.com");
foreach (var record in domain.Dns)
{
Console.WriteLine($"{record.Type} {record.Name} {record.Value}");
}
// Once the record is published:
await announcer.Domains.VerifyAsync(domain.Id);
Verification resolves <selector>._domainkey.<domain>
and compares what is published to the key we issued. It is recorded only on a real
match, and until it matches we refuse to send rather than quietly letting unsigned
mail out under your name. Lost the record? GET
/v1/domains/{id}/dns returns it again.
Sending a message
curl -X POST https://mail.misralo.com/v1/emails \
-H "Authorization: Bearer $ANNOUNCER_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "billing@your-company.com",
"to": "customer@example.net",
"subject": "Your receipt",
"text": "Thanks for your order."
}'
await announcer.send({
from: 'billing@your-company.com',
to: 'customer@example.net',
subject: 'Your receipt',
text: 'Thanks for your order.',
});
announcer.send(
from_="billing@your-company.com",
to="customer@example.net",
subject="Your receipt",
text="Thanks for your order.",
)
_, err = client.Send(ctx, &announcer.SendEmailRequest{
From: "billing@your-company.com",
To: announcer.Address("customer@example.net"),
Subject: "Your receipt",
Text: "Thanks for your order.",
})
await announcer.SendAsync(new SendEmailRequest
{
From = "billing@your-company.com",
To = "customer@example.net",
Subject = "Your receipt",
Text = "Thanks for your order.",
});
to, cc and
bcc each accept a single address or an array, and any address
may carry a display name — "Acme Billing <billing@your-company.com>".
reply_to (or replyTo; both are
accepted) is a header and nothing else: no envelope recipient, nothing billable,
nothing that can bounce. At least one of text or
html is required, and at most 50 addresses across the three
recipient fields combined.
One message is one envelope with a RCPT TO per address, so
to and cc recipients see each other
and the bcc sees nobody. For a newsletter, loop over the
endpoint instead: one request per recipient gives each its own idempotency key, its
own bounce, and no shared visibility.
Attachments
"attachments": [
{ "filename": "invoice-1042.pdf", "content": "JVBERi0xLjcK..." },
{ "filename": "logo.png", "content": "iVBORw0KGgo...", "content_id": "logo" }
]
import { readFile } from 'node:fs/promises';
await announcer.send({
from: 'billing@your-company.com',
to: 'customer@example.net',
subject: 'Invoice 1042',
text: 'Your invoice is attached.',
attachments: [
{ filename: 'invoice-1042.pdf', content: await readFile('invoice-1042.pdf') },
],
});
from pathlib import Path
from announcer import Attachment
announcer.send(
from_="billing@your-company.com",
to="customer@example.net",
subject="Invoice 1042",
text="Your invoice is attached.",
attachments=[Attachment("invoice-1042.pdf", Path("invoice-1042.pdf").read_bytes())],
)
pdf, err := os.ReadFile("invoice-1042.pdf")
if err != nil {
log.Fatal(err)
}
_, err = client.Send(ctx, &announcer.SendEmailRequest{
From: "billing@your-company.com",
To: announcer.Address("customer@example.net"),
Subject: "Invoice 1042",
Text: "Your invoice is attached.",
Attachments: []announcer.Attachment{{Filename: "invoice-1042.pdf", Content: pdf}},
})
await announcer.SendAsync(new SendEmailRequest
{
From = "billing@your-company.com",
To = "customer@example.net",
Subject = "Invoice 1042",
Text = "Your invoice is attached.",
Attachments = [new Attachment("invoice-1042.pdf", await File.ReadAllBytesAsync("invoice-1042.pdf"))],
});
content is the file's bytes, base64-encoded.
content_type is optional and guessed from the filename when
left out. An attachment with a content_id is an inline image:
the HTML shows it with <img src="cid:logo">, and mail
clients do not list it as a download. The field names are Resend's, and the camelCase
spellings contentType and
contentId are accepted too. The SDKs take the file's bytes
as each language holds them and do the base64 for you, from version 0.2.0.
At most 20 attachments, and 10 MB per message: text, html and every
attachment together, before encoding. Executable files Gmail refuses —
.exe, .js,
.bat, .scr and the rest of its list —
are refused here too, rather than accepted and bounced. There is no
path for fetching a file from a URL: read it and send the
bytes. Attachments are not stored, and neither are their names; the message keeps an
attachment_count and nothing else about them.
Many receiving servers refuse messages over 10 to 25 MB, so a large file is better sent as a link.
Not sending twice
Pass an Idempotency-Key header and the message row is
reserved before injection, so a retry — even a concurrent one — returns the original
message rather than sending again. The replayed response carries
idempotentReplay: true. A request still in flight gets a 409.
Without the header, every POST sends.
What counts as a message
Recipients are the billable unit, not messages. A message to five people counts five against your daily limit and five against your monthly cap. Counting it once would make the spend ceiling — the thing that bounds what a leaked key can cost you — bypassable by padding the array.
Sending over SMTP
For software that can send mail but cannot make an HTTPS request: Supabase's custom SMTP, a WordPress mail plugin, a framework's mailer. It is the same account, with the same limits, the same suppression list and the same message history as the API.
| Host | smtp.misralo.com |
|---|---|
| Port | 587 with STARTTLS, or 465 with TLS |
| Username | announcer |
| Password | An API key. A send-scoped one is enough, and the right one to give software you do not control. |
| Sender | Any address on a domain you have verified |
The message goes out as your software wrote it. We remove any
Bcc header, so blind copies stay blind, and add a
Message-ID and a Date if it has none.
The From address decides the signing domain, so it must be
on a domain this account has verified. A message sent again because the connection
dropped before our reply arrived is recognised and not sent twice.
The limits are the API's: 50 recipients and 10 MB per message. Refusals come back as
SMTP replies carrying the same sentence the API would give. A 550
means the message cannot go out as it is, an unverified domain or suppressed
recipients; a 451 means try again later, a daily limit or the
rate limit. Sign-in is only offered once the connection is encrypted, and lines must
end in CRLF, as every mainstream mail library already does.
Supabase
In your project's Authentication settings, turn on custom SMTP and enter the host, port, username and API key above. Use a sender address on your verified domain; the sender name is up to you. Supabase then sends sign-up confirmations, magic links and password resets through Announcer. Supabase applies its own rate limit to these emails, which you can raise in the same settings.
WordPress
WordPress hands mail to the server it runs on unless a plugin sends it over SMTP. In an SMTP plugin, such as WP Mail SMTP with its Other SMTP mailer, enter the settings above: port 587 with TLS (plugins often call STARTTLS just "TLS"), or 465 with SSL, and authentication on. Set the From address to one on your verified domain, and turn on the plugin's option to force it, so a contact-form plugin cannot send as an address we would refuse.
Suppression
An address that hard-bounces or complains goes on your suppression list and is dropped before injection, because a dead address that keeps being mailed damages the reputation every message you send depends on.
Suppression is per recipient. A send where one of three addresses is suppressed goes
to the other two and reports the dropped one in a suppressed
array. Only when every recipient — or every to
recipient — is suppressed is the whole message refused, with a 422 that names them.
The attempt is still recorded and still counted: asking us to mail addresses you
already know are suppressed is work you asked for.
GET /v1/suppressions lists the addresses.
Messages and events
GET /v1/messages returns your messages newest first,
including refused ones — "did you even try to send this?" should be answerable from
data. Each row reports its primary recipient (the first
to) and a recipient_count;
?search= matches any recipient, so "did we mail this
person?" answers yes when they were a cc. Filter with
?status= — an unknown status is a 400 rather than an empty
list, because a typo that answers "no messages" reads as a delivery problem.
GET /v1/messages/{id}/events is the message's own timeline:
what was attempted, what the receiving server said, and when. How far back either
reaches is the history window on your plan — 14 days on Free.
We do not store what you wrote. Bodies are assembled, signed and handed to the mail server; there is no column that could hold one, so no endpoint here can return one. We keep the envelope and the subject line.
Webhooks
Register an HTTPS endpoint with POST /v1/webhooks and we POST
delivery events to it. The response returns a secret, once.
Each delivery carries a signature header:
X-Announcer-Signature: t=<unix-seconds>,v1=<hex>
Deliveries also carry an
RFC 9421 HTTP message signature
in Signature, Signature-Input and
Signature-Agent, per Web Bot Auth. That one is public-key:
fetch
the key directory and
you can verify a request came from us before reading its body, without holding any
shared secret. The HMAC below is still the one that proves the body; use
whichever suits your stack, or both.
v1 is HMAC-SHA256 over the string
"<t>.<body>", keyed with your secret. Recompute it over
the raw request body and compare in constant time before trusting anything in
the payload — that is what makes a delivery provably ours rather than anyone who
learned your endpoint URL. Reject a timestamp too far from now to stop replays.
The SDKs do all of that, timestamp included:
import express from 'express';
import { Announcer, SignatureVerificationError } from 'announcer-sdk';
const announcer = new Announcer();
const app = express();
// express.raw, not express.json: the signature covers the exact bytes.
app.post('/hooks/announcer', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = announcer.webhooks.verify(
req.body,
req.header('X-Announcer-Signature'),
process.env.ANNOUNCER_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof SignatureVerificationError) return res.sendStatus(400);
throw error;
}
// event.event is 'delivered', 'bounced', 'complained', ...
res.sendStatus(200);
});
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(), # the raw bytes, not request.get_json()
request.headers.get("X-Announcer-Signature"),
os.environ["ANNOUNCER_WEBHOOK_SECRET"],
)
except SignatureVerificationError:
return "", 400
# event.event is "delivered", "bounced", "complained", ...
return "", 200
func announcerWebhook(w http.ResponseWriter, r *http.Request) {
// The raw bytes are what was signed.
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
event, err := announcer.VerifyWebhook(
body,
r.Header.Get("X-Announcer-Signature"),
os.Getenv("ANNOUNCER_WEBHOOK_SECRET"),
)
if err != nil {
http.Error(w, "", http.StatusBadRequest)
return
}
// event.Event is announcer.EventDelivered, EventBounced, ...
w.WriteHeader(http.StatusOK)
}
app.MapPost("/hooks/announcer", async (HttpRequest request, IConfiguration config) =>
{
// The raw bytes are what was signed.
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
try
{
var evt = WebhookVerifier.Verify(
body,
request.Headers["X-Announcer-Signature"],
config["Announcer:WebhookSecret"]!);
// evt.Event is EventType.Delivered, EventType.Bounced, ...
return Results.Ok();
}
catch (AnnouncerSignatureVerificationException)
{
return Results.BadRequest();
}
});
Errors
Failures are RFC 9457 problem
documents, served as application/problem+json:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.4",
"title": "Forbidden",
"status": 403,
"detail": "Domain your-company.com is not verified."
}
A validation failure adds an errors object keyed by field
name, so you do not have to parse the prose in detail. Some
problems carry extra members too — a 422 from a fully suppressed send lists the
addresses under suppressed.
Rate limits
Two limits apply to every authenticated request, keyed to your account: a short-term rate limiter — 10 requests a second, burst 30, and a stricter 1 a minute on domain registration, which does RSA key generation — and the durable daily and monthly caps on your plan.
Over either, you get a 429 with a
Retry-After header in seconds. Going past a monthly allowance
is billed, never refused; the hard cap above it is what stops a leaked key running up
an unbounded bill, and hitting it gives you a clear error rather than a surprise.
Health
GET /healthz is unauthenticated and answers
{"status":"ok"} when every backend is reachable, or a 503
problem document when one is not. It does not name the failing backend: that detail is
for us, not for anonymous callers.
Machine-readable
The full surface is specified in OpenAPI 3.1 at
openapi.json, and discoverable per
RFC 9727 at
/.well-known/api-catalog. Every page on this
site, this one included, is also served as markdown to anything that asks for
Accept: text/markdown.