Prerequisites
- An Astro project using the Node adapter
- An AhaSend account with a verified sending domain
- An API key with the
messages:send:allscope, and your account ID
Install the SDK and the Node Adapter
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.
Add sandbox: true to the send request to validate it 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
- 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 * 60 * 1000).toISOString() }to defer delivery by an hour. The first attempt must be in the future and within seven days of the request. - Attachments: for binary files such as PDFs, base64-encode the bytes yourself and pass the encoded string as
datainattachments: [{ data, content_type, file_name, base64: true }]. The flag tells AhaSend how to decodedata; it does not perform the encoding.
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.
