Notix
Guides

Send email from NestJS with a service, a DTO and a webhook.

NestJS wants the client behind a provider, the input behind a DTO and the failure behind an exception, and that is exactly the shape a production send through the email API should have anyway. This guide is seven small files: the service and module, the DTO and controller, the exception that maps the API’s error envelope, the webhook controller that reads the raw body, and the bootstrap that makes the raw body available.

What you need

A key, a domain, four packages.

An API key.

Created in the dashboard under API keys, shown once, read from NOTIX_API_KEY through ConfigService. A sending access key is enough for a service that only sends.

A verified domain.

The from address has to be on a domain whose records you have published. The quickstart walks through it.

The packages.

terminal
npm install notix-js @nestjs/config class-validator class-transformer
Steps

Seven files, each doing one thing.

  1. The service and its module.

    One client per process, built from ConfigService. getOrThrow means a missing key stops the app at boot rather than inside the first request.

    src/notix/notix.service.ts
    // src/notix/notix.service.ts
    import { Injectable } from "@nestjs/common";
    import { ConfigService } from "@nestjs/config";
    import { Notix } from "notix-js";
    
    @Injectable()
    export class NotixService {
      private readonly client: Notix;
    
      constructor(config: ConfigService) {
        // getOrThrow: a missing key fails at boot, not inside a request.
        const apiKey = config.getOrThrow<string>("NOTIX_API_KEY");
        // Pointing at a different Notix base URL (a staging environment, say)?
        // Read it from config and pass it as the second argument.
        this.client = new Notix(apiKey);
      }
    
      get emails() {
        return this.client.emails;
      }
    
      webhooks(secret: string) {
        return this.client.webhooks(secret);
      }
    }
    
    src/notix/notix.module.ts
    // src/notix/notix.module.ts
    import { Module } from "@nestjs/common";
    import { ConfigModule } from "@nestjs/config";
    import { NotixService } from "./notix.service";
    
    @Module({
      imports: [ConfigModule],
      providers: [NotixService],
      exports: [NotixService],
    })
    export class NotixModule {}
    
  2. The DTO.

    class-validator rejects a bad address before the API is called, and whitelist: true on the global pipe drops any field you did not declare.

    src/receipts/send-receipt.dto.ts
    // src/receipts/send-receipt.dto.ts
    import { IsEmail, IsString, Length } from "class-validator";
    
    export class SendReceiptDto {
      @IsString()
      @Length(1, 64)
      orderId!: string;
    
      @IsEmail()
      to!: string;
    }
    
  3. The controller that sends.

    An idempotency key built from the order id, a 202 because the message is queued rather than delivered, and the SDK’s error thrown as one exception type.

    src/receipts/receipts.controller.ts
    // src/receipts/receipts.controller.ts
    import { Body, Controller, HttpCode, Post } from "@nestjs/common";
    import { NotixService } from "../notix/notix.service";
    import { NotixApiException } from "../notix/notix-api.exception";
    import { SendReceiptDto } from "./send-receipt.dto";
    
    @Controller("receipts")
    export class ReceiptsController {
      constructor(private readonly notix: NotixService) {}
    
      @Post()
      @HttpCode(202)
      async send(@Body() dto: SendReceiptDto) {
        const { data, error } = await this.notix.emails.send(
          {
            from: "receipts@acme.com",
            to: dto.to,
            templateId: "tpl_receipt_v4",
            variables: { orderId: dto.orderId },
          },
          // One key per business event, so a retried request can never send twice.
          { idempotencyKey: `order-${dto.orderId}-receipt` },
        );
    
        if (error) throw new NotixApiException(error);
        return { emailId: data.emailId };
      }
    }
    
    src/notix/notix-api.exception.ts
    // src/notix/notix-api.exception.ts
    import { HttpException } from "@nestjs/common";
    
    // The API answers every failure as { code, message }. A 401 or 403 from
    // Notix is our key, not the caller's problem, so it surfaces as a 500.
    const STATUS: Record<string, number> = {
      BAD_REQUEST: 400,
      UNAUTHORIZED: 500,
      FORBIDDEN: 500,
      NOT_FOUND: 404,
      NOT_UNIQUE: 409,
      RISK_REFUSED: 422,
      INSUFFICIENT_BALANCE: 402,
      RATE_LIMITED: 429,
      INTERNAL_SERVER_ERROR: 502,
      SERVICE_UNAVAILABLE: 503,
    };
    
    export class NotixApiException extends HttpException {
      constructor(error: { code: string; message: string }) {
        const status = STATUS[error.code] ?? 500;
        super(
          { error: status >= 500 ? "Email could not be sent" : error.message, code: error.code },
          status,
        );
      }
    }
    
  4. The webhook controller, on the raw body.

    constructEvent verifies the HMAC-SHA256 signature over the timestamp and the exact bytes, so the controller reads req.rawBody, the Buffer Nest keeps when the app is created with rawBody: true. This is NestJS’s documented pattern for webhook signature verification.

    src/webhooks/webhooks.controller.ts
    // src/webhooks/webhooks.controller.ts
    import { BadRequestException, Controller, HttpCode, Post, Req } from "@nestjs/common";
    import { ConfigService } from "@nestjs/config";
    import type { RawBodyRequest } from "@nestjs/common";
    import type { Request } from "express";
    import { NotixService } from "../notix/notix.service";
    
    @Controller("webhooks")
    export class WebhooksController {
      private readonly secret: string;
    
      constructor(private readonly notix: NotixService, config: ConfigService) {
        this.secret = config.getOrThrow<string>("NOTIX_WEBHOOK_SECRET");
      }
    
      @Post("notix")
      @HttpCode(200)
      handle(@Req() req: RawBodyRequest<Request>) {
        // req.rawBody is the Buffer Nest kept because main.ts passed rawBody: true.
        if (!req.rawBody) throw new BadRequestException("raw body missing");
    
        let event;
        try {
          event = this.notix.webhooks(this.secret).constructEvent(req.rawBody, { headers: req.headers });
        } catch {
          throw new BadRequestException("invalid signature");
        }
    
        switch (event.type) {
          case "email.delivered":
            // event.data.id is the emailId the send returned
            break;
          case "email.bounced":
            // event.data.bounce.type === "Permanent": the address is now suppressed
            break;
        }
        return { ok: true }; // answer fast, do the work on a queue
      }
    }
    
  5. Bootstrap and module.

    rawBody: true at creation; the built-in JSON parser stays on, which the raw-body feature requires. ConfigModule.forRoot reads .env in development and the platform’s variables in production.

    src/main.ts
    // src/main.ts
    import { NestFactory } from "@nestjs/core";
    import { ValidationPipe } from "@nestjs/common";
    import type { NestExpressApplication } from "@nestjs/platform-express";
    import { AppModule } from "./app.module";
    
    async function bootstrap() {
      // rawBody: true keeps the unparsed bytes on req.rawBody for the webhook
      // signature; the built-in JSON parser stays on for everything else.
      const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
      app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
      await app.listen(process.env.PORT ?? 3000);
    }
    bootstrap();
    
    src/app.module.ts
    // src/app.module.ts
    import { Module } from "@nestjs/common";
    import { ConfigModule } from "@nestjs/config";
    import { NotixModule } from "./notix/notix.module";
    import { ReceiptsController } from "./receipts/receipts.controller";
    import { WebhooksController } from "./webhooks/webhooks.controller";
    
    @Module({
      imports: [ConfigModule.forRoot({ isGlobal: true }), NotixModule],
      controllers: [ReceiptsController, WebhooksController],
    })
    export class AppModule {}
    
In production

What is already covered, and what is not.

The controller above already carries the three things a production send needs: the idempotency key, the error envelope, and a signed webhook. The reasoning behind each is on the Node.js guide; batch sends, scheduling and templates are on the production guide for transactional email. What NestJS adds is the raw-body option and the exception mapping, both on this page.

Answer the webhook fast.

Notix retries a non-2xx answer six times with backoff and disables an endpoint after 30 consecutive failures. Return the 200 and hand the event to a queue processor; the signature verification page has the headers, the tolerance and replay protection.

Test the service, not the network.

NotixService is the one seam: in a unit test, provide a fake with the same emails.send shape returning { data, error }, and the controller and exception mapping are testable without an API key. Keep one integration test that sends a real message to your own address on the free plan.

FAQ

Questions, answered.

How do I send email from a NestJS application?
Wrap the notix-js client in an injectable NotixService that reads NOTIX_API_KEY from ConfigService with getOrThrow, export it from a NotixModule, and inject it into the controller that sends. The controller validates a DTO with class-validator, calls notix.emails.send with { idempotencyKey } keyed on the business event, and throws a small HttpException subclass when the SDK returns error. The files on this page are the whole setup.
How do I verify a Notix webhook in NestJS?
The signature is an HMAC-SHA256 over the timestamp and the exact request bytes, so the controller needs the unparsed body. Pass { rawBody: true } to NestFactory.create, keep the built-in body parser on, and type the request as RawBodyRequest<Request>: req.rawBody is then a Buffer you hand to constructEvent together with req.headers. This is NestJS's documented pattern for webhook signatures and works with both the Express and Fastify adapters.
Why not disable the body parser for the webhook route?
Because rawBody: true depends on it. NestJS keeps a copy of the raw bytes while its global parser runs, and it cannot do that if bodyParser: false is passed at creation. Leave the parser on and read req.rawBody; the parsed req.body is still there if you want it, but only rawBody verifies.
How do I turn Notix errors into NestJS responses?
The SDK returns { code, message } instead of throwing. Throw an HttpException subclass that maps the code to a status: BAD_REQUEST 400, NOT_UNIQUE 409, RISK_REFUSED 422, INSUFFICIENT_BALANCE 402, RATE_LIMITED 429, and the two that mean your own key is wrong, UNAUTHORIZED and FORBIDDEN, as a 500 with the code logged. Nest's default exception filter serialises it; no custom filter is needed.
Should the send happen in the controller or in a queue?
For a receipt or a reset, the controller is fine: the API answers as soon as the message is queued on Notix's side, and the idempotency key already makes a retry safe. Use a BullMQ processor or a Nest queue when one request fans out to many emails or you want your own retry schedule; keep the same idempotency key on every attempt. Batch sends of up to 100 messages are one call.
Does this work with the Fastify adapter?
Yes. rawBody: true is also accepted when you create the app with a FastifyAdapter, and RawBodyRequest works the same way. Fastify's default JSON body limit is 1 MiB, which Notix webhook payloads are well under.

Send the first one now.

Verify a domain, copy an API key, start the app. The free plan does not ask for a card.