Emails
Base URL: https://api.notideus.io. All endpoints below are under /v1 and use the error envelope on failure — see Errors.
POST /v1/emails
Scope: emails:send — dual-auth: also accepts a session token.
Enqueues one email. Either html or template_id is required; when both are present the template wins.
Request fields
| Field | Type | Required | Notes |
|---|---|---|---|
from | string | yes | Accepted formats: a@b.c, Name <a@b.c>; domain must be verified |
to | string | yes | Min 1 recipient |
subject | string | yes | |
html | string | required unless template_id | Raw HTML body |
template_id | uuid | no | Dashboard-managed template; wins over html |
variables | mapstringstring | if the template declares slots | {{name}} slots; values HTML-escaped |
text | string | no | Plain-text alternative; sent as multipart/alternative with the HTML |
headers | mapstringstring | no | Custom message headers (max 10 entries) — see Custom headers |
reply_to | string | no | Address replies should go to; display-name forms are reduced to the bare address |
tags | string | no | Stored as JSON array |
idempotency_key | string | no | Replay returns 200 + original |
Request example
curl -X POST https://api.notideus.io/v1/emails \
-H "Authorization: Bearer $NT_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Notideus <noreply@demo.dev>",
"to": ["jane@example.com"],
"subject": "Welcome to Notideus",
"html": "<p>Hi Jane,</p><p>Your account is ready.</p>",
"variables": {"name": "Jane"},
"tags": ["welcome"],
"idempotency_key": "docs-quickstart-1"
}'
Custom headers
The headers object attaches custom message headers to the outgoing email — at most 10 entries:
- Names must be valid header tokens of at most 126 characters.
- Values must be 1–995 printable-ASCII characters (
0x20–0x7e): non-empty, and no control or non-ASCII characters. - Reserved names are rejected with 400
invalid_headers:From,To,Cc,Bcc,Subject,Date,Message-ID,MIME-Version,Content-Type,Content-Transfer-Encoding,Return-Path,Received,DKIM-Signature, plus any name starting withX-SES(case-insensitive).
RFC 8058 one-click unsubscribe works on transactional sends:
{
"headers": {
"List-Unsubscribe": "<https://example.com/preferences>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
}
}
Response
201 Created — the stored email row:
{
"id": "01a0a11c-188e-7c9e-9b69-753fcb4c8d0d",
"team_id": "00000000-0000-0000-0000-000000000001",
"domain_id": "01a0a118-2c7b-7da6-aa31-ceb20daf80c6",
"template_id": null,
"from_address": "Notideus <noreply@demo.dev>",
"to_emails": ["jane@example.com"],
"subject": "Welcome to Notideus",
"html": "<p>Hi Jane,</p><p>Your account is ready.</p>",
"size_kb": 0.04,
"tags": ["welcome"],
"status": "queued",
"idempotency_key": "docs-quickstart-1",
"created_at": "2026-09-14T18:09:28.206638Z",
"requeued_at": null,
"ses_message_id": null,
"broadcast_id": null,
"contact_id": null,
"unsubscribe_token": null,
"body_text": "",
"headers": null,
"reply_to": ""
}
200 OK — a replay: same request (same idempotency_key) returns the original stored row with 200 instead of 201. See Idempotency.
The row shape:
| Field | Type | Notes |
|---|---|---|
id | uuid | Stored email identifier; identical across idempotent replays |
team_id | uuid | Owning team |
domain_id | uuid | Matched verified sending domain |
template_id | uuid, null | Template used, null for raw HTML sends |
from_address | string | Normalized From, e.g. Name <a@b.c> |
to_emails | string | Recipients exactly as stored |
subject | string | |
html | string | Rendered HTML after variable substitution |
size_kb | number | HTML size in kilobytes |
tags | string | |
status | string | One of queued, sent, delivered, opened, clicked, bounced, complained — see Email lifecycle |
idempotency_key | string, null | Key the row was stored under, if any |
created_at | timestamp | RFC 3339 |
requeued_at | timestamp, null | Set when a send is requeued |
ses_message_id | string, null | Provider message id, set once handed to SES |
broadcast_id | uuid, null | Set for broadcast sends |
contact_id | uuid, null | Set when addressed to a stored contact |
unsubscribe_token | string, null | Per-email unsubscribe token |
body_text | string | Plain-text alternative, empty when unset |
headers | object, null | Custom headers exactly as accepted, null when unset |
reply_to | string | Bare reply address, empty when unset |
Errors
- 400
bad_request— malformed JSON or a missing field. - 400
invalid_from— the From address cannot be parsed. - 400
invalid_headers— a custom header is reserved, malformed or outside the limits above. - 400
invalid_reply_to—reply_tocannot be parsed as an address. - 402
quota_exceeded— monthly send allowance reached; see Quotas and limits. - 403
insufficient_scope— the key lacksemails:send. - 403
domain_not_allowed— the key is bound to a different domain than the From address resolves to. - 422
from_domain_not_verified— the From domain matches no verified team domain.
All error codes: Errors.
POST /v1/emails/batch
Scope: emails:send — API key only (no session auth).
Enqueues up to 100 emails in one call. The body is an emails array; each item has the same shape as a single send — including the optional text, headers and reply_to fields — with its own idempotency_key.
Request example
{
"emails": [
{
"from": "Notideus <noreply@demo.dev>",
"to": ["jane@example.com"],
"subject": "Your weekly digest",
"html": "<p>Hi Jane, here is your digest.</p>",
"idempotency_key": "digest-2026-09-14-jane"
},
{
"from": "Notideus <noreply@demo.dev>",
"to": ["john@example.com"],
"subject": "Your weekly digest",
"html": "<p>Hi John, here is your digest.</p>",
"idempotency_key": "digest-2026-09-14-john"
}
]
}
Response
200 OK — one result per item, in request order. Each item is either {"index","id"} (accepted) or {"index","error"} (rejected):
{
"data": [
{
"index": 0,
"id": "01a0a11c-4e6e-7228-b9b3-f50fd0728a90"
},
{
"index": 1,
"id": "01a0a11c-4e75-7486-8f23-8d50dc697ebf"
},
{
"index": 2,
"error": {
"code": "from_domain_not_verified",
"message": "no verified domain matches the from address"
}
}
]
}
Decode: index is the item's position in the request array (0-based); id is the stored email's UUID, exactly what a single send would return; error carries the same code/message envelope as a whole-call failure. Partial failures never abort the batch — one bad item does not affect the others.
Whole-call failures
- 400
batch_too_large— more than 100 items; nothing is processed:
{
"error": {
"code": "batch_too_large",
"message": "batch is limited to 100 emails"
}
}
- 402
quota_exceeded— the whole batch is checked against your quota up front: if it would not fit, nothing is sent. Idempotent replays of already-stored emails are exempt, so retrying with the same keys never double-counts. See Quotas and limits.
Only the items with an error need retrying — re-send just those with the same per-item idempotency_keys. Full walkthrough: Sending batches.