> ## 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](/docs/guides/account-setup).
* A verified sending domain. See [sending domains](/docs/guides/sending-domains).
* An API key with the **Sending only** permission, stored in the `NUNTLY_API_KEY` environment variable. See [API keys](/docs/guides/api-keys).
* Node.js 20 or later, which the SDK requires.

## Install and configure the SDK

<Steps>
  <Step title="Install the SDK">
    From your NestJS project root:

    <CodeGroup>
      ```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
      ```
    </CodeGroup>

    The SDK has zero runtime dependencies and ships both ES module and CommonJS entry points.
  </Step>

  <Step title="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
    ```
  </Step>

  <Step title="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.
  </Step>
</Steps>

<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`.
</Note>

## 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`.
</Note>

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](/docs/guides/data-retention), 30 days by default.

### Receive webhooks in a NestJS controller

<Steps>
  <Step title="Create the webhook">
    Create a webhook in the dashboard as described in the [webhooks guide](/docs/guides/webhooks). 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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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;
        }

        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`.
  </Step>
</Steps>

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](/docs/guides/observability-webhooks) 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](/docs/guides/sending-webhooks).

## 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](/docs/guides/idempotency) 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`.
</Warning>

### Retries

The SDK retries a failed request 2 times by default, with exponential backoff from 500 ms to 8 s plus jitter. It follows the `Retry-After` header when the API sends one.

| Failure | Error class | Retried by the SDK |
| - | - | - |
| `400`, `422` | `BadRequestError`, `UnprocessableEntityError` | No |
| `401`, `403` | `AuthenticationError`, `PermissionDeniedError` | No |
| `408`, `429` | `APIError`, `RateLimitError` | Yes |
| `409` | `ConflictError` | Yes |
| `500`, `502`, `503`, `504` | `InternalServerError` | Yes |
| Request timeout | `APIConnectionTimeoutError` | Yes |
| Other network errors | `APIConnectionError` | No |

A `409` is either `idempotency_key_in_progress`, which a retry resolves, or `idempotency_key_payload_mismatch`, which needs a new key. 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](/docs/guides/api-keys#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

<CardGroup cols={2}>
  <Card title="Handle webhook events" icon="webhook" href="/docs/guides/integrate-webhooks">
    Signature verification details and webhook best practices
  </Card>

  <Card title="Sending events" icon="paper-plane" href="/docs/guides/sending-webhooks">
    Payload reference for every sending event
  </Card>

  <Card title="Idempotency keys" icon="repeat" href="/docs/guides/idempotency">
    Key formats, strategies, and error codes
  </Card>

  <Card title="SDK reference" icon="code" href="/docs/api-reference/sdks">
    Install and configure the Nuntly SDKs
  </Card>
</CardGroup>

## Other frameworks

<CardGroup cols={3}>
  <Card title="Send email from Express" icon="node-js" href="/docs/guides/send-email-from-express">
    Send from an Express route and verify webhooks on the raw body
  </Card>

  <Card title="Send email from Next.js" icon="react" href="/docs/guides/send-email-from-nextjs">
    Send from a Server Action and receive webhooks in a Route Handler
  </Card>

  <Card title="Send email from Spring Boot" icon="java" href="/docs/guides/send-email-from-spring-boot">
    Use the Spring Boot starter on Java 21
  </Card>
</CardGroup>


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