Skip to main content
Guide

Send email from Express, Fastify, Next.js

Send email from a Node.js server: Express and Fastify routes, Next.js App Router handlers, NestJS providers, and webhook receivers that verify.

mailkube 6 min read Updated August 18, 2026
sdkwebhooks

The send call itself is one line in any framework. What differs is where the client lives, whether the API key can leak into a client bundle, and what your framework does to the request body before your webhook handler sees it. That last one accounts for most of the time people lose here.

To send email from a Node.js Express server, or Fastify, or Next.js, or NestJS, you build the client once and reuse it. The rest is routing.

The short answer

npm install @mailkube/mailkube-node

Create the client at module scope, not per request. It holds configuration and nothing per-call, so one instance serves the whole process:

// lib/mailkube.js
import { Mailkube } from '@mailkube/mailkube-node';

export const mailkube = new Mailkube();   // reads MAILKUBE_API_KEY

Then import it wherever you send. Every example below assumes that module.

Send email from Express

A send inside a route, and the response going back before the mail has been delivered anywhere:

import express from 'express';
import { mailkube } from './lib/mailkube.js';

const app = express();
app.use(express.json());

app.post('/orders', async (req, res) => {
  const order = await createOrder(req.body);

  const email = await mailkube.emails.send({
    from: 'billing@example.com',
    to: order.customerEmail,
    subject: 'Your receipt',
    html: `<p>Thanks for order ${order.id}.</p>`,
    idempotencyKey: `receipt-${order.id}`,
    tags: [{ name: 'kind', value: 'receipt' }],
  });

  res.status(201).json({ orderId: order.id, emailId: email.id });
});

The webhook receiver is the part that needs care. express.json() parses the body and throws the original bytes away, and the signature covers those exact bytes, so a parsed body can never verify. Mount express.raw on the webhook path only:

import { verify } from '@mailkube/mailkube-node';

app.post('/webhooks/mailkube', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = await verify(req.body, req.headers, process.env.MAILKUBE_WEBHOOK_SECRET);

  if (event.type === 'email.bounced') {
    await suppress(event.data.bounce.reason);
  }

  res.sendStatus(204);
});

Order matters. If app.use(express.json()) runs before this route, it has already consumed the body.

Send email from Fastify

Fastify parses JSON by default too, so the webhook route needs its own content type parser. Register it inside an encapsulated plugin and the change stays local to that scope instead of altering every route in the app:

import Fastify from 'fastify';
import { verify } from '@mailkube/mailkube-node';
import { mailkube } from './lib/mailkube.js';

const fastify = Fastify();

fastify.post('/orders', async (request, reply) => {
  const email = await mailkube.emails.send({
    from: 'billing@example.com',
    to: request.body.email,
    subject: 'Your receipt',
    html: '<p>Thanks for your order.</p>',
  });

  return reply.code(201).send({ emailId: email.id });
});

await fastify.register(async (scope) => {
  scope.addContentTypeParser(
    'application/json',
    { parseAs: 'buffer' },
    (request, body, done) => done(null, body),
  );

  scope.post('/webhooks/mailkube', async (request, reply) => {
    const event = await verify(request.body, request.headers, process.env.MAILKUBE_WEBHOOK_SECRET);
    await handle(event);
    return reply.code(204).send();
  });
});

Send email from Next.js

Use a route handler under app/, and keep the client in a server module. This is the framework where the key is most likely to escape, because the boundary between server and client code is a convention rather than a process:

// app/api/orders/route.ts
import { mailkube } from '@/lib/mailkube';

export async function POST(request: Request) {
  const { email: to, orderId } = await request.json();

  const email = await mailkube.emails.send({
    from: 'billing@example.com',
    to,
    subject: 'Your receipt',
    html: `<p>Thanks for order ${orderId}.</p>`,
    idempotencyKey: `receipt-${orderId}`,
  });

  return Response.json({ id: email.id }, { status: 201 });
}

Never import that module from a client component, and never put the key in a NEXT_PUBLIC_ variable, which is inlined into the browser bundle at build time. A server action works the same way and carries the same risk: the function body stays on the server, but anything it imports at module scope has to be server-only too.

The webhook handler reads the raw body itself, so there is no parser to work around:

// app/api/webhooks/mailkube/route.ts
import { verify } from '@mailkube/mailkube-node';

export async function POST(request: Request) {
  const body = new Uint8Array(await request.arrayBuffer());
  const event = await verify(body, request.headers, process.env.MAILKUBE_WEBHOOK_SECRET!);

  await handle(event);
  return new Response(null, { status: 204 });
}

Both handlers run unchanged on the edge runtime, because the SDK uses no Node built-ins. Sending email from a serverless function covers what changes when you deploy them that way.

Send email from NestJS

Wrap the client in a module so it can be injected and swapped for a fake in tests:

// mailkube.module.ts
import { Module } from '@nestjs/common';
import { Mailkube } from '@mailkube/mailkube-node';

export const MAILKUBE = Symbol('MAILKUBE');

@Module({
  providers: [{ provide: MAILKUBE, useFactory: () => new Mailkube() }],
  exports: [MAILKUBE],
})
export class MailkubeModule {}
// receipts.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { Mailkube } from '@mailkube/mailkube-node';
import { MAILKUBE } from './mailkube.module';

@Injectable()
export class ReceiptsService {
  constructor(@Inject(MAILKUBE) private readonly mailkube: Mailkube) {}

  async sendReceipt(order: Order) {
    return this.mailkube.emails.send({
      from: 'billing@example.com',
      to: order.customerEmail,
      subject: 'Your receipt',
      html: `<p>Thanks for order ${order.id}.</p>`,
      idempotencyKey: `receipt-${order.id}`,
    });
  }
}

Injecting through a token rather than importing the client directly is what makes the service testable. In a unit test you provide an object with an emails.send stub and assert on the parameters, without a network call and without a key in your test environment.

For webhooks, create the app with rawBody enabled and read request.rawBody in the controller:

const app = await NestFactory.create(AppModule, { rawBody: true });

Nest buffers the untouched body onto the request when that option is set, and leaves normal JSON parsing in place for every other route.

Types

The package is written in TypeScript and ships its own declarations, so there is nothing to install alongside it. SendEmailParams and Email cover the send path. Webhook events are a discriminated union, which means switch on event.type narrows event.data for you:

import type { WebhookEvent } from '@mailkube/mailkube-node';

function handle(event: WebhookEvent) {
  switch (event.type) {
    case 'email.delivered':
      return record(event.data.delivery);
    case 'email.bounced':
      return suppress(event.data.bounce.reason);
    case 'unknown':
      return log(event.eventType, event.raw);
  }
}

That last arm is the reason a new event type will not break a deployed handler. An event this version of the SDK does not recognise arrives as type: "unknown" with the server’s own name on eventType and the untouched payload on raw, so narrowing on every other case keeps working. type stays a fixed literal set, which is what lets the compiler go on checking your existing branches while the server gains events yours has never heard of.

Timestamps come back as the strings the API sent, not as Date objects. That is deliberate, so a round trip through your own storage cannot shift a value by a timezone. Call new Date(value) at the point you actually need one.

Common mistakes

  • Parsing the body before verifying the signature. The most common failure by a distance. The signature covers the bytes that arrived, so any parse and re-serialize breaks it, whatever the JSON looks like afterwards.
  • A body parser registered globally, ahead of the webhook route. In Express this is mount order. In Fastify it is scope. Both are silent: you get a verification failure, not a warning.
  • The API key in a NEXT_PUBLIC_ variable or imported from a client component. It ships to the browser, and anybody can then send as your domain until you revoke it.
  • Blocking the response on the send. A long-running server can queue the message and answer immediately. Do that when the user is waiting on the request and the email is not what they asked for.
  • No idempotencyKey on a route something else retries. Job runners and API gateways retry. Without a key, a retry is a second message in someone’s inbox.

Where to go next

The Node SDK reference has the full parameter list, and the webhooks guide covers the event catalogue, retry behaviour and the signature scheme. If you have not sent anything yet, sending your first email from Node.js starts from domain verification.

Start sending in minutes

Create an account, verify a domain, and send your first message today.

Get started