One hundred emails in one call, or one email at nine tomorrow.
The single send endpoint is the right tool for a receipt or a reset link. Two other shapes come up in every product: a set of messages produced by one job, and a message that must go out at a chosen time. Notix handles both with the same email object. A batch is an array of up to 100 of them posted to POST /v1/emails/batch; a scheduled send is any email with a scheduledAt timestamp, which can be moved or cancelled until it goes.
Batch: the same email, one hundred times.
A batch request is a JSON array. Each element is a complete email, with its own recipients, template or body, attachments, headers and, if you want, its own scheduledAt. Nothing is shared between elements, so a digest with 100 personalised bodies and a plain notification to 100 addresses look the same to the API. The one thing a batch cannot do is render a React element on the client: pass HTML or a templateId with variables instead.
The batch total is what the per-email limits cannot see. One hundred emails that each pass the per-email attachment check could still add up to a request no server should accept, so the batch carries its own cap on attachment bytes, measured after base64 decoding, and the body as a whole is capped at 20 MB at the edge.
| Limit | Value | Note |
|---|---|---|
| Emails per request | 100 | The 101st is refused with 400 BAD_REQUEST before anything is stored. |
| Request body | 20 MB | Applied at the edge; over it the answer is 413 with no JSON body. |
| Attachments per email | 10 | Same as a single send; base64 in the body. |
| Attachments across the batch | 40 MB decoded | Stops 100 emails that each pass the per-email check from adding up to a request nothing would accept. |
| Idempotency-Key | Up to 256 characters | One key for the whole request; a retry with the same key and body returns the same ids without sending again. |
| Billing | 0.4 units per email | Batch items are transactional sends, so 2.5 of them make one unit, the same as calling the single endpoint 100 times. |
Validation runs on the whole request before anything is stored, so a batch with one bad element is refused as a batch, and the 400 names the field and the limit. A from address on a domain the team has not verified is refused the same way. Past validation, each message is created and queued on its own; a recipient on the suppression list is recorded as suppressed rather than sent, and a row that could not be created is simply absent from the response.
One request, one id per message.
The response is a data array of email ids. Each id behaves exactly like one from the single endpoint: fetch it, watch its events, receive its webhooks.
curl -X POST https://app.usenotix.dev/api/v1/emails/batch \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Idempotency-Key: digest-2026-09-13" \
-H "Content-Type: application/json" \
-d '[
{ "from": "digest@acme.com", "to": "ada@example.com",
"templateId": "tpl_weekly", "variables": { "name": "Ada" } },
{ "from": "digest@acme.com", "to": "grace@example.com",
"templateId": "tpl_weekly", "variables": { "name": "Grace" },
"scheduledAt": "2026-09-14T08:00:00+01:00" }
]'
# 200 { "data": [ { "emailId": "em_..." }, { "emailId": "em_..." } ] }
import { Notix } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY);
const { data, error } = await notix.emails.batch(
recipients.map((r) => ({
from: "digest@acme.com",
to: r.email,
templateId: "tpl_weekly",
variables: { name: r.name },
})),
{ idempotencyKey: `digest-${issueId}` },
);
if (error) return respond(error);
// data is [{ emailId }, ...], one per accepted message.
import os
from datetime import datetime, timedelta, timezone
from notix import Notix
notix = Notix(os.environ["NOTIX_API_KEY"])
tomorrow = datetime.now(timezone.utc) + timedelta(days=1)
data, error = notix.emails.batch(
[
{"from_": "digest@acme.com", "to": r["email"],
"templateId": "tpl_weekly", "variables": {"name": r["name"]},
"scheduledAt": tomorrow}
for r in recipients
],
options={"idempotency_key": f"digest-{issue_id}"},
)
{
"data": [
{ "emailId": "em_01j7x3k9q2" },
{ "emailId": "em_01j7x3k9q3" }
]
}
Scheduled: the same email, later.
Any send, single or inside a batch, accepts a scheduledAt timestamp in ISO 8601 form with a timezone offset. The message is stored with status SCHEDULED and handed to the queue with a delay; at the chosen time it goes out like any other send, through the same domain, suppression and webhook path. A timestamp in the past is treated as now. There is no stated upper bound on how far ahead you can schedule.
While a message is SCHEDULED it is yours to change. PATCH /v1/emails/{id} with a new scheduledAt moves it; POST /v1/emails/{id}/cancel cancels it, records a CANCELLED event and hands back any slot it had taken. Once the message has left the queue both calls answer 400 with the message “Email already processed”, so treat a 400 here as “too late” rather than as a bug.
A scheduled send is not part of today’s traffic. It holds no reservation against your daily or monthly allowance when you create it; the queue worker reserves for it on the day it goes out, and cancelling it costs nothing. The check that you are not already over your limit still runs at the request, so a team that is over today cannot queue tomorrow’s sends as a way round it.
# Schedule: any send accepts scheduledAt (ISO 8601 with an offset)
curl -X POST https://app.usenotix.dev/api/v1/emails \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "billing@acme.com", "to": "ada@example.com",
"subject": "Your invoice is due tomorrow",
"html": "<p>Invoice 1042 is due on 15 September.</p>",
"scheduledAt": "2026-09-14T09:00:00Z" }'
# 200 { "emailId": "em_..." } status: SCHEDULED
# Move it
curl -X PATCH https://app.usenotix.dev/api/v1/emails/em_... \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "scheduledAt": "2026-09-14T12:00:00Z" }'
# Cancel it
curl -X POST https://app.usenotix.dev/api/v1/emails/em_.../cancel \
-H "Authorization: Bearer $NOTIX_API_KEY"
const { data } = await notix.emails.send({
from: "billing@acme.com",
to: "ada@example.com",
subject: "Your invoice is due tomorrow",
html: "<p>Invoice 1042 is due on 15 September.</p>",
scheduledAt: "2026-09-14T09:00:00Z",
});
// Move it while it is still SCHEDULED
await notix.emails.update(data.emailId, { scheduledAt: "2026-09-14T12:00:00Z" });
// Or cancel it; after it has gone out both calls answer 400
await notix.emails.cancel(data.emailId);
data, error = notix.emails.send({
"from_": "billing@acme.com",
"to": "ada@example.com",
"subject": "Your invoice is due tomorrow",
"html": "<p>Invoice 1042 is due on 15 September.</p>",
"scheduledAt": datetime(2026, 9, 14, 9, 0, tzinfo=timezone.utc),
})
notix.emails.update(data["emailId"], {"scheduledAt": datetime(2026, 9, 14, 12, 0, tzinfo=timezone.utc)})
notix.emails.cancel(data["emailId"])
Retries, and what a batch costs.
A batch is the request most likely to be retried by accident: a job runner times out, a deploy restarts the worker, and the same 100 messages are posted again. Put an Idempotency-Key on every batch, named after the thing that produced it, such as the digest issue or the export id. A retry with the same key and body returns the original ids and sends nothing; a retry with a different body is a 409 so you notice the drift. Keys live for 24 hours, which covers a retry loop and not much more.
Billing is per message, not per request. Batch items are transactional sends, and a transactional email is 0.4 of a usage unit, so 100 of them cost 40 units, the same as 100 calls to the single endpoint. A scheduled send costs the same as an immediate one and is counted on the day it goes out. The pricing page has the unit price and what each plan includes.
Questions, answered.
How do I send many emails with one API call?
How do I schedule an email to send later through an API?
Is a batch all-or-nothing?
Do the ids come back in the order I sent the messages?
Can I retry a batch safely?
Does a scheduled send count against my limit when I schedule it, or when it goes out?
Keep reading.
Idempotency keys for email
Why a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
ProductEmail API
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesTransactional email in Node.js, production-ready
Idempotency keys, batch sends, a webhook handler, templates, the error envelope and retries on the typed SDK.
LearnVerify an email webhook signature
HMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
NotixDocs
The quickstart: verify a domain, copy an API key, send the first email.
NotixPricing
A free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
The endpoints are in the API reference.
Batch, schedule, update and cancel, with every field and every error code.