Sending and messages
Send an email
POST /v1/sendAccepts the message and returns 202 with an id. Nothing is delivered synchronously.
curl -X POST http://api.localhost/v1/send \
-H "X-API-Key: $NOTIFYZR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channels": [{
"type": "smtp",
"sender": { "email_address": "hello@example.com", "alias": "Example" },
"recipients": {
"to": ["someone@example.com"],
"cc": [],
"bcc": []
}
}],
"message": {
"subject": "Welcome aboard",
"template_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"variables": { "contact.name": "Ada", "plan": "Pro" }
},
"deliver_at": "2026-09-10T09:00:00Z"
}'Request
| Field | Required | Notes |
|---|---|---|
channels[] | yes | At least one. Today type is always "smtp". |
channels[].sender.email_address | yes | Must be on a verified domain of this organization. There is no default sender to fall back on. |
channels[].sender.alias | no | Display name. |
channels[].recipients.to[] | yes | |
channels[].recipients.cc[] / .bcc[] | no | Default []. |
message.subject | yes | Plain text. Placeholders are not filled in here — see below. |
message.template_id | either this | A published template. |
message.body[] | …or this | { "mime_type": "text/plain" | "text/html", "content": "…" }. Send both entries for a multipart email. |
message.variables | no | A flat object of placeholder name to value. See below. |
deliver_at | no | Any parseable date/time. Omit to send now. |
You must supply either template_id or body[]. Sending both is allowed: the template is rendered first, and each inline body entry then overrides the template for its own content type. So a text/html entry replaces the template's HTML while leaving its plain-text part alone.
Variables
Two details about variables are easy to get wrong, and both fail silently.
Keys are flat and match the placeholder exactly. A placeholder {{ contact.name }} is filled by the key "contact.name" — a nested object is not walked:
"variables": { "contact.name": "Ada" } // ✅ fills {{ contact.name }}
"variables": { "contact": { "name": "Ada" } } // ❌ leaves it blankOnly the template's bodies are rendered. The subject and any inline body[] you send are used verbatim — put placeholders in the template, and set the subject on the send.
An unknown placeholder renders as an empty string rather than failing the send, which is why a wrong shape shows up as a gap in the email rather than an error.
Response
{ "id": "…", "status": "queued", "scheduled_at": "2026-09-10T09:00:00+00:00" }scheduled_at is present only when deliver_at was.
What is checked before anything is recorded
Four refusals happen before the message row is written and before quota is spent, so a rejected send costs nothing:
| Refusal | Code |
|---|---|
| No sender address | notifyzr.send.error.sender_required |
| Sender's domain not verified | notifyzr.send.error.sender_domain_not_verified |
template_id names a draft | notifyzr.template.error.not_published |
| No recipients left | notifyzr.send.error.no_recipients |
Suppressed recipients are dropped silently
Addresses on the suppression list are removed from to, cc and bcc before the send is recorded. A partially suppressed send still reaches the survivors and reports success. Only when every recipient was suppressed do you get SEND_NO_RECIPIENTS.
List messages
GET /v1/messagesPaginated. Filters combine.
| Query | Values |
|---|---|
page, per_page | Default 25, max 100 |
status | queued, processing, delivered, failed |
search | Recipient or subject |
date_from, date_to | ISO dates |
api_key_id | Which key sent it |
channel_id | A Discord/Telegram/ntfy channel |
domain_id | Which verified domain it went out from |
broadcast_id | Everything one broadcast produced |
{
"messages": [{
"id": "…",
"type": "email",
"to": "someone@example.com",
"subject": "Welcome",
"status": "delivered",
"channel_id": null,
"channel_name": null,
"channel_type": null,
"api_key_id": "…",
"domain_id": "…",
"scheduled_at": null,
"created_at": "2026-09-08T12:01:44+00:00",
"delivered_at": "2026-09-08T12:01:47+00:00"
}],
"pagination": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 }
}domain_id can be null
Mail written before sender attribution was enforced has none, and the column is cleared when a domain is deleted. Channel messages never have one — they have no sending domain at all.
Get a message
GET /v1/messages/{id}Everything the list gives, plus the rendered content, the recipients, the sender, the attempt count, the last error — and the message's lifecycle events alongside it.
{
"message": {
"id": "…",
"type": "email",
"status": "failed",
"subject": "Welcome",
"body": { "html": "<p>…</p>", "text": "…" },
"recipients": { "to": ["someone@example.com"], "cc": [], "bcc": [] },
"sender": { "email_address": "hello@example.com", "alias": "Example" },
"content": null,
"attempts": 5,
"last_error": "Connection refused",
"channel": null,
"api_key": { "id": "…", "label": "Production" },
"domain": { "id": "…", "domain": "example.com" },
"scheduled_at": null,
"created_at": "…",
"delivered_at": null
},
"events": [
{ "type": "email.queued", "detail": {}, "created_at": "…" },
{ "type": "email.failed", "detail": { "error": "…" }, "created_at": "…" }
]
}For a channel message, content and channel are populated instead of body, recipients and sender.
Messages sent before event recording existed have no stored events; the response derives a minimal set from the row rather than returning an empty list.
Message metrics
GET /v1/messages/metrics?range=7drange is 24h, 7d or 30d (default 7d). This is what the dashboard's Overview charts are drawn from.
{
"range": "7d",
"granularity": "day",
"series": [{ "bucket": "2026-09-02 00:00:00", "total": 120, "delivered": 118, "failed": 1, "pending": 1 }],
"totals": { "total": 840, "delivered": 826, "failed": 6, "pending": 8 },
"domains": [{ "domain_id": "…", "domain": "example.com", "total": 840, "delivered": 826, "failed": 6, "pending": 8 }],
"channels": [{ "type": "discord", "total": 12, "delivered": 12, "failed": 0 }]
}granularity is hour for 24h and day otherwise. totals is rolled up from series rather than queried separately, so the headline figures can never disagree with the bars under them.
A domains entry with a null domain_id is the unattributed bucket: mail whose sending domain was never recorded or has since been deleted. It is reported rather than dropped, so the breakdown still adds up to the totals.
Get an email
GET /v1/emails/{id}A deliberately slim view of one email: its delivery state and nothing else. Use GET /v1/messages/{id} when you want the content.
{
"email": {
"id": "…",
"status": "delivered",
"attempts": 1,
"last_error": null,
"scheduled_at": null,
"created_at": "…",
"delivered_at": "…"
}
}Send a channel message
POST /v1/channels/{id}/messagescurl -X POST http://api.localhost/v1/channels/$CHANNEL_ID/messages \
-H "X-API-Key: $NOTIFYZR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "content": "Deploy finished ✅" }'content is 1–4000 characters. Like an email send it returns 202, is recorded in /v1/messages, and emits channel_message.sent or channel_message.failed.
See Channels for creating one.
Message lifecycle
queued ──▶ processing ──▶ delivered
└▶ failedA message stays queued until a worker claims it. Failures are retried, each attempt waiting 30 seconds longer than the last — the row goes back to queued between attempts, so attempts climbing with the status unchanged is normal. After the last attempt it is failed and the job is dead-lettered.
Each transition emits an event: email.queued, email.scheduled, email.delivered, email.failed. Subscribe with a webhook rather than polling.