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

Installbash
pnpm add @rkiza/sndr express
# or
npm install @rkiza/sndr express
Environmentenv
# .env
SNDR_API_KEY=sndr_live_x9k2...
SNDR_WEBHOOK_SECRET=...

Recipes

The patterns you actually need.

  1. 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);
  2. 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');
      },
    );
  3. 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?
Webhooks need the raw bytes for HMAC; the rest of your app probably wants parsed JSON. Mounting express.raw() per-route is the surgical fix — every other route keeps express.json() behavior.
Should I share the Sndr client across requests?
Yes. Instantiate at module scope; the SDK is safe for concurrent use and reuses connections via the underlying fetch implementation.
How do I handle the SDK's automatic retries plus my own?
The SDK retries 5xx + 429 internally up to 3 attempts. Your queue then retries the whole send call on failure. That stacks: a transient outage that lasts past your SDK's in-process retries will get picked up on the next queue attempt.

Send your first Express email.

3,000 emails a month on the Free plan. No credit card needed.