Integration
Send email from Express with SNDR.
Send transactional email from Express — route handlers, queued jobs, and webhook receivers with the express.raw() body parser that keeps HMAC verification correct.
Express is still the workhorse Node.js framework for API backends; the SDK is a clean fit. The thing every team gets wrong with Express + webhooks: app-level express.json() consumes the request body before your verifier can see the raw bytes, which breaks HMAC. The recipes below mount express.raw() per-route on the webhook handler so verification stays correct without disabling JSON parsing everywhere else.
Setup
Install + configure
pnpm add @rkiza/sndr express
# or
npm install @rkiza/sndr express# .env
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...Recipes
The patterns you actually need.
Basic send from a route handler
Instantiate Sndr once at module scope and reuse the client across requests. Catch SndrError so you can map our error codes to your HTTP responses cleanly.
// server.ts import express from 'express'; import { Sndr, SndrError } from '@rkiza/sndr'; const app = express(); app.use(express.json()); const sndr = new Sndr(process.env.SNDR_API_KEY!); app.post('/api/welcome', async (req, res) => { const { email, name } = req.body as { email: string; name: string }; try { const { id } = await sndr.emails.send({ from: 'hello@yourdomain.com', to: email, subject: `Welcome, ${name}`, html: `<p>Thanks for joining us, ${name}.</p>`, }); res.json({ id }); } catch (err) { if (err instanceof SndrError) { res.status(err.status >= 400 && err.status <= 599 ? err.status : 502).json({ error: { code: err.code, message: err.message } }); return; } throw err; } }); app.listen(3000);Webhook receiver — express.raw() per-route
express.raw({ type: 'application/json' }) is the critical bit. It exposes req.body as a Buffer of the raw bytes so the HMAC verifier can compute over the exact payload SNDR signed. Don't replace it with express.json() — re-serialised JSON breaks the signature.
import express from 'express'; import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; const app = express(); app.use(express.json()); // For everything else. app.post( '/webhooks/sndr', // Per-route raw body — overrides the app-level json() above for THIS route only. express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyWebhookSignature( req.body as Buffer, req.header('x-sndr-signature') ?? '', process.env.SNDR_WEBHOOK_SECRET!, ); if (!ok) return res.status(400).send('bad signature'); const event = JSON.parse((req.body as Buffer).toString('utf8')) as WebhookEventPayload; switch (event.type) { case 'email.delivered': // event.data.email_id, event.data.to: string[] break; case 'email.bounced': // event.data.email_id, event.data.error break; case 'email.unsubscribed': // event.data.addresses: string[] break; } res.status(200).send('ok'); }, );Queue + retry (BullMQ)
Inline sends couple your route latency to the upstream API. A BullMQ worker with autoretry handles transient failures and keeps the request fast. Pair with a stable Idempotency-Key derived from the business event.
// queue.ts import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; import { Sndr, SndrError } from '@rkiza/sndr'; const connection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null }); export const emailQueue = new Queue('transactional-email', { connection }); const sndr = new Sndr(process.env.SNDR_API_KEY!); new Worker( 'transactional-email', async (job) => { const { payload, idempotencyKey } = job.data as { payload: Parameters<typeof sndr.emails.send>[0]; idempotencyKey: string }; return sndr.emails.send(payload, { idempotencyKey }); }, { connection, settings: { backoffStrategy: (attempts) => Math.min(60_000, 1000 * 2 ** attempts) }, }, ); // Dispatch from a route: await emailQueue.add( 'order-confirmation', { payload: { from: 'orders@yourdomain.com', to: customer.email, subject: 'Confirmed', html: '...' }, idempotencyKey: `order-${orderId}`, }, { attempts: 5 }, );
Express FAQ
Why express.raw() per-route instead of app-level?
Should I share the Sndr client across requests?
How do I handle the SDK's automatic retries plus my own?
Send your first Express email.
3,000 emails a month on the Free plan. No credit card needed.