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
- An Astro project using the Node adapter
- 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 and the Node Adapter
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.
astro add node wires the adapter into astro.config.mjs for you:
astro.config.mjs
The SDK runs on the Node adapter and on Cloudflare’s
workerd runtime. For Cloudflare specifics, see the Cloudflare Workers guide. Astro’s Vercel adapter runs on-demand routes in a serverless function; its optional Edge Function runs middleware only.Configure Environment Variables
.env
astro:env/server throws, which means a missing key surfaces as a 500 on the first request rather than at boot. validateSecrets: true also checks them when the dev server starts and when astro build runs, so a missing value fails the build instead. Add the env block alongside the adapter configuration that astro add node created:
astro.config.mjs
Create the Client
Create the client once at module scope and reuse it across requests:src/lib/ahasend.ts
src/lib/request.ts
Send an Email from an Astro API Endpoint
API routes live undersrc/pages/api/. Mark the route prerender = false so it renders on demand instead of being built as a static file (required in static-first projects; harmless if your whole site is already output: "server"):
src/pages/api/welcome.ts
Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>. Replace the bearer-token check with your application’s normal authentication and authorization if this endpoint is user-facing, and add rate limiting appropriate to your traffic. A send endpoint that accepts an arbitrary recipient without credentials is an open mail relay, so the token check, the 16 KB body cap, and the recipient validation all run before anything reaches AhaSend. The error responses stay generic and the log line carries only the status and request ID — never the API key, the payload, or the provider’s response body.
A 202 is multi-status: result.data carries one entry per recipient, and an individual recipient can come back status: "error" with a null id (a suppressed address, say) while the call itself succeeds, so check every entry, not just the first. This route has one recipient, so any rejection is a failed request and it answers 422 instead of a misleading 200 — a suppressed address will not start working on retry. Report per-recipient outcomes instead of a single status once you fan out to a batch, and keep the recipient-level error strings out of the response, since they can carry addresses and provider diagnostics.
The SDK sends an Idempotency-Key with every send and retries transient failures automatically. The key it generates covers its own retries but not a retry your caller makes after a timeout, and a 5xx releases the key server-side for re-execution — so a duplicate is still possible. Passing your own key derived from the business event, as above, closes that gap: an exact repeat within 24 hours replays the stored result instead of sending again. Reuse a key only with an identical payload; on the same key with a different body the API answers 422 and the SDK throws AhaSendIdempotencyMismatchError, which the catch above reports as a 502.
The send example uses sandbox: true to validate the request without delivering anything. That changes the request body, so give sandbox sends their own idempotency keys rather than reusing a live one.
Handle Webhooks
Astro endpoints receive a standardRequest, so verifier.parse() can take request.headers and the raw body bytes directly, no adapter needed. parse() is asynchronous, so await it. This endpoint is unauthenticated until the signature checks out, so read it through the same bounded reader: verification rejects bodies over MAX_WEBHOOK_BODY_BYTES (30 MB), but calling request.text() or request.arrayBuffer() first would buffer an oversized body before that check runs.
src/pages/api/webhooks/ahasend.ts
https://your-app.com/api/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown (including the aha-whsec- prefix).
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
POST returns 404 or a static HTML page
POST returns 404 or a static HTML page
The route was prerendered at build time. Add
export const prerender = false to the endpoint (or set output: "server" globally) and make sure an adapter is configured. On-demand endpoints don’t work with a purely static build.A request fails with “AHASEND_API_KEY is missing”
A request fails with “AHASEND_API_KEY is missing”
Astro validates secret server variables from the
env.schema at runtime, the first time a module imports from astro:env/server — so a missing value surfaces as a 500 on the first request that touches it, not at boot. validateSecrets: true moves the check to dev-server start and astro build. Check that .env is in the project root for local development and set the same variables in your production host’s runtime environment. Import secrets only from astro:env/server.Webhook verification fails with 400
Webhook verification fails with 400
The logged
reason names the cause. signature_mismatch usually means the body bytes changed: pass the unchanged raw bytes to verifier.parse(), not a re-serialized JSON.stringify(await request.json()), since re-serialization changes the byte layout. timestamp_outside_tolerance means the server clock drifted more than five minutes from AhaSend’s, so fix time sync rather than the handler.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.

