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.
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.
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.
The from address has to be on a domain whose records you have published. The quickstart walks through it.
npm install notix-js @nestjs/config class-validator class-transformer
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
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
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 {}
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
import { IsEmail, IsString, Length } from "class-validator";
export class SendReceiptDto {
@IsString()
@Length(1, 64)
orderId!: string;
@IsEmail()
to!: string;
}
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
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
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,
);
}
}
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
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
}
}
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
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
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 {}
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.
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.
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.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesThe SDK, plain fetch, or Nodemailer pointed at the relay: three ways to send from Node in a few minutes.
GuidesA route that sends with notix-js, the raw-body webhook receiver, and the same idempotency and error handling as the Node guide.
Use casesProduct notifications from templates, batched when they fan out, with delivery events back through webhooks.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Verify a domain, copy an API key, start the app. The free plan does not ask for a card.