Use case
Transactional notification emails
Order receipts, payment alerts, status updates, system warnings — the high-volume backbone of any SaaS. Idempotent, deduplicated, queueable, and replayable.
Transactional notifications are the bulk of every SaaS's outbound mail. They're triggered by a business event (an order, a payment, a state change), need to arrive in seconds, and must not double-send when your system retries — a customer getting two charge-confirmation emails is worse than getting none. This page covers the patterns that hold up under real production traffic: queue-don't-block, stable idempotency keys, per-event-type webhooks back, and the suppression rules that keep your sending reputation intact.
Anatomy
What every solid implementation has
- 1A business event (order.created, payment.captured, ticket.assigned) produces a job, not a direct send call.
- 2A worker dequeues the job, builds the email body, and calls sndr.emails.send with a stable Idempotency-Key derived from the business event ID.
- 3A failed send returns to the queue with exponential backoff; the same Idempotency-Key means retries do not double-deliver.
- 4A webhook receiver records the delivered / bounced / unsubscribed event back to your DB so you know what landed.
- 5A suppression layer respects user opt-outs and bounces; transactional sends bypass marketing opt-outs but never bypass hard bounces.
- 6Templates live in code or in the SNDR template store — versioned, previewable, testable.
Implementation
The actual code
Queue + idempotent send
The send is wrapped in a job so a request failure does not lose the notification. The Idempotency-Key is derived from the business event ID — a retry of the same job results in the server returning the existing message ID, not a fresh send.
import { Sndr } from '@rkiza/sndr'; const sndr = new Sndr(process.env.SNDR_API_KEY!); export async function notifyOrderConfirmed(orderId: string) { const order = await db.order.findUniqueOrThrow({ where: { id: orderId } }); const customer = await db.user.findUniqueOrThrow({ where: { id: order.user_id } }); // Skip on hard-bounced addresses, marketing-opt-out is irrelevant // for transactional sends — but a hard bounce is the customer's // address being unreachable, and re-sending only damages your // reputation. if (await db.suppression.exists({ address: customer.email, kind: 'hard_bounce' })) { await db.notificationLog.create({ data: { kind: 'order_confirmation', order_id: orderId, status: 'suppressed_hard_bounce', }}); return; } await sndr.emails.send( { from: 'orders@yourdomain.com', to: customer.email, subject: `Order #${order.number} confirmed`, html: renderOrderConfirmationHtml(order), text: renderOrderConfirmationText(order), tags: [ { key: 'kind', value: 'order_confirmation' }, { key: 'order_id', value: orderId }, ], }, { // Stable per-business-event key. Reused across retries of this // job. Server-side dedupe means two retries return the same // email_id, no double-send. idempotencyKey: `order-confirmation-${orderId}`, }, ); }Record delivery + bounces via webhook
Persist what landed so your admin / customer-success team can see the audit trail. The bounced-event handler removes the address from active sends; the unsubscribed-event handler updates the user preference (only relevant for non-transactional sends — see considerations).
import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; export async function handleSndrWebhook(req: { body: Buffer; headers: Record<string, string> }) { if (!verifyWebhookSignature( req.body, req.headers['x-sndr-signature'] ?? '', process.env.SNDR_WEBHOOK_SECRET!, )) { return { status: 400 }; } const event = JSON.parse(req.body.toString('utf8')) as WebhookEventPayload; // Persist with the event.id as unique key so a redelivery from the // SNDR side does not insert twice. Cheap idempotency on the receiver. const exists = await db.webhookEvent.findUnique({ where: { id: event.id } }); if (exists) return { status: 200 }; await db.webhookEvent.create({ data: { id: event.id, type: event.type } }); switch (event.type) { case 'email.delivered': await db.notificationLog.updateMany({ where: { sndr_email_id: event.data.email_id }, data: { delivered_at: new Date() }, }); break; case 'email.bounced': // Add to suppression so subsequent sends short-circuit. for (const addr of event.data.email_id ? [] : []) { /* see comment */ } // event.data.email_id + event.data.error: log the bounce reason; // resolve the original recipient via your notificationLog table. await db.notificationLog.updateMany({ where: { sndr_email_id: event.data.email_id }, data: { bounced_at: new Date(), error: event.data.error }, }); break; case 'email.unsubscribed': await db.suppression.createMany({ data: event.data.addresses.map((addr) => ({ address: addr, kind: 'unsubscribe', created_at: new Date(), })), skipDuplicates: true, }); break; } return { status: 200 }; }
Production considerations
What you have to get right
Transactional vs marketing
A transactional notification is triggered by a user-initiated business event (order, payment, password). It bypasses marketing opt-outs — the user expects it. A marketing email is not. Mixing the two on the same sending IPs damages deliverability for both.
Stable idempotency keys
Always derive the Idempotency-Key from your business event ID, not from a UUID generated at send-time. A worker retry must produce the same key so the server can dedupe. The SDK's auto-generated UUID key is fine for fire-and-forget sends but wrong for retryable jobs.
Hard bounce suppression
If a customer's address hard-bounces (Permanent Failure 5xx), do not retry — re-sending only damages your reputation. Persist suppressions in your DB and short-circuit before the SDK call. SNDR also suppresses bounces server-side as a defence in depth.
Subject-line clarity
A transactional subject should describe the event ("Order #1234 confirmed"). Avoid marketing-style adjectives ("Amazing news about your order!") — they harm spam classification for accounts that send mixed transactional + marketing.
Templating discipline
Either version-control templates in code or use the SNDR template store (also versioned). Never let business logic mutate template state — a templating bug then double-sends.
Replayable webhooks for incident triage
If your webhook receiver was down for 30 minutes, every event SNDR tried to deliver returns to the queue. Replay from the admin re-fires each one — your DB catches up. Without replay you've silently lost a half hour of delivery audit data.
FAQ
Should I queue every send or send inline?
Queue every send. Inline ties request latency to the upstream API and loses transactional reliability — if your process crashes mid-call, the customer gets nothing. A job queue with exponential backoff fixes both.
How do I batch many notifications?
Do not batch transactional sends — each is its own deliverability decision and its own retry. SNDR scales to thousands per second; batching is a marketing-send concept, not a transactional one.
What about user notification preferences?
Transactional notifications bypass marketing opt-outs but should still respect per-event-type prefs (e.g. 'Don't email me on every comment'). Honor those in your worker before the send call — never send a notification the user explicitly disabled.
How fast does SNDR deliver?
P50 send latency is 38ms from the API to the MTA accepting; first-hop ISP delivery typically lands within 1-5 seconds for the major receivers. SLA is 99.99% uptime; observed monthly is higher.
Related
Ship this in production today.
Free tier includes 3,000 emails / month. No card required.
Start building
Your next message starts here.
Connect your domain, create an API key, and send with SNDR.