Skip to main content
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 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.
  • A verified sending domain. See sending domains.
  • An API key with the Sending only permission. See 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 and loads .env files with the built-in --env-file flag. You don’t need tsx, ts-node, or dotenv.

Install and configure the SDK

Create a new project with ES modules enabled:
Install Express and the Nuntly SDK, then the Express type definitions for your editor:
The Nuntly SDK has zero runtime dependencies and ships its own type definitions. Create a .env file at the project root:
.env
Add .env to .gitignore. Never commit your API key or your webhook signing secret.
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:
src/nuntly.ts
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:
src/server.ts
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:
The Nuntly API accepts the email with a 202 and the SDK returns its ID and first status:
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), 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 for the error codes.
Express 4 doesn’t forward rejected promises. Wrap each handler body in try/catch and call next(err).

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 shows events across all your emails. Nuntly keeps email records and their events for 30 days by default. See data retention.

Receive webhooks in Express

Nuntly signs every webhook request with HMAC-SHA256 (RFC 2104) 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.
1

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 for every field.
2

Save the signing secret

Copy the signing secret into NUNTLY_WEBHOOK_SECRET in your .env file. The dashboard shows it once.
3

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.
Add the webhook route to src/server.ts, above app.use(express.json()):
src/server.ts
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.
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.
What each event means and what to do with it: 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. 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. The bounce details follow the delivery status notification 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:
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.

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:
src/nuntly.ts

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:
src/server.ts
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

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 NestJS

Inject the client as a provider and send from a service

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