Skip to content

Sending and messages ​

Send an email ​

POST /v1/send

Accepts the message and returns 202 with an id. Nothing is delivered synchronously.

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

FieldRequiredNotes
channels[]yesAt least one. Today type is always "smtp".
channels[].sender.email_addressyesMust be on a verified domain of this organization. There is no default sender to fall back on.
channels[].sender.aliasnoDisplay name.
channels[].recipients.to[]yes
channels[].recipients.cc[] / .bcc[]noDefault [].
message.subjectyesPlain text. Placeholders are not filled in here — see below.
message.template_ideither thisA published template.
message.body[]…or this{ "mime_type": "text/plain" | "text/html", "content": "…" }. Send both entries for a multipart email.
message.variablesnoA flat object of placeholder name to value. See below.
deliver_atnoAny 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:

json
"variables": { "contact.name": "Ada" }      // ✅ fills {{ contact.name }}
"variables": { "contact": { "name": "Ada" } } // ❌ leaves it blank

Only 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 ​

json
{ "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:

RefusalCode
No sender addressnotifyzr.send.error.sender_required
Sender's domain not verifiednotifyzr.send.error.sender_domain_not_verified
template_id names a draftnotifyzr.template.error.not_published
No recipients leftnotifyzr.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/messages

Paginated. Filters combine.

QueryValues
page, per_pageDefault 25, max 100
statusqueued, processing, delivered, failed
searchRecipient or subject
date_from, date_toISO dates
api_key_idWhich key sent it
channel_idA Discord/Telegram/ntfy channel
domain_idWhich verified domain it went out from
broadcast_idEverything one broadcast produced
json
{
  "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.

json
{
  "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=7d

range is 24h, 7d or 30d (default 7d). This is what the dashboard's Overview charts are drawn from.

json
{
  "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.

json
{
  "email": {
    "id": "…",
    "status": "delivered",
    "attempts": 1,
    "last_error": null,
    "scheduled_at": null,
    "created_at": "…",
    "delivered_at": "…"
  }
}

Send a channel message ​

POST /v1/channels/{id}/messages
bash
curl -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
                       └▶ failed

A 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.

Released under the Apache 2.0 License.