Skip to main content
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.
  • A verified sending domain. See sending domains.
  • An API key with the Sending only permission, stored in the NUNTLY_API_KEY environment variable. See API keys.
  • Node.js 20 or later, which the SDK requires.

Install and configure the SDK

1

Install the SDK

From your NestJS project root:
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:
.env
The Nest CLI loads this file with the --env-file flag:
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:
src/app.module.ts
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 in the NestJS documentation for the factory pattern.
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:
src/app.service.ts
  • from must use your verified domain. It accepts a display name (Acme <hello@...>) in the RFC 5322 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.
Expose the service from the controller and translate SDK errors into an HTTP response:
src/app.controller.ts
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:
The response contains the email ID and its first status:
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, 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. 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:
src/main.ts
Nest still parses req.body for every route and also exposes the unparsed bytes as req.rawBody. See 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:
src/webhooks.controller.ts
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 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.

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 for other strategies.
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 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. 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.
  • 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

Handle webhook events

Signature verification details and webhook best practices

Sending events

Payload reference for every sending event

Idempotency keys

Key formats, strategies, and error codes

SDK reference

Install and configure the Nuntly SDKs

Other frameworks

Send email from Express

Send from an Express route and verify webhooks on the raw body

Send email from Next.js

Send from a Server Action and receive webhooks in a Route Handler

Send email from Spring Boot

Use the Spring Boot starter on Java 21