Event-Driven Email Microservice with NestJS and Twilio SendGrid

July 21, 2025
Written by
Oghenevwede Emeni
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Event-Driven Email Microservice with NestJS and Twilio SendGrid

A few years ago, when I moved from a smaller team to a larger one, I noticed everyone was obsessed with microservices, and I was even more surprised to see that this obsession extended to their email service structure. But it slowly started making sense to me when I realized how the basic flow I was used to quickly resulted in bottlenecks, especially when we had to add new email logic or maintain the codebase, because it was tied too closely to the core system. It became clear to me that for any system meant to scale and evolve rapidly, email services need to operate as a standalone, something like an event-driven layer.

In this post, you’ll learn how to build an email microservice using Twilio SendGrid and NestJS. You’ll map internal app events to SendGrid templates, trigger emails dynamically, and configure a webhook listener to track delivery, open, and bounce events in real time, all in a modular and scalable way.

Prerequisites

To complete the tutorial, you will need the following:

  • A free Twilio SendGrid account
  • Node.js Installation
  • NestJs installed on your machine. If you don’t already have it installed, you can install it by doing this npm i -g @nestjs/cli or going to the documentation to learn how
  • Basic knowledge of Node.js and NestJS, and how to set up a NestJs Project
  • A text editor like VS Code
  • An understanding of how environment variables work.
  • A verified sender email in your SendGrid account
  • Ngrok installed for local webhook testing

Setting up the NestJs project

Creating the project folder and generating the NestJS app

To get started, create a folder where your email microservice will live. So, in your terminal, navigate to the directory where you want to keep your project and run, then run the commands below:

mkdir email_microservice
cd email_microservice

If you do not have NestJs installed on your device, you can make use of the command below to set it up. Make sure you have Node.js (version >= 20) is installed on your operating system

npm i -g @nestjs/cli

Now that your folder is ready, use the Nest CLI to scaffold your project. This will give you a clean structure and all the boilerplate setup you need to build with NestJS. Use the command below to do this and replace project name with what you would like to call your project.

nest new project-name

If you're asked to choose a package manager, select either npm or yarn, whichever you prefer. Once that’s done, you’ll have a complete NestJS project initialized in the folder.

Installing dependencies

Once your project is generated, navigate into the newly created project folder. To do this, run the command below:

cd project-name

In that folder, you need to install some dependencies. These dependencies will be needed to send emails, load environment variables, and support event-based logic. For this, you need to install three packages:

npm install @sendgrid/mail @nestjs/config @nestjs/event-emitter
  • @sendgrid/mail: Allows your service to send transactional emails using Twilio SendGrid.
  • @nestjs/config: Lets you securely manage sensitive config values like API keys using a .env file.
  • @nestjs/event-emitter: Helps you trigger and listen for custom app events in a clean, decoupled way.

Creating the .env file

Before generating your SendGrid API key, you need to first create a .env file where you’ll securely store it. Now, in the root of your project( /project-name), create a new file called .env and add the following:

SENDGRID_API_KEY=
SENDGRID_SENDER_EMAIL=
  • SENDGRID_API_KEY: This key would be gotten on your SendGrid dashboard.
  • SENDGRID_SENDER_EMAIL: This must be a verified sender identity in your account. You can add one via Marketing - Senders in the SendGrid dashboard

Getting your SendGrid credentials

Now that you have created your .env file, go to your SendGrid dashboard to get the required credentials.

  • In the left-hand menu, go to Settings > API Keys
  • Click Create API Key
  • Name it something like Email Microservice Key or whatever you prefer
  • Choose Full Access (for simplicity)
  • Click Create & View, then copy the API key
  • Paste it into your .env under SENDGRID_API_KEY

You’ll also need to get your verified sender email. To get one:

  • In the left-hand menu, go to Marketing > Senders
  • Create a new sender if you don’t have one and wait for it to get approved
  • If you already have an email sender set up, paste that into SENDGRID_SENDER_EMAIL

Registering environment variables globally

Your app needs to access these variables you just added, to do that, update your main application module so NestJS can read the values in your .env file. Open src/app.module.ts and add the ConfigModule:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true })],
})
export class AppModule {}

This way you can access any of the variables in your env anywhere in your code, securely.

Building the Email Module

The email module handles your logic for sending transactional emails using Twilio SendGrid. To do that, create a new folder called email, in the root of your project folder ( /project-name) and within it a file called email.service.ts.

Now within this file, you need to make some necessary imports:

import { Injectable } from '@nestjs/common';
import * as sgMail from '@sendgrid/mail';
import { ConfigService } from '@nestjs/config';
  • @nestjs/common: Provides the Injectable decorator used for services.
  • @sendgrid/mail: This is the official Node.js SDK for interacting with Twilio SendGrid.
  • ConfigService: To read the values in your .env file.

Next, you need to define your service class.

@Injectable()
export class EmailService {
  constructor(private configService: ConfigService) {
	const apiKey = this.configService.get<string>('SENDGRID_API_KEY');
	sgMail.setApiKey(apiKey);
  }

This sets up EmailService as a NestJS provider and within the constructor:

  • The ConfigService is injected.
  • The SENDGRID_API_KEY is being called from the .env file.
  • The SendGrid SDK is initialized with that key so it can send emails on behalf of your service.

Next, you need to set up the methods that will be needed for sending the email. It will consist of to, which is the recipients email address, subject the subject line of your email, templateID the dynamic template ID of any template created on the Sendgrid dashboard and dynamicData a key-value object for dynamic fields in your template e.g { name: ‘Williams’ }

async sendEmail(to: string, subject: string, templateId: string, dynamicData: any) {
	const from = this.configService.get<string>('SENDGRID_SENDER_EMAIL');
	if (!from) {
  	    throw new Error('SENDGRID_SENDER_EMAIL is not defined in the configuration');
	}
	const msg = {
  	    to,
  	    from,
  	    subject,
  	    templateId,
  	    dynamicTemplateData: dynamicData,
	};
	try {
  	await sgMail.send(msg);
	} catch (error) {
  	console.error('SendGrid Error:', error.response?.body || error.message);
  	throw error;
	}
  }

Also included is a try and catch block. The try statement attempts to send an email using SendGrid with the provided message object, while the catch block logs any errors (including detailed SendGrid responses if available) and re-throws the error for further handling.

Next, you have to register the email module. To do this, create a new file in your email folder called email.module.tsand do the following:

  • Import your ConfigModule to allow access to your .env variables
  • Import you EmailService as a provider
  • Export your EmailService so that other parts of the app like your event handlers will be able to access it.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EmailService } from './email.service';
@Module({
  imports: [ConfigModule],
  providers: [EmailService],
  exports: [EmailService],
})
export class EmailModule {}

Creating the Event System

This section is responsible for the logic that listens for internal application events (like user.registered, order.shipped) and maps them to email templates. This makes the email system flexible and easy to extend, without modifying the core codebase. For example, if you're building an e-commerce app and add a gift card feature, the recipient can be notified when a gift card is sent to them. As long as the gift card event is mapped to an email template, the email notification will be sent automatically when the event is triggered.

Create the Event Mapper

Create a new directory called event, in the root of your project ( /project-name) and in this directory, a new file called event-mapper.ts. This is where all your registered service would sit. For the purpose of this article, we would assume you have 2 services called:

  • user.registered
  • payment.success
import { Injectable } from '@nestjs/common';
type EventConfig = {
  templateId: string;
  subject: string;
  toField: string;
};
@Injectable()
export class EventMapper {
  private events: Record<string, EventConfig> = {
	'user.registered': {
  	templateId: 'd-xxxxxxxxxxxx', 
  	subject: 'Welcome to My App!',
  	toField: 'email',
	},
	'payment.success': {
  	templateId: 'd-yyyyyyyyyyyy',
  	subject: 'Your Payment Was Successful',
  	toField: 'email',
	},
  };
  get(eventType: string): EventConfig | null {
	return this.events[eventType] || null;
  }
}

Here is what is going on above:

  • You have defined a type called EventConfig which stores the following
  • templateId: the ID of a dynamic SendGrid email template
  • subject: the email subject line
  • toField: which field in the incoming data to use as the recipient’s email address
  • Also events are being mapped and a .get() method is being provided to return the appropriate config for a given event type. This way new events can be onboarded by simply updating the map, no need to write more code or touch business logic.
If you do not have a template already, you can create one in your SendGrid dashboard. To do this, navigate to Email API - Dynamic Templates, then click Create a Dynamic Template. After naming your template and adding at least one version, you will be able to copy the Template ID and use it in your code.

Create the Event Service

Next, you need to create your event.service.ts in your events folder. So here, you are going to make use of the package EventEmitter2, which is used to emit and listen for custom app events. You will also need to import the EventMapper to match incoming events to templates

import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventMapper } from './event-mapper';
@Injectable()
export class EventService {
  constructor(
	private eventEmitter: EventEmitter2,
	private eventMapper: EventMapper,
  ) {}

Next, you need to add the logic for handling these events and for dispatching them as well. So in the method, here is what is going to be happening:

  • It will receive an incoming type and data payload
  • Then use the event mapper to check if it’s supported
  • The finally emit the event internally using NestJS’s event system (the reason why the event emitter package was installed)
handleIncomingEvent(type: string, data: any) {
	const config = this.eventMapper.get(type);
	if (!config) {
  	    throw new Error(`Unhandled event type: ${type}`);
	}
	this.eventEmitter.emit(type, data);
  }
}

With this, you are able to centralize how events are validated and dispatched, so that the email logic never touches the business logic directly.

Create the Event Module

The event module connects the event mapping and dispatch logic into a reusable feature module within NestJS.So, to do this, create a new file called event.module.ts in your event folder and paste the code below:

import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { EventService } from './event.service';
import { EventMapper } from './event-mapper';
import { EmailModule } from '../email/email.module';
@Module({
  imports: [EventEmitterModule.forRoot(), EmailModule],
  providers: [EventService, EventMapper],
  exports: [EventService],
})
export class EventModule {}

Here is what is going on:

  • EventEmitterModule.forRoot() Initializes Nest’s built-in event system. This would allow your app to emit and listen to custom events within the application.
  • EmailModule imports the previously created email module, so that event handlers can trigger email sending.
  • providers registers the EventService and EventMapper as injectable services.
  • exports makes the EventService available to other modules that import the event module (such as the controller).

Listen for events in the Email Service

To respond when an event is emitted (e.g., user.registered), you need to update the EmailService to listen for it and send the appropriate email. So head back to your email service file and update it with the code below:

@OnEvent('user.registered')
async handleUserRegistered(payload: any) {
  await this.sendEmail(
	payload.email,
	'Welcome to Our App!',
	'd-xxxxxxxxxxxx',
	{ name: payload.name }
  );
}
 @OnEvent('invoice.generated')
  async handleInvoiceGeneratedEvent(payload: any) {
	await this.sendEmail(
  	payload.email,
  	'Your Invoice is Ready',
  	'd-xxxxxxxxxxxx',
  	{ amount: payload.amount }
	);
  }

Also don’t forget to import the decorator OnEvent, which will allow the services to subscribe to specific events.

import { OnEvent } from '@nestjs/event-emitter';
Reminder: Replace d-xxxxxxxxxxxx with your actual template ID from the SendGrid dashboard.

Here is what this does:

  • @OnEvent('user.registered') registers this method to run whenever the user.registered event is emitted.
  • payload: any is the data associated with the event, and mostly includes fields like email and name.
  • sendEmail(...) uses the service’s existing method to send the email with the subject, template ID, and dynamic values.

Create a Controller to Trigger Events

Now you need a controller that exposes an HTTP endpoint so that other services or parts of your app can trigger internal events like user.registered. To do this in your app.controller.ts file, add the following code below:

import { Controller, Post, Body } from '@nestjs/common';
import { EventService } from './event/event.service';
@Controller()
export class AppController {
  constructor(private readonly eventService: EventService) {}
  @Post('events')
  triggerEvent(@Body() body: { type: string; data: any }) {
	return this.eventService.handleIncomingEvent(body.type, body.data);
  }
}

So this is what is going on:

  • @Controller() registers this class as a NestJS controller.
  • @Post('events') creates a route at POST /events. A way to access the endpoint.
  • @Body() accepts a JSON payload with two fields:
  • type the event name (e.g. "user.registered" or “payment.success”)
  • data the data needed by the email template (e.g. { email, name })

Inside the triggerEvent() method, the controller calls the handleIncomingEvent() method from EventService. So what’s happening is that it emits the event into your internal system, which is then picked up by the listener in EmailService.

Finally, double-check your app.module.ts to make sure all the necessary services and controllers have been imported successfully.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EmailModule } from './email/email.module';
import { EventModule } from './event/event.module';
import { AppController } from './app.controller';
@Module({
  imports: [
	ConfigModule.forRoot({ isGlobal: true }),
	EmailModule,
	EventModule,
  ],
  controllers: [AppController],
})
export class AppModule {}

Now time to test all you’ve put together so far. To do this you will test the register event you simulated earlier. The request body below, mimics what a backend service or another microservice might send when a new user registers.

{
  "type": "user.registered",
  "data": {
	"email": "ovwedeemeni@gmail.com",
	"name": "Ada Lovelace"
  }
}

In postman or any tool you are making use of, make a post request to

POST http://localhost:3000/events
Content-Type: application/json

If your setup and SendGrid template are correct, you’ll get an email in seconds.

New account creation

Now that all is good, you can move on to the webhook listener to track things such as opens, delivery etc.

Handle SendGrid Webhooks

SendGrid can notify your application about the status of sent emails via HTTP webhooks. These notifications include things like whether an email was delivered, opened, or bounced. In this section, you will learn how to expose a webhook endpoint that listens for those events.

Create the Webhook Controller

Start by creating a new folder called webhook in the root of your project (/project-name) and in that a file called webhook.controller.ts. In that file paste the code bellow:

import { Controller, Post, Body, Headers } from '@nestjs/common';
@Controller('webhook')
export class WebhookController {
  @Post('sendgrid')
  handleSendgridWebhook(@Body() events: any[], @Headers('user-agent') userAgent: string) {
	console.log('Received SendGrid Webhook from:', userAgent);
	for (const event of events) {
  	console.log(`[${event.event}] for ${event.email}`, event);
	}
	return { received: true };
  }
}

So here is what this controller does:

  • @Post('sendgrid') creates an endpoint at POST /webhook/sendgrid.
  • @Body() events: any[] allows SendGrid to send webhook payloads as an array of event objects.
  • @Headers('user-agent') extracts and logs the User-Agent header to verify requests.
  • The loop logs each event type (like delivered, open, bounce) and the email it applied to.
  • The response { received: true } confirms to SendGrid that the webhook was processed.

Register the Controller

Next, you need to register the controller in your app.module.ts.

import { WebhookController } from './webhook/webhook.controller';
@Module({
  controllers: [AppController, WebhookController],
})

Test Webhooks Locally with Ngrok

Since SendGrid cannot reach your localhost, use Ngrok to expose your local server to the internet. Run your existing application in your terminal, and open another terminal in your vscode to run the command below to allow ngrok generate a url for you.

npx ngrok http 3000

This creates a public HTTPS tunnel to your local port 3000, which your NestJS app uses.

This will generate a URL like:

https://abcd-1234.ngrok-free.app

You will need this to configure your Sendgrid Webhook for testing

If you do not have Ngrok installed, follow these steps:

# On macOS using Homebrew
brew install ngrok
# On Windows or Linux
# Visit https://ngrok.com/download and install the CLI manually

Configure Webhook in SendGrid

To do this:

  • Go to your SendGrid Dashboard.
  • Go to Settings, Mail Settings, click on Event Webhook.
  • Click on Create New Webhook
  • Add a name and paste the link gotten from Ngrok, with this attached to the suffix - webhook/sendgrid

Your webhook should look like -

https://abcd-1234.ngrok-free.app/webhook/sendgrid
  • Under Event Notifications, enable the actions to be posted. E.g, Processed, Delivered, Opened, Clicked or Bounced.
  • Save the Configuration
Creating a new event webhook

Testing and troubleshooting

After completing the setup, it’s important to verify that the email microservice behaves as expected and that webhook events are received correctly.

Sending a test event

To trigger an email, send a POST request to the /events endpoint. This would simulate a real app firing an event like user.registered.

In postman or any tool you are making use of, make a post request to

POST http://localhost:3000/events
Content-Type: application/json

Then in the body add this.

{
  "type": "user.registered",
  "data": {
	"email": "adalove@gmail.com",
	"name": "Ada Lovelace"
  }
}

If the setup is correct:

  • An email should be sent to adalove@gmail.com (or the email address you inputted)
  • The email message should use your SendGrid dynamic template
  • The terminal should show a user.registered event being received and an email sent

Viewing webhook logs

If webhooks are configured correctly and your server is exposed using Ngrok, SendGrid will send event notifications to /webhook/sendgrid. In your terminal, look for logs like:

Webhook Response Example

These logs come from the WebhookController and confirm that webhook events are getting to your application.

While testing here are some common issues you may encounter and how to resolve them:

Emails not arriving

  • Check if SENDGRID_SENDER_EMAIL is verified in your account.
  • Make sure you're using a real, deliverable recipient email address.
  • Template ID errors:
  • Double-check that your template ID is copied correctly from the SendGrid dashboard.
  • Make sure the template has at least one active version.

Webhook events not received

  • Confirm your Ngrok URL is active and correct in the SendGrid webhook settings. To test, you can swap your localhost:3000 url on Postman while testing to see if it delivers the email as it is supposed to.
  • Make sure ngrok is not closed or paused.
  • Check that your WebhookController is registered in app.module.ts.

Unhandled event type error:

  • If you see Unhandled event type, it means the EventMapper does not have a matching config for the type field sent in your request.
  • Add the event type and template config to event-mapper.ts.

Conclusion

Congratulations on setting up a complete event-driven email system using NestJS and Twilio SendGrid! You’ve learned how to trigger transactional emails from internal events, build a plug-and-play event mapper, and configure SendGrid webhooks to track delivery, opens, and bounces in real time.

If you’d like to explore the finished version of this microservice, you can check it out on GitHub here.

Email infrastructure should be treated with care. Remember email sending is really monitored, so try not to spam people or send the wrong thing. As a follow up you should consider checking out the Twilio Sendgrid's documentation and blog to learn more. Learn about features like IP warming and sender reputation management to improve the chances of your mails landing in your user' inbox and not getting blocked. You can also explore more advanced email flows like failure retries, A/B testing, and user-level analytics.

Thanks for reading!

Oghenevwede is a Senior Product Engineer with 8 years of obsessing about how things work on her phone and on the web. Her new obsession is health and wellness.