runtimeConfig: anything under runtimeConfig.public is serialized into the client payload.
Use a send-only key from Credentials → Add → API Key v2 with messages:send:{your-domain}. Use messages:send:all when sending from many domains. For management tasks such as creating a webhook, use a separate full key from Account Settings → API Keys with the needed scopes. See send-only credentials and authentication.
Keep credentials on the server. Use sandbox mode for tests. Reuse a stable, business-event idempotency key only with the exact same payload; use different keys for sandbox and live sends. Check every recipient’s status and return a failure when any item is error; HTTP 202 alone does not mean every recipient was accepted.
For webhooks, verify the raw request bytes using the full secret as the raw UTF-8 HMAC key, including its aha-whsec- or aha-rsec- prefix. Never base64-decode the secret. The AhaSend SDK verifiers handle this. These examples use a 30,000,000-byte webhook body limit; a hosting platform may impose a smaller limit. Acknowledge verified unknown event types with 2xx. See verification and retry policy.
Prerequisites
- A Nuxt project
- An AhaSend account with a verified sending domain
- An API key with the
messages:send:{your-domain}scope (messages:send:allfor sending from many domains), and your account ID
Install the SDK
Try a Sandbox Send
Run this short SDK check locally before adding the longer server integration below. Use a verified sender, a send-only API key, and your account ID. It sends to a reserved test address withsandbox: true, so no email is delivered.
In the server project directory, add .env to .gitignore, then create .env with AHASEND_API_KEY, AHASEND_ACCOUNT_ID, and AHASEND_FROM (an address on your verified domain). Keep these values out of browser code and AI prompts. Use Node.js 22 or newer. Save this as quick-send.mjs:
quick-send.mjs
node --env-file=.env quick-send.mjs from that directory. The output lists each recipient status; queued or scheduled means accepted for sandbox processing. The process exits with a failure if the request or any recipient fails. Continue below for the full integration.
Configure Environment Variables
Declare the keys inruntimeConfig so Nuxt maps them from NUXT_-prefixed environment variables at runtime:
nuxt.config.ts
.env
Create the Client
Create the client on first use and reuse it across requests:server/utils/ahasend.ts
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
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.
The send example uses sandbox: true to validate the request 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. The Node-server example below readsevent.node.req directly and counts bytes before buffering them. Do not run readBody() or other body-reading middleware first: parsing and re-serializing JSON changes the signed bytes. Answer every failed check with an empty body so nothing about the failure reaches the sender:
server/api/webhooks/ahasend.post.ts
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
For substitutions, attachments and scheduled sends, use the sending guide. The Node.js SDK and Go SDK references explain client options, per-recipient results and retries. Use idempotency rules when your application retries a business operation.Troubleshooting
Webhook verification always returns 400
Webhook verification always returns 400
Something consumed or rewrote the request body before the bounded stream reader could read 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.
401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
The API key never reached the AhaSend SDK client on the server. 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.Credentials work locally but are missing after deploy
Credentials work locally but are missing after deploy
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.400 error mentioning the from address
400 error mentioning the from address
The
from address must belong to a verified sending domain on your account. Check domain status in the dashboard.Related Guides
- Before sending: verify a domain and create a send-only key.
- Other ways to send: REST API, SMTP, CLI quickstart, Node.js SDK and Go SDK.
- Request rules: API authentication, scopes, idempotency, errors and rate limits.
- Testing and events: sandbox mode, CLI webhook testing, event payloads, signature verification and delivery retries.
- Data and limits: retention, tracking and plans and feature availability.

