Skip to main content
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.

Prerequisites

  • An Astro project using the Node adapter
  • An AhaSend account with a verified sending domain
  • An API key with the messages:send:all scope, 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
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. 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 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

  • Templating: pass substitutions per recipient and use {{ variable }} in the subject or body.
  • Batch sends: recipients accepts 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 data in attachments: [{ data, content_type, file_name, base64: true }]. The flag tells AhaSend how to decode data; it does not perform the encoding.
See the API reference for every endpoint the SDK exposes. Using a different meta-framework? The same SDK powers the Next.js and SvelteKit guides.

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.