Skip to main content
This guide shows how to send transactional email from Astro with AhaSend, keep credentials on the server, and test a sandbox send. In Astro the send has to live in an on-demand API endpoint, since pages are prerendered by default. Switch on on-demand rendering and add the @astrojs/node adapter to get real server endpoints. 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

  • 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:all for 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 with sandbox: 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
Run 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
Register them as secret server variables so Astro reads them from your adapter at runtime. Astro checks secrets lazily by default — the first import from 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
Never expose any of these values through astro:env/client or a PUBLIC_ variable. That would ship the API key or endpoint token in your client bundle.

Create the Client

Create the client once at module scope and reuse it across requests:
src/lib/ahasend.ts
Both endpoints below read their request body through the same bounded reader, so neither one buffers an unbounded upload:
src/lib/request.ts

Send an Email from an Astro API Endpoint

API routes live under src/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
Call this route only from trusted server-side code over HTTPS, passing 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 standard Request, 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
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).
Signature verification does not prevent replay of a valid delivery within the timestamp window, which is five minutes by default. Before adding side effects, persist the webhook-id header atomically with durable processing work, acknowledge duplicates without processing them again, and make the work idempotent.

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

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.
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.
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.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.