> ## Documentation Index
> Fetch the complete documentation index at: https://nuntly.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Send email from NestJS

> Send transactional email from a NestJS app with the Nuntly TypeScript SDK: register the client as a provider, send from a service, and verify delivery webhooks.

Send a transactional email from a NestJS service, then receive its delivery events in a signed webhook handler. The steps assume you already run a NestJS API.

You add one dependency, `@nuntly/sdk`, edit four files from the `nest new` scaffold, and create one controller. The `Nuntly` client goes straight into the NestJS dependency injection container. The samples use the standard `Nuntly` client, which throws typed exceptions, and catch them with `try`/`catch`.

## Prerequisites

* A Nuntly account. The free plan includes 3,000 emails per month (100 per day) and needs no credit card. See [account setup](https://nuntly.com/docs/guides/account-setup.md).
* A verified sending domain. See [sending domains](https://nuntly.com/docs/guides/sending-domains.md).
* An API key with the **Sending only** permission, stored in the `NUNTLY_API_KEY` environment variable. See [API keys](https://nuntly.com/docs/guides/api-keys.md).
* Node.js 20 or later, which the SDK requires.

## Install and configure the SDK

**1. Install the SDK**

From your NestJS project root:

```bash npm theme={null}
npm install @nuntly/sdk
```

```bash pnpm theme={null}
pnpm add @nuntly/sdk
```

```bash yarn theme={null}
yarn add @nuntly/sdk
```

```bash bun theme={null}
bun add @nuntly/sdk
```

The SDK has zero runtime dependencies and ships both ES module and CommonJS entry points.

**2. Add your secrets to a .env file**

Create `.env` at the project root and add it to `.gitignore`:

```bash .env theme={null}
NUNTLY_API_KEY=ntly_your_api_key
NUNTLY_WEBHOOK_SECRET=whsec_your_signing_secret
```

The Nest CLI loads this file with the `--env-file` flag:

```bash theme={null}
npx nest start --watch --env-file .env
```

**3. Register the client as a provider**

Register `Nuntly` with a factory provider in `src/app.module.ts`. The class is its own injection token, so any constructor can ask for it by type:

```typescript src/app.module.ts theme={null}
import { Module } from '@nestjs/common';
import { Nuntly } from '@nuntly/sdk';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { WebhooksController } from './webhooks.controller.js';

@Module({
  controllers: [AppController, WebhooksController],
  providers: [
    AppService,
    {
      provide: Nuntly,
      useFactory: () => new Nuntly({ apiKey: process.env.NUNTLY_API_KEY }),
    },
  ],
})
export class AppModule {}
```

The factory runs when Nest boots the module. If the key is missing, the SDK throws `API key is required` at startup, so a misconfigured deployment fails before the first send. See [custom providers](https://docs.nestjs.com/fundamentals/custom-providers) in the NestJS documentation for the factory pattern.

> [!NOTE]
>
> NestJS 12 scaffolds an ES module project, so relative imports end in `.js` and `main.ts` uses a top-level `await`. The NestJS 11 scaffold uses CommonJS: drop the `.js` suffix from relative imports and call `bootstrap()` without `await`.

## Send your first email

Put the send in a service and keep the controller thin. `AppService` receives the client through its constructor:

```typescript src/app.service.ts theme={null}
import { Injectable } from '@nestjs/common';
import { Nuntly } from '@nuntly/sdk';

@Injectable()
export class AppService {
  constructor(private readonly nuntly: Nuntly) {}

  sendWelcomeEmail(userId: string, to: string) {
    return this.nuntly.emails.send(
      {
        from: 'Acme <hello@yourdomain.com>',
        to,
        subject: 'Welcome to Acme',
        html: '<p>Your account is ready. Sign in to get started.</p>',
        text: 'Your account is ready. Sign in to get started.',
        tags: [{ name: 'category', value: 'welcome' }],
      },
      { idempotencyKey: `welcome-${userId}` },
    );
  }
}
```

* `from` must use your verified domain. It accepts a display name (`Acme <hello@...>`) in the [RFC 5322](https://nuntly.com/rfc/message-format) format.
* `html` and `text` together give mail clients that block HTML a readable version.
* `tags` label the email. They show on its detail page in the dashboard and in every webhook event for it.
* `idempotencyKey` binds the send to one user. See [idempotency keys](#idempotency-keys).

Expose the service from the controller and translate SDK errors into an HTTP response:

```typescript src/app.controller.ts theme={null}
import { BadGatewayException, Body, Controller, Logger, Param, Post } from '@nestjs/common';
import { APIConnectionError, APIError } from '@nuntly/sdk';
import { AppService } from './app.service.js';

@Controller('users')
export class AppController {
  private readonly logger = new Logger(AppController.name);

  constructor(private readonly appService: AppService) {}

  @Post(':id/welcome')
  async sendWelcome(@Param('id') id: string, @Body() body: { email: string }) {
    try {
      const email = await this.appService.sendWelcomeEmail(id, body.email);
      return { id: email.id, status: email.status };
    } catch (error) {
      if (error instanceof APIError) {
        this.logger.error(`Nuntly rejected the send: ${error.status} ${error.code} (request ${error.requestId})`);
      } else if (error instanceof APIConnectionError) {
        this.logger.error(`Nuntly unreachable: ${error.name}`);
      }
      throw new BadGatewayException('Email could not be sent');
    }
  }
}
```

> [!NOTE]
>
> The scaffold's `src/app.controller.spec.ts` and `test/app.e2e-spec.ts` test the `getHello()` method this page replaces. Update or delete them before you run `npm test`.

Start the app and call the route:

```bash theme={null}
curl -X POST http://localhost:3000/users/42/welcome \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com"}'
```

The response contains the email ID and its first status:

```json theme={null}
{ "id": "em_01ka8k8s80gvx9604cn9am5st4", "status": "queued" }
```

`queued` means Nuntly accepted the email. Delivery events arrive afterwards.

## Observe what happened

After the API accepts an email, it moves through statuses such as `queued`, `processed`, and `sent`, then reaches an outcome such as `delivered`, `bounced`, or `complained`.

### Email timeline in the dashboard

In the dashboard, open **Emails**, select the email, and click **Email Timeline** to see each event with its timestamp. From code, `nuntly.emails.events.list(id)` returns the same history. It needs a **Full access** API key: a **Sending only** key cannot read emails.

Nuntly keeps emails and their events for your organization's [retention window](https://nuntly.com/docs/guides/data-retention.md), 30 days by default.

### Receive webhooks in a NestJS controller

**1. Create the webhook**

Create a webhook in the dashboard as described in the [webhooks guide](https://nuntly.com/docs/guides/webhooks.md). Use `https://your-api.example.com/webhooks/nuntly` as the endpoint URL and select the `email.*` events you need. Copy the signing secret into `NUNTLY_WEBHOOK_SECRET`. The dashboard shows it only once.

**2. Keep the raw request body**

Nuntly signs each request with HMAC-SHA256 over the signing timestamp, a dot, and the raw request body, and sends the result in the `webhook-signature` header. NestJS parses JSON bodies before your controller runs, so enable `rawBody` in `src/main.ts` to keep the original bytes:

```typescript src/main.ts theme={null}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { rawBody: true });
  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
```

Nest still parses `req.body` for every route and also exposes the unparsed bytes as `req.rawBody`. See [raw body](https://docs.nestjs.com/faq/raw-body) in the NestJS documentation.

**3. Verify and handle events**

`verifyWebhook` from the SDK checks the signature, rejects timestamps more than 5 minutes away from the current time, and returns a typed event:

```typescript src/webhooks.controller.ts theme={null}
import { Controller, Headers, HttpCode, Logger, Post, Req, UnauthorizedException, type RawBodyRequest } from '@nestjs/common';
import { verifyWebhook, WebhookVerificationError, type WebhookEvent } from '@nuntly/sdk';
import type { Request } from 'express';

@Controller('webhooks')
export class WebhooksController {
  private readonly logger = new Logger(WebhooksController.name);

  @Post('nuntly')
  @HttpCode(200)
  async handle(@Req() req: RawBodyRequest<Request>, @Headers('webhook-signature') signature: string) {
    let event: WebhookEvent;
    try {
      event = await verifyWebhook(req.rawBody ?? '', signature, process.env.NUNTLY_WEBHOOK_SECRET ?? '');
    } catch (error) {
      if (error instanceof WebhookVerificationError) {
        this.logger.warn(`Webhook rejected: ${error.message}`);
        throw new UnauthorizedException();
      }
      throw error;
    }

    switch (event.type) {
      case 'email.delivered':
        this.logger.log(`Email ${event.data.id} delivered`);
        break;
      case 'email.bounced':
        this.logger.warn(`Email ${event.data.id} bounced (${event.data.bounce.bounceType})`);
        break;
      case 'email.complained':
        this.logger.warn(`Email ${event.data.id} marked as spam`);
        break;
      default:
        this.logger.log(`Unhandled event ${event.type}`);
    }

    return { received: true };
  }
}
```

`WebhookEvent` is a discriminated union, so TypeScript narrows `event.data` in each `case`. A missing or invalid signature returns `401` and logs the reason, for example `Missing webhook signing secret` or `Webhook signature verification failed`.

Nuntly treats any `2xx` response as a successful delivery. NestJS answers `POST` routes with `201` by default; `@HttpCode(200)` makes the response explicit. Return quickly and queue any slow processing.

Nuntly retries failed deliveries, and the **Replay** button in the [Webhooks tab](https://nuntly.com/docs/guides/observability-webhooks.md) of the observability dashboard sends an event again. Both can deliver the same event more than once, so deduplicate on the top-level `event.id` before acting on it.

For local testing, expose port 3000 through a tunnel such as ngrok and use the tunnel URL as the endpoint. The full list of events and payloads is in [sending events](https://nuntly.com/docs/guides/sending-webhooks.md).

## Production notes

### Idempotency keys

An idempotency key lets the API recognize a repeated request: a second request with the same key within 24 hours returns the first response instead of sending another email. The SDK generates a random key for each `emails.send` call, which makes its own retries safe.

Your own code can still call `send` twice, for example when a job runs again after a restart, and each call gets a new random key. Derive the key from the business event instead, like `welcome-${userId}`. Keys are 8 to 255 characters. See [idempotency keys](https://nuntly.com/docs/guides/idempotency.md) for other strategies.

> [!WARNING]
>
> The API stores a `4xx` response under its key for 24 hours. If a send fails because the domain is not verified yet, retrying with the same key returns the same error. Fix the cause, then use a new key. Reusing a key with a different request body returns `409` with the code `idempotency_key_payload_mismatch`.

### Retries

The SDK retries a request that failed for a temporary reason, such as a rate limit, a server error, a timeout or a network error, and waits longer before each new attempt. A send carries an idempotency key, yours or one the SDK generates, so a retry cannot send the email twice. An error that a retry cannot fix, such as an invalid payload or a revoked API key, reaches your code at once. The [SDK README](https://github.com/nuntly/nuntly-sdk-typescript#retry-configuration) lists the exact rules.

Change the defaults with the `maxRetries` and `retry` client options, globally or per request. Avoid adding your own retry loop on top of the SDK's.

### Error handling

Every `APIError` exposes `status`, `code`, `title`, and `requestId`. `APIConnectionError` covers timeouts and network failures, which have no HTTP status. The controller above logs these fields and returns `502 Bad Gateway` for any failed send. Include the `requestId` when you contact support.

### Separate environments

* Create one API key per environment, named after it ("Production", "Staging").
* In production, use a **Sending only** key restricted to your production domain. A leaked key can then send email but cannot read emails, change domains, or configure webhooks. See [permissions](https://nuntly.com/docs/guides/api-keys.md#permissions).
* Create a separate webhook endpoint and signing secret per environment.

### What not to log

Recipient email addresses are personal data. Your logs live in a separate system with its own retention and access rules, so keep addresses out of them.

* Log the email ID, the event ID, the event type, the HTTP status, the error code, and the request ID. The email ID leads to everything else in the dashboard.
* Do not log `to`, `cc`, `bcc`, `from`, subjects, message bodies, or raw webhook payloads.

## Next steps

> [card] [Handle webhook events](https://nuntly.com/docs/guides/integrate-webhooks.md)
>
> Signature verification details and webhook best practices

> [card] [Sending events](https://nuntly.com/docs/guides/sending-webhooks.md)
>
> Payload reference for every sending event

> [card] [Idempotency keys](https://nuntly.com/docs/guides/idempotency.md)
>
> Key formats, strategies, and error codes

> [card] [SDK reference](https://nuntly.com/docs/api-reference/sdks.md)
>
> Install and configure the Nuntly SDKs

## Other frameworks

> [card] [Send email from Express](https://nuntly.com/docs/guides/send-email-from-express.md)
>
> Send from an Express route and verify webhooks on the raw body

> [card] [Send email from Next.js](https://nuntly.com/docs/guides/send-email-from-nextjs.md)
>
> Send from a Server Action and receive webhooks in a Route Handler

> [card] [Send email from Spring Boot](https://nuntly.com/docs/guides/send-email-from-spring-boot.md)
>
> Use the Spring Boot starter on Java 21


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.