One template per notification type.
Comment, mention, deploy finished, payment failed: each gets a template with named variables, edited in the dashboard without a deploy. Your code passes data, never markup.
A product notification is a template, a recipient and a few variables. Notix sends it through the transactional email API with an idempotency key so a retried worker never sends twice, fans an event out to up to 100 people in one batch call, holds low-priority notices for a scheduled digest, and reports delivery, bounces and complaints back through signed webhooks so your own activity log can show what happened.
Three calls cover every notification a product sends.
When the event fires, call POST /v1/emails with the template for that notification type, the variables it names, and an idempotency key built from the event and the recipient. Store the emailId on your notification row.
When the event fans out, call POST /v1/emails/batch with up to 100 emails and one key. Put a scheduledAt on the ones that can wait for the morning digest.
A webhook endpoint receives email.delivered, email.bounced and the rest, each carrying the email id, so your notification row updates by primary key and a bounced address gets fixed in the product.
A template per type, variables from the event, a key that names both.
# One notification, one template, the variables the template names.
curl -X POST https://app.usenotix.dev/api/v1/emails \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Idempotency-Key: comment-cmt_8812-notify-usr_123" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <notifications@acme.com>",
"to": "user@example.com",
"templateId": "tpl_new_comment",
"variables": {
"commenter": "Amara",
"excerpt": "Can we move the deadline to Friday?",
"threadUrl": "https://acme.com/t/8812"
}
}'
# 200 { "emailId": "eml_abc123" }
import { Notix } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY);
// One template per notification type; the key names the event and the
// recipient, so a retried worker never sends the same notice twice.
export async function notifyNewComment(comment: Comment, recipient: User) {
const { data, error } = await notix.emails.send(
{
from: "Acme <notifications@acme.com>",
to: recipient.email,
templateId: "tpl_new_comment",
variables: {
commenter: comment.author.name,
excerpt: comment.body.slice(0, 140),
threadUrl: `https://acme.com/t/${comment.threadId}`,
},
},
{ idempotencyKey: `comment-${comment.id}-notify-${recipient.id}` },
);
if (error) throw new Error(error.message);
return data.emailId;
}
import os
from notix import Notix
notix = Notix(os.environ["NOTIX_API_KEY"])
def notify_new_comment(comment, recipient):
data, error = notix.emails.send(
{
"from": "Acme <notifications@acme.com>",
"to": recipient.email,
"templateId": "tpl_new_comment",
"variables": {
"commenter": comment.author.name,
"excerpt": comment.body[:140],
"threadUrl": f"https://acme.com/t/{comment.thread_id}",
},
},
options={"idempotency_key": f"comment-{comment.id}-notify-{recipient.id}"},
)
if error:
raise RuntimeError(error["message"])
return data["emailId"]
Batches of 100, and a scheduledAt for the notices that can wait.
# An event that fans out to many people: one call, up to 100 emails,
# one idempotency key for the whole batch.
curl -X POST https://app.usenotix.dev/api/v1/emails/batch \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Idempotency-Key: deploy-dpl_551-notify" \
-H "Content-Type: application/json" \
-d '[
{ "from": "Acme <notifications@acme.com>", "to": "ada@example.com",
"templateId": "tpl_deploy_finished", "variables": { "app": "api", "version": "2.14.0" } },
{ "from": "Acme <notifications@acme.com>", "to": "tunde@example.com",
"templateId": "tpl_deploy_finished", "variables": { "app": "api", "version": "2.14.0" } },
{ "from": "Acme <notifications@acme.com>", "to": "chidi@example.com",
"templateId": "tpl_weekly_digest", "variables": { "count": "12" },
"scheduledAt": "2026-09-15T08:00:00+01:00" }
]'
# 200 { "data": [ { "emailId": "eml_1" }, { "emailId": "eml_2" }, { "emailId": "eml_3" } ] }
// Fan out in pages of 100; each page gets its own key.
export async function notifyDeployFinished(deploy: Deploy, watchers: User[]) {
for (let i = 0; i < watchers.length; i += 100) {
const page = watchers.slice(i, i + 100);
const { data, error } = await notix.emails.batch(
page.map((user) => ({
from: "Acme <notifications@acme.com>",
to: user.email,
templateId: "tpl_deploy_finished",
variables: { app: deploy.app, version: deploy.version },
})),
{ idempotencyKey: `deploy-${deploy.id}-notify-${i / 100}` },
);
if (error) throw new Error(error.message);
await db.notification.createMany({
data: data.data.map((row, n) => ({ userId: page[n].id, emailId: row.emailId, state: "queued" })),
});
}
}
// Low-priority notices: hold them for the morning digest instead.
export async function queueForDigest(user: User, items: string[]) {
const nextMorning = nextLocalTime(user.timeZone, 8);
return notix.emails.send(
{
from: "Acme <notifications@acme.com>",
to: user.email,
templateId: "tpl_weekly_digest",
variables: { count: String(items.length) },
scheduledAt: nextMorning.toISOString(),
},
{ idempotencyKey: `digest-${user.id}-${nextMorning.toISOString().slice(0, 10)}` },
);
}
def notify_deploy_finished(deploy, watchers):
for i in range(0, len(watchers), 100):
page = watchers[i : i + 100]
data, error = notix.emails.batch(
[
{
"from": "Acme <notifications@acme.com>",
"to": user.email,
"templateId": "tpl_deploy_finished",
"variables": {"app": deploy.app, "version": deploy.version},
}
for user in page
],
options={"idempotency_key": f"deploy-{deploy.id}-notify-{i // 100}"},
)
if error:
raise RuntimeError(error["message"])
for user, row in zip(page, data["data"]):
Notification.objects.create(user=user, email_id=row["emailId"], state="queued")
The event’s data.id is the email id from the send, so the update is a primary-key write.
// Next.js route handler. The event id is the email id you stored, so
// the notification row updates without a lookup by address.
import { notix } from "@/lib/notix";
export async function POST(request: Request) {
const raw = await request.text();
const event = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!).constructEvent(raw, {
headers: request.headers,
});
const state = {
"email.delivered": "delivered",
"email.bounced": "bounced",
"email.complained": "complained",
"email.suppressed": "suppressed",
"email.opened": "opened",
}[event.type];
if (state) {
await db.notification.updateMany({ where: { emailId: event.data.id }, data: { state } });
if (event.type === "email.bounced" && event.data.bounce.type === "Permanent") {
// The address is now suppressed; ask for a new one in the product.
await db.user.update({ where: { email: event.data.to[0] }, data: { emailInvalid: true } });
}
}
return new Response("ok");
}
import os
from notix import Notix
notix = Notix(os.environ["NOTIX_API_KEY"])
webhooks = notix.webhooks(os.environ["NOTIX_WEBHOOK_SECRET"])
STATES = {
"email.delivered": "delivered",
"email.bounced": "bounced",
"email.complained": "complained",
"email.suppressed": "suppressed",
"email.opened": "opened",
}
@app.post("/hooks/notix")
def notix_hook():
event = webhooks.construct_event(request.data, headers=request.headers)
state = STATES.get(event["type"])
if state:
Notification.objects.filter(email_id=event["data"]["id"]).update(state=state)
if event["type"] == "email.bounced" and event["data"]["bounce"]["type"] == "Permanent":
User.objects.filter(email=event["data"]["to"][0]).update(email_invalid=True)
return "", 200
Documented limits of the send and batch endpoints and the event catalogue. Change one there and it changes here.
| Item | Value | Notes |
|---|---|---|
| Emails per batch call | 100 | POST /v1/emails/batch. Each item carries the same fields and limits as a single send. |
| Request body | 20 MB | Applied at the edge; a larger body is refused with 413 and no JSON. |
| Attachments | 10 MB per email, 40 MB per batch | Measured after base64 decoding. 10 files per email, 7 MB each. |
| Scheduling | scheduledAt per email | An ISO 8601 timestamp with offset. A scheduled email can be moved with PATCH or cancelled until it is sent. |
| Idempotency | Idempotency-Key header | One key per request, for single and batch sends alike. A replay within 24 hours returns the original ids. |
| Delivery events | 13 email events | queued, sent, delivery_delayed, delivered, bounced, rejected, rendering_failure, complained, failed, cancelled, suppressed, opened, clicked. |
The full request and response shapes, the idempotency header and the event payloads are in the docs.
Three kinds of notification, three rules. Notix enforces the suppression list on all of them and the unsubscribe on the marketing kind; the middle row is yours.
| Kind | Examples | Rule |
|---|---|---|
| Must always go out | Payment failed, security alert, invoice due | Send as transactional. It reaches a contact who has unsubscribed from marketing. Only a suppressed address (hard bounce or complaint) is dropped. |
| The user can turn off | Weekly digest, comment replies, tips | Keep the preference in your app and do not call the API for people who opted out. Notix does not store per-notification preferences for transactional sends. |
| Marketing | Feature launches, offers | Send through a campaign or journey to a contact book. An unsubscribe stops these at send time, and List-Unsubscribe headers are set for you. |
Comment, mention, deploy finished, payment failed: each gets a template with named variables, edited in the dashboard without a deploy. Your code passes data, never markup.
comment-8812-notify-usr_123 is stable across a retried worker, a redelivered queue message and a double-clicked button. The same key returns the same emailId and sends nothing new.
An event with fifty watchers is one batch call. Low-priority notices get a scheduledAt for the next morning and arrive as one digest instead of eleven interruptions.
The webhook event carries the id you were given at send time, so a notification's state updates by primary key. Show "delivered" and "bounced" in your own activity log.
A failed payment is not a preference. Send it as a transactional email, which reaches even a contact who unsubscribed from marketing, and keep optional notices behind a setting in your app.
OTPs, receipts and alerts with idempotency keys, a deliverability pre-check and a shared suppression list.
Use casesA welcome sent on sign-up, or a short sequence through a journey, with double opt-in built in for imported lists.
LearnUp to 100 messages in one call, a send scheduled for later, and what each returns and costs.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
GuidesIdempotency keys, batch sends, a webhook handler, templates, the error envelope and retries on the typed SDK.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Templates, batch sends and signed webhooks on every plan, and the free plan's 5,000 emails a month to test with. No card.