---
title: "API documentation — Announcer"
description: "How to send with Announcer: registering a domain, publishing the DNS record, sending a message, reading delivery events, and verifying webhooks."
source: https://misralo.com/docs.html
---

*For developers*

# API documentation

Base URL `https://mail.misralo.com` — machine-readable at [openapi.json](https://misralo.com/openapi.json)

*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`.

```bash
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.

```bash
# 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:

```bash
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

```bash
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](https://www.rfc-editor.org/rfc/rfc9457) 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](https://misralo.com/openapi.json), and discoverable per [RFC 9727](https://www.rfc-editor.org/rfc/rfc9727) at [/.well-known/api-catalog](https://misralo.com/.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`.
