For developers
API documentation
The short version
Register a domain, publish the one DNS record we hand back, and POST your message. No SDK to install and no SMTP credentials to rotate — if your language can make an HTTPS request, it can send.
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.
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"
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.
Registering a domain
We generate a signing key that exists for your domain and nothing else. The private half never leaves our servers.
# 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"}'
The response carries a dns array. Copy the record into your
DNS provider, then ask us to check it:
curl -X POST https://mail.misralo.com/v1/domains/$ID/verify \ -H "Authorization: Bearer $ANNOUNCER_KEY"
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."
}'
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.
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.
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>
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.
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.