> ## 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 Express

> Send a transactional email from an Express route with the Nuntly TypeScript SDK, then receive signed delivery events with a webhook endpoint.

Send a transactional email from an Express app and track what happens to it. You end up with one Express app and two routes:

* `POST /signup` sends a welcome email with the Nuntly SDK.
* `POST /webhooks/nuntly` receives signed delivery events (`email.delivered`, `email.bounced`, `email.complained`).

The samples use Express 5, TypeScript, the [`@nuntly/sdk`](/docs/api-reference/sdks) package, and the body parsers built into Express. They use the standard `Nuntly` client, which throws on failure, and let Express 5 forward the error.

## 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. See [API keys](/docs/guides/api-keys). You store it in an environment variable in the next section.
* Node.js 24 or later. It runs TypeScript files through [type stripping](https://nodejs.org/api/typescript.html) and loads `.env` files with the built-in [`--env-file` flag](https://nodejs.org/api/cli.html#--env-fileconfig). You don't need `tsx`, `ts-node`, or `dotenv`.

## Install and configure the SDK

Create a new project with ES modules enabled:

```bash theme={null}
mkdir express-email && cd express-email
npm init -y
npm pkg set type=module
```

Install Express and the Nuntly SDK, then the Express type definitions for your editor:

<CodeGroup>
  ```bash npm theme={null}
  npm install express @nuntly/sdk
  npm install --save-dev @types/express
  ```

  ```bash pnpm theme={null}
  pnpm add express @nuntly/sdk
  pnpm add -D @types/express
  ```

  ```bash yarn theme={null}
  yarn add express @nuntly/sdk
  yarn add -D @types/express
  ```

  ```bash bun theme={null}
  bun add express @nuntly/sdk
  bun add -d @types/express
  ```
</CodeGroup>

The Nuntly SDK has zero runtime dependencies and ships its own type definitions.

Create a `.env` file at the project root:

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

<Warning>Add `.env` to `.gitignore`. Never commit your API key or your webhook signing secret.</Warning>

You get the webhook signing secret when you create the webhook below. Leave the placeholder for now.

Create the client once, in its own module, so every route shares it:

```typescript src/nuntly.ts theme={null}
import { Nuntly } from '@nuntly/sdk';

// One client for the whole process.
export const nuntly = new Nuntly({ apiKey: process.env.NUNTLY_API_KEY });
```

A single instance keeps your API key, timeout, and retry settings in one place.

## Send your first email

In Express, send a transactional email from the route handler that triggers it. Here `POST /signup` creates a user and sends a welcome email:

```typescript src/server.ts theme={null}
import express, { type NextFunction, type Request, type Response } from 'express';
import { APIError } from '@nuntly/sdk';
import { nuntly } from './nuntly.ts';

const app = express();

app.use(express.json());

app.post('/signup', async (req, res) => {
  const { email } = req.body;

  // Create the user in your database here.

  const sent = await nuntly.emails.send({
    from: 'Acme <hello@yourdomain.com>',
    to: email,
    subject: 'Welcome to Acme',
    html: '<p>Your account is ready. <a href="https://yourdomain.com/login">Log in</a></p>',
    text: 'Your account is ready. Log in at https://yourdomain.com/login',
  });

  res.status(201).json({ emailId: sent.id, status: sent.status });
});

app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof APIError) {
    console.error(`Nuntly ${err.status} ${err.code} request=${err.requestId}`);
    return res.status(502).json({ error: 'email_not_sent' });
  }
  next(err);
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));
```

Replace `yourdomain.com` with your verified domain. The import path ends in `.ts` because Node.js loads the file as written, without a build step.

Start the server and call the route:

```bash theme={null}
node --env-file=.env src/server.ts

curl -X POST http://localhost:3000/signup \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com"}'
```

The Nuntly API accepts the email with a `202` and the SDK returns its ID and first status:

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

The `queued` status means Nuntly accepted the email for delivery. The recipient's server answers later, and you see its answer in the delivery events below.

### Errors without try/catch

Express 5 forwards a rejected promise from an `async` handler to your error middleware ([Express error handling](https://expressjs.com/en/guide/error-handling.html)), so it acts as the `try/catch` for every route. When `nuntly.emails.send()` fails with an HTTP error, the SDK throws a typed `APIError` subclass with `status`, `code`, and `requestId`. The error middleware turns it into a single `502` response and logs the request ID you can share with Nuntly support. See [errors](/docs/api-reference/errors) for the error codes.

<Note>Express 4 doesn't forward rejected promises. Wrap each handler body in `try/catch` and call `next(err)`.</Note>

## Observe what happened

Delivery, bounces, and spam complaints happen after the `202`, on the recipient's side. You can follow them in the dashboard for a single email, and receive them in your application with a webhook.

### Email timeline in the dashboard

In the dashboard, open **Emails**, select the email by its ID, then click **Email Timeline**. The panel lists every event for that email in order, with its timestamp. The **Events** tab of the [observability dashboard](/docs/guides/observability-events) shows events across all your emails. Nuntly keeps email records and their events for 30 days by default. See [data retention](/docs/guides/data-retention).

### Receive webhooks in Express

Nuntly signs every webhook request with HMAC-SHA256 ([RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104)) over the signing timestamp and the raw request body, using your webhook signing secret. The SDK's `verifyWebhook` function checks that signature and returns a typed event.

<Steps>
  <Step title="Create the webhook">
    In the dashboard, go to **Webhooks** and click **Create Webhook**. Enter your endpoint URL and select `email.delivered`, `email.bounced`, and `email.complained`. See [set up webhooks](/docs/guides/webhooks) for every field.
  </Step>

  <Step title="Save the signing secret">
    Copy the signing secret into `NUNTLY_WEBHOOK_SECRET` in your `.env` file. The dashboard shows it once.
  </Step>

  <Step title="Expose your local server">
    Nuntly sends webhooks to a public HTTPS URL. During development, expose `localhost:3000` with a tunnel such as ngrok or cloudflared, and use the tunnel URL as the endpoint.
  </Step>
</Steps>

Add the webhook route to `src/server.ts`, above `app.use(express.json())`:

```typescript src/server.ts theme={null}
import express, { type NextFunction, type Request, type Response } from 'express';
import { APIError, verifyWebhook, WebhookVerificationError } from '@nuntly/sdk';
import { nuntly } from './nuntly.ts';

const app = express();

// Registered before express.json(): the signature covers the raw bytes.
app.post('/webhooks/nuntly', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook(req.body, req.get('webhook-signature') ?? '', process.env.NUNTLY_WEBHOOK_SECRET!);
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return res.status(401).json({ error: 'invalid_signature' });
    }
    throw err;
  }

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

  res.json({ received: true });
});

app.use(express.json());

// POST /signup, the error middleware, and app.listen() stay as before.
```

`verifyWebhook` rejects a request when the signature doesn't match or when its timestamp is more than 5 minutes away from the current time. Inside each `case`, TypeScript narrows `event.data`, so `event.data.bounce.bounceType` type-checks. `event.data.id` is the email ID that `nuntly.emails.send()` returned.

<Warning>
  Register the webhook route before `app.use(express.json())`. The JSON parser consumes the request stream and replaces `req.body` with an object, so `express.raw()` never sees the original bytes. Every webhook then fails with a `500` and this error:

  `TypeError: The "list" argument must be an instance of SharedArrayBuffer, ArrayBuffer or ArrayBufferView.`
</Warning>

What each event means and what to do with it:

| Event | Meaning | Suggested action |
| - | - | - |
| `email.delivered` | The recipient's mail server accepted the email. | Mark the email as delivered in your database. |
| `email.bounced` | The recipient's server rejected the email. `bounceType` is `Permanent`, `Transient`, or `Undetermined`. | On `Permanent`, flag the address so your app stops sending to it. |
| `email.complained` | The recipient marked the email as spam. | Stop sending non-essential email to the address. |
| `email.deliveryDelayed` | Delivery is delayed and can still succeed. | Wait for `email.delivered` or `email.bounced`. |
| `email.failed` | Nuntly could not deliver the email. | Alert and investigate. |

Return a `2xx` before any slow work, and move that work to a queue. When your endpoint fails, Nuntly retries the delivery, and you can replay a failed delivery from the [webhooks observability view](/docs/guides/observability-webhooks). Retries mean the same event can arrive more than once, so deduplicate on the event `id` (prefixed `evt_`), not on `event.data.id`.

The full payload of every event is in [sending events](/docs/guides/sending-webhooks). The bounce details follow the [delivery status notification](https://nuntly.com/rfc/dsn) format.

## Production notes

### Idempotency keys

An idempotency key is a unique value you send with a request. The API processes that request once, even when you or the SDK retry it.

The SDK sends one for you. Each `nuntly.emails.send()` call generates an `Idempotency-Key` and reuses it across its own retries, so a timeout followed by an automatic retry doesn't send twice. Two separate calls get two different keys. A double-clicked signup button or a job that runs twice still sends two emails.

To deduplicate across calls, derive the key from your own data:

```typescript theme={null}
const sent = await nuntly.emails.send(
  {
    from: 'Acme <hello@yourdomain.com>',
    to: user.email,
    subject: 'Welcome to Acme',
    text: 'Your account is ready.',
  },
  { idempotencyKey: `welcome-${user.id}` },
);
```

Keys expire after 24 hours. A retry with the same key and the same body returns the original response. The same key with a different body returns a `409` with the code `idempotency_key_payload_mismatch`. See [idempotency keys](/docs/guides/idempotency).

### Retries

By default, the SDK retries a failed request up to 2 times on `408`, `409`, `429`, `500`, `502`, `503`, `504`, and timeouts. It uses exponential backoff from 500 ms to 8 seconds and honors the `Retry-After` header. Other `4xx` errors, such as a `422` for an invalid payload, mean the request itself is wrong, and the SDK doesn't retry them.

To change the policy, set `maxRetries` or `retry` on the client:

```typescript src/nuntly.ts theme={null}
import { Nuntly } from '@nuntly/sdk';

export const nuntly = new Nuntly({
  apiKey: process.env.NUNTLY_API_KEY,
  maxRetries: 5,
});
```

### Error handling

The error middleware above handles `APIError`, which covers every HTTP error from the API. A network failure throws `APIConnectionError` instead, and a timeout (60 seconds by default) throws its subclass `APIConnectionTimeoutError`. Neither extends `APIError`, so add a branch for them:

```typescript src/server.ts theme={null}
import { APIConnectionError, APIError } from '@nuntly/sdk';

app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof APIError) {
    console.error(`Nuntly ${err.status} ${err.code} request=${err.requestId}`);
    return res.status(502).json({ error: 'email_not_sent' });
  }
  if (err instanceof APIConnectionError) {
    console.error(`Nuntly unreachable: ${err.message}`);
    return res.status(503).json({ error: 'email_not_sent' });
  }
  next(err);
});
```

Decide per route whether a failed email fails the request. A password reset should, because the user can't continue without it. A welcome email can fail without blocking the signup.

### Separate environments

Use a separate API key and a separate webhook for development, staging, and production. Each webhook has its own signing secret. A staging key then can't send production traffic, and a staging endpoint never receives production events. Keep the values in each environment's `.env` file or secret manager.

### What not to log

Recipient addresses are personal data. Every address you write to your logs is another copy you have to protect and delete. The code above logs identifiers only: the email ID, the event type, and the SDK `requestId`.

* Don't log `req.body` on the signup route. It contains the user's address.
* Don't log a full webhook event. `event.data.to`, `event.data.from`, and `event.data.bounce.bouncedRecipients` contain addresses.
* Log `event.id`, `event.type`, and `event.data.id`. They are enough to find the email in the dashboard.

SDK errors include the request ID, status, and response headers. They never include the request body or the API key.

## 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 NestJS" icon="node-js" href="/docs/guides/send-email-from-nestjs">
    Inject the client as a provider and send from a service
  </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.