Skip to main content
In NestJS the send belongs in an injectable service. The webhook needs one decision made at bootstrap: without { rawBody: true } on NestFactory.create, there is nothing left to verify the signature against.

Prerequisites

  • Node.js 22 or newer
  • An AhaSend account with a verified sending domain
  • An API key with the messages:send:{yourdomain.com} scope, and your account ID

Install the SDK

Configure Environment Variables

For local development, add your credentials to an uncommitted .env file, which @nestjs/config loads. In production, inject the same variables from your platform’s secret manager:
.env
Enable the config module globally in AppModule, and register the throttler the send route uses below:
app.module.ts

Create the Client

Wrap AhaSendClient in an injectable service. Nest providers are singletons by default, so this reuses one configured client and any optional local rate limiter across requests. The SDK still creates a fresh automatic idempotency key for each logical send call and reuses it only for that call’s internal retries:
mail/ahasend.service.ts
A 202 is a multi-status response: result.data holds one entry per recipient, and an individual recipient can come back with status: "error" and a null id (a suppressed address, for example) while the call itself succeeds. Inspect every entry, not just the first.

Send an Email from a NestJS Controller

Do not expose a send-any-email route to unauthenticated clients. This example protects a backend-to-backend HTTPS route with a long random bearer token, and puts a rate limit in front of the token check so a stolen or brute-forced token cannot turn the route into an open mail relay. If your app already authenticates users, replace this guard with your existing guard and load the recipient address from your trusted user record instead of accepting an arbitrary address from the browser.
mail/internal-auth.guard.ts
Use a concrete DTO so Nest has runtime validation metadata:
mail/welcome.dto.ts
mail/mail.controller.ts
ThrottlerGuard is bound to this controller rather than registered as an APP_GUARD: a global throttler would also answer AhaSend’s webhook deliveries with 429, and 100 consecutive failed deliveries disable the webhook. Behind a reverse proxy, enable Express’s trust proxy setting as well, or every request looks like it comes from the proxy and shares one bucket. The stable, hashed signup ID above lets a retry reuse the same idempotency key without putting a customer identifier in request metadata. Keep the payload stable for a given signup ID; reusing a key with a different payload is rejected. Add sandbox: true to the send request to validate it without delivering anything. It changes the request body, so give a sandbox send a different idempotencyKey from the live send it stands in for.

Handle Webhooks

There is no Nest-specific adapter, so use the SDK’s generic WebhookVerifier directly. It needs the raw request body, which Nest can retain for you: pass rawBody: true to NestFactory.create, and Nest exposes the unparsed bytes as req.rawBody alongside the parsed body.
main.ts
Then verify and dispatch in a controller. Constructing the verifier through ConfigService ensures .env has been loaded before the secret is read. verifier.parse() verifies the signature and timestamp before it parses the event:
mail/webhook.controller.ts
isAhaSendError identifies the failure by brand rather than instanceof, so a forged signature still becomes an opaque 400 — not a 500 that tells the sender which check failed — even when two copies of the SDK end up in the dependency tree. This minimal receiver verifies and acknowledges events without side effects. Before adding side effects, atomically record the webhook-id header with durable work (for example, an outbox row), acknowledge duplicates with a 2xx response, and process the work idempotently. Timestamp verification alone does not deduplicate a valid replay within the accepted window. Keep event.data out of your logs as you add that handling: it carries the recipient address, sender, and subject, plus the opener’s IP and user agent on open and click events. Express’s JSON parser already caps the body at 100 kB and answers anything larger with a 413 before your handler runs; if you raise that limit with app.useBodyParser("json", { limit }) — which needs NestFactory.create<NestExpressApplication> — the new ceiling applies to this route too, so keep it tight. Cap concurrent webhook work, and do not start untracked background work after responding. Create the webhook in your AhaSend dashboard pointing at https://your-app.com/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix).

Going Further

  • Templating: pass substitutions per recipient and use {{ variable }} in the subject or body.
  • Batch sends: recipients accepts up to 100 entries; each gets a separate, individually-substituted message.
  • Scheduling: set schedule: { first_attempt: new Date(Date.now() + 60 * 60 * 1000).toISOString() } to defer delivery by one hour.
  • Your own idempotency keys: pass { idempotencyKey: "order-123" } as the second argument to send() to dedupe against your own identifiers.
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]. For binary files such as PDFs, base64-encode the bytes yourself and pass the encoded string as database64: true tells AhaSend how to decode data, it does not encode for you.
See the API reference for every endpoint the SDK exposes. Nest runs on Express or Fastify under the hood, so the standalone Express and Fastify guides show the SDK’s built-in webhook adapters for those platforms.

Troubleshooting

Nest only captures the raw body when you pass { rawBody: true } to NestFactory.create, and the capture rides on Nest’s built-in body parser — passing bodyParser: false disables it just as surely. Without the raw bytes, this controller rejects the request. If you switch to the Fastify adapter, follow Nest’s Fastify raw-body setup and use Fastify’s request type instead of Express’s Request type.
Beyond a missing raw body, check that AHASEND_WEBHOOK_SECRET matches the dashboard value exactly (including the aha-whsec- prefix) and that no proxy in front of Nest rewrites the request body (which invalidates the HMAC).
The API key is missing, malformed, or revoked. config.getOrThrow fails fast at boot if the variable isn’t loaded. Verify .env sits in the project root and ConfigModule.forRoot runs before AhaSendService is instantiated.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.