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:allscope, and your account ID
Install the SDK
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 once at module scope 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.
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
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.envfile. - Templating: pass
substitutionsper recipient and use{{ variable }}in the subject or body. - Batch sends:
recipientsaccepts 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 }]. Setbase64: truefor binary files such as PDFs.
Troubleshooting
Webhook verification always returns 400
Webhook verification always returns 400
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.401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
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.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.
