Integration

Send email from Fastify with SNDR.

Send transactional email from a Fastify app — JSON schema validation per route, queueable jobs, and webhook receivers with a per-route raw-body content type parser that keeps HMAC verification correct.

Fastify's per-route schema validation, encapsulated plugins, and lifecycle hooks make it a clean fit for transactional sends. The thing every Fastify webhook handler gets wrong: the default JSON content-type parser consumes the body before your verifier sees the raw bytes. The recipes below register a per-route raw parser on the webhook handler so HMAC verification stays correct while every other route keeps schema-validated JSON.

Setup

Install + configure

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

Recipes

The patterns you actually need.

  1. Send with schema-validated input

    Fastify's JSON schema validator runs before your handler. The schema doubles as TypeScript type information via FromSchema, so the handler stays type-safe without manual casts.

    // src/server.ts
    import Fastify from 'fastify';
    import { Sndr, SndrError } from '@rkiza/sndr';
    
    const app = Fastify({ logger: true });
    
    const sndr = new Sndr(process.env.SNDR_API_KEY!);
    
    const welcomeSchema = {
      type: 'object',
      required: ['email', 'name'],
      properties: {
        email: { type: 'string', format: 'email' },
        name: { type: 'string', minLength: 1, maxLength: 120 },
      },
    } as const;
    
    app.post<{ Body: { email: string; name: string } }>(
      '/api/welcome',
      { schema: { body: welcomeSchema } },
      async (req, reply) => {
        try {
          const { id } = await sndr.emails.send({
            from: 'hello@yourdomain.com',
            to: req.body.email,
            subject: `Welcome, ${req.body.name}`,
            html: `<p>Thanks for joining us, ${req.body.name}.</p>`,
          });
          return { id };
        } catch (err) {
          if (err instanceof SndrError) {
            return reply.code(err.status).send({
              error: { code: err.code, message: err.message },
            });
          }
          throw err;
        }
      },
    );
    
    await app.listen({ port: 3000 });
  2. Webhook receiver — per-route raw body parser

    Fastify's addContentTypeParser supports a 'parseAs: buffer' mode that hands the raw bytes to your handler. Combined with config.rawBody (or scoped to the webhook route only) this preserves the exact body SNDR signed.

    import Fastify from 'fastify';
    import { verifyWebhookSignature } from '@rkiza/sndr';
    import type { WebhookEventPayload } from '@rkiza/sndr';
    
    const app = Fastify({ logger: true });
    
    // Register a raw-body parser for application/json. By default Fastify
    // parses JSON; this REPLACES that behavior with raw-buffer access.
    // If you want JSON parsing for OTHER routes, register this parser only
    // on a route-prefixed Fastify instance via app.register() with a path
    // prefix, OR use fastify-raw-body for per-route scoping.
    app.addContentTypeParser(
      'application/json',
      { parseAs: 'buffer' },
      (_req, body, done) => done(null, body),
    );
    
    app.post('/webhooks/sndr', async (req, reply) => {
      const raw = req.body as Buffer;
      const ok = verifyWebhookSignature(
        raw,
        req.headers['x-sndr-signature'] as string ?? '',
        process.env.SNDR_WEBHOOK_SECRET!,
      );
      if (!ok) return reply.code(400).send('bad signature');
    
      const event = JSON.parse(raw.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;
      }
      return 'ok';
    });
    
    await app.listen({ port: 3001 });
  3. Mixed: JSON-parsed routes + raw-body webhook (with fastify-raw-body)

    The clean production answer: install fastify-raw-body and apply it ONLY to the webhook route via per-route config. The rest of your app keeps Fastify's standard JSON parsing.

    import Fastify from 'fastify';
    import rawBody from 'fastify-raw-body';
    import { verifyWebhookSignature, Sndr } from '@rkiza/sndr';
    
    const app = Fastify({ logger: true });
    
    await app.register(rawBody, {
      field: 'rawBody',         // accessible as req.rawBody
      global: false,            // off by default; opt in per-route
      encoding: false,          // give us a Buffer, not a string
      runFirst: true,           // runs before the default JSON parser
      routes: ['/webhooks/sndr'],
    });
    
    const sndr = new Sndr(process.env.SNDR_API_KEY!);
    
    // Normal JSON-parsed route — req.body is the parsed object.
    app.post<{ Body: { email: string } }>('/api/welcome', async (req) => {
      const { id } = await sndr.emails.send({
        from: 'hello@yourdomain.com',
        to: req.body.email,
        subject: 'Welcome',
        html: '<p>Welcome.</p>',
      });
      return { id };
    });
    
    // Webhook route — req.rawBody is the raw Buffer; req.body is also
    // parsed JSON (for convenience), but verify against the raw bytes.
    app.post('/webhooks/sndr', async (req, reply) => {
      const raw = (req as typeof req & { rawBody: Buffer }).rawBody;
      if (!verifyWebhookSignature(
        raw,
        req.headers['x-sndr-signature'] as string ?? '',
        process.env.SNDR_WEBHOOK_SECRET!,
      )) {
        return reply.code(400).send('bad signature');
      }
      return 'ok';
    });
    
    await app.listen({ port: 3000 });

Fastify FAQ

Do I need fastify-raw-body or can I do it with addContentTypeParser?

addContentTypeParser works app-wide; fastify-raw-body lets you scope raw-body access per-route. For most apps that mix normal JSON routes with a webhook receiver, fastify-raw-body is cleaner.

How do I queue sends in Fastify?

Same as Express — BullMQ on Redis is the standard. Instantiate the Worker outside Fastify (in a separate process or via fastify-plugin), and Fastify routes only dispatch jobs via the Queue.

Why does Fastify's schema validation matter for email?

Because email addresses, subjects, and HTML bodies all have validation rules. Letting Fastify reject malformed input before it reaches Sndr saves a round-trip and gives you cleaner error responses.

Ship your first Fastify email in 5 minutes.

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.