Skip to main content
In Nuxt, declare the AhaSend credentials in the top-level, server-only section of runtimeConfig: anything under runtimeConfig.public is serialized into the client payload.

Prerequisites

  • A Nuxt project
  • An AhaSend account with a verified sending domain
  • An API key with the messages:send:all scope, and your account ID

Install the SDK

Configure Environment Variables

Declare the keys in runtimeConfig so Nuxt maps them from NUXT_-prefixed environment variables at runtime:
nuxt.config.ts
.env
Anything under runtimeConfig.public (or a NUXT_PUBLIC_-prefixed variable) is serialized into the client payload and readable by anyone in the browser. Keep all AhaSend credentials in the top-level, server-only section of runtimeConfig.

Create the Client

Create the client once at module scope and reuse it across requests:
server/utils/ahasend.ts
Files in server/utils/ are auto-imported in all server routes, so useAhaSend() is available everywhere without an import. Passing the request event lets Nuxt apply the runtime environment overrides for that request.

Send an Email from a Nuxt Server Route

server/api/welcome.post.ts
A 202 from AhaSend 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 while the call itself succeeds. Inspect every entry, but do not return recipient-level errors to the browser because they can contain addresses and provider diagnostics. Require your application’s authenticated server to call this route. If a browser calls it directly, replace the bearer-token check with your normal server-side session validation and derive the recipient from that verified identity. eventId must be a stable ID for the welcome-email business event, persisted by the caller and reused for every retry. The SDK reuses this explicit idempotency key during its retries; a 5xx can be re-executed, so your application must still reconcile an uncertain result instead of blindly creating a new event ID. The content-length check rejects an oversized declared body, but Nitro does not cap request bodies on its own. Set a matching body-size limit at your reverse proxy or platform ingress in front of both routes. Message sends draw on your account’s rate limit of 100 requests per second with a 200-request burst, shared by every API key on the account. The SDK already retries 429 and 5xx responses with backoff and honors Retry-After, so bound the concurrency of whatever calls this route rather than adding a second retry loop on top of it. 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 sandbox is not a separate namespace: reusing welcome:<eventId> with sandbox flipped is the same key carrying a different payload, which the API rejects with a 422 for the 24 hours the original record lives. Give sandbox sends their own key prefix.

Handle Webhooks

Signature verification needs the exact request bytes. readRawBody(event, false) returns the undecoded buffer Nitro received, which is what the HMAC covers; readBody() parses the JSON, and its re-serialized output can never be verified. Answer every failed check with an empty body so nothing about the failure reaches the sender:
server/api/webhooks/ahasend.post.ts
Do not convert the event into a web Request (with toWebRequest or fromWebHandler) so you can reuse a fetch-style webhook adapter: Nitro’s Node request stream is wrapped in a stream that throws an uncaught error if the consumer stops reading before the upload finishes, which an oversized delivery does. Create the webhook in your AhaSend dashboard pointing at https://your-app.com/api/webhooks/ahasend, and copy its secret into NUXT_AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix). Signature timestamp checks do not prevent a valid delivery from being replayed inside the accepted window. Before adding side effects, atomically store the webhook-id header in a table with a unique constraint together with a durable work/outbox record. On a uniqueness conflict, acknowledge the delivery without enqueuing the work again. Process that work idempotently, acknowledge unknown event types, and return a successful response quickly so AhaSend does not retry completed work.

Going Further

  • Deployment: use a Nuxt server deployment preset; a static-only deployment cannot run these routes. Configure every NUXT_ secret in the deployment platform’s runtime environment because a built production server does not read your local .env file.
  • 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_000).toISOString() } to defer delivery.
  • 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. The same server-route pattern works in the other meta-frameworks too: see the SvelteKit and Next.js guides.

Troubleshooting

Something consumed or rewrote the request body before readRawBody could return the exact bytes the HMAC was computed over, or the configured webhook secret does not match this dashboard endpoint. Keep body-parsing server middleware off this route, and check that no proxy in front of Nitro re-encodes the payload.
The API key never reached the client. Check that nuxt.config.ts declares ahasendApiKey in runtimeConfig and that the env var is named exactly NUXT_AHASEND_API_KEY, since Nuxt only maps variables whose names match the config key. Restart the dev server after editing .env.
A built Nuxt server does not read your local .env file. Configure the matching NUXT_AHASEND_* and NUXT_WELCOME_ROUTE_TOKEN values in your deployment platform’s runtime environment, never as NUXT_PUBLIC_ variables.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.