Skip to main content
Route Handlers and Server Actions in the Next.js App Router both run on the server, so the AhaSend SDK and your API key stay out of the browser bundle. This guide sends a welcome email from each of them, and receives verified AhaSend webhooks through the SDK’s App Router adapter.

Prerequisites

  • A Next.js project using the App Router
  • An AhaSend account with a verified sending domain
  • An API key with the messages:send:{yourdomain.com} scope matching the domain in from.email (or messages:send:all to cover every domain), and your account ID

Install the SDK

Configure Environment Variables

For local development, add your credentials to an uncommitted .env.local, which Next.js loads automatically. In production, inject the same values through your hosting platform’s secret settings:
.env.local
Never prefix these variables with NEXT_PUBLIC_. Next.js inlines every NEXT_PUBLIC_* variable into the browser bundle at build time. A NEXT_PUBLIC_AHASEND_API_KEY would hand full send access to anyone who opens DevTools. Unprefixed variables stay server-side.

Create the Client

Build the client on first use and reuse it across requests. The server-only marker makes an accidental Client Component import fail at build time, and fromEnv() validates the required AhaSend configuration:
lib/ahasend.ts
Read credentials inside a function, not at module scope. next build imports every route module to collect its configuration, so a client constructed — or an environment variable asserted — while the module is evaluating turns a build without production secrets into a hard build failure (Failed to collect page data). That is the normal case for Docker image builds and for CI that keeps secrets out of the build step.

Send an Email from a Next.js Route Handler

Route Handlers are public endpoints. This backend-to-backend example requires a long random bearer token over HTTPS; use your application’s existing authentication and authorization instead when the caller is a user. Also configure request-size and rate limits at your hosting layer.
lib/internal-auth.ts
app/api/welcome/route.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. The stable, hashed signup ID lets retries across requests reuse the same idempotency key without placing the raw customer identifier in request metadata. Keep the payload stable for a given signup ID; reusing a key with a different payload is rejected.

Alternative: Send from a Server Action

Server Actions are also public mutation endpoints. Authenticate inside the action, then load the recipient from your server-side user record rather than accepting an email address from the browser. Adapt requireCurrentUser() to your authentication and data-access layer:
app/actions.ts
Add sandbox: true to the send request to validate it without delivering anything. Idempotency keys are scoped to the account and matched against a hash of the request body, so a sandbox send is not a separate namespace: reusing welcome-<hash> with sandbox flipped is the same key with a different payload, which the API rejects with a 422 for the 24 hours the original record lives. Prefix sandbox keys distinctly (sandbox-welcome-…).

Handle Webhooks

The SDK ships an App Router adapter, nextRouteHandler, that reads a bounded raw body, verifies the HMAC signature and timestamp over those exact bytes, and hands you a typed event. Build it on first request and call it from POST:
app/api/webhooks/ahasend/route.ts
This minimal receiver verifies and acknowledges events without side effects. Timestamp verification is not replay deduplication: before adding side effects, atomically record the webhook-id header with durable work, acknowledge already-recorded deliveries with a 2xx response, and process the work idempotently. Do not launch untracked work after returning a response: a bare floating promise is killed when the serverless invocation ends, so hand deferred work to after() from next/server, which keeps the invocation alive until it settles. Match the one-megabyte application limit at your host or reverse proxy, configure opaque rejections, and cap concurrent webhook work. Create the webhook in your AhaSend dashboard pointing at https://your-app.com/api/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix). Invalid signatures are rejected with a 400 before your handler runs.

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.
  • Deploying to Vercel? The Vercel guide covers environment variable scoping, runtime choice, and webhook endpoints on Vercel Functions.
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]. Set base64: true for binary files such as PDFs.
See the API reference for every endpoint the SDK exposes. Building a standalone API server instead? Start from the Express guide.

Troubleshooting

The API key is missing, malformed, or revoked. Verify AHASEND_API_KEY is set in .env.local (restart next dev after editing it) and that the key exists in your dashboard. In production, set the variable in your hosting provider’s environment settings.
You prefixed the variable with NEXT_PUBLIC_. Rename it to AHASEND_API_KEY, rotate the leaked key in the dashboard, and only read it from server code (Route Handlers, Server Actions, Server Components).
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.