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
- Bun installed
- 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
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 Bun. Save this as quick-send.mjs:
quick-send.mjs
bun run 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.
The latest Bun is a supported runtime for the package, so no shims or flags are needed.
Configure Environment Variables
Create.env yourself and ensure it is ignored by git before adding values:
.env
Bun loads
.env automatically, no dotenv package or --env-file flag needed. The variables are available on process.env (and Bun.env) as soon as your script starts.Create the Client
Create the client once at module scope and reuse it across requests:lib/ahasend.ts
Send an Email from a Bun.serve Route
Bun.serve with a fetch handler is all the HTTP server you need. A send route reaches into your AhaSend quota and puts caller-supplied text into mail you sign, so it authenticates the caller and validates the body before it calls the SDK:
server.ts
bun run server.ts. Keep development: false and the error handler: with NODE_ENV unset, Bun.serve defaults to development mode, and its built-in 500 page hands the thrown error’s message and the surrounding source back to whoever made the request — including on the webhook route, which anyone who finds the URL can reach.
Call this route only from trusted server-side code with Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>, and replace the token check with your application’s normal authentication and authorization if the endpoint is user-facing. The check compares SHA-256 digests through timingSafeEqual rather than !==, because JavaScript’s string comparison returns as soon as two characters differ and leaks the token prefix to an attacker who can time repeated requests; hashing first also keeps the comparison from revealing the token’s length. Serve both routes only over HTTPS, terminating TLS at Bun or a trusted reverse proxy, and add rate limiting plus a body limit sized for JSON in front of the send route — maxRequestBodySize is a server-wide setting, so on its own it lets a 30 MB body reach either path. Never expose a recipient-controlled send endpoint without access control.
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.
The SDK retries transient failures automatically, but a retry after a 5xx can still send twice. For a business operation your application may retry later, pass a stable idempotencyKey, reuse it only with the exact same request payload, and remember that the server retains non-secret results for 24 hours.
The send request already uses sandbox: true to validate it without delivering anything. Sandbox is a body field, so a key already used for a sandbox send is rejected when the same key is replayed for the live send — give the two runs different keys.
Handle Webhooks
There’s no Bun-specific adapter, and you don’t need one:verifier.parse() accepts a Fetch Headers object and raw Uint8Array body directly. parse() is asynchronous, so await it. Read the body with req.arrayBuffer(), not req.json(), so the verifier sees the exact bytes AhaSend signed. The server configuration above caps request bodies at the verifier’s fixed 30,000,000-byte limit before the handler reads them.
The complete server.ts above mounts /webhooks/ahasend before the fallback response.
Narrow the catch to AhaSendWebhookVerificationError and rethrow anything else. Every non-2xx answer counts as a failed delivery: 6 attempts over about 16 minutes, with a webhook disabled after more than 100 consecutive failed attempts. A bug of your own should therefore surface as a 5xx rather than as a rejection that looks like a bad signature, which quietly spends that budget.
Webhooks can be delivered more than once. After verification and before performing side effects, atomically commit the webhook-id and durable work (such as an outbox job) in the same transaction. Acknowledge an ID that transaction has already committed without enqueueing it again.
Create the webhook in your AhaSend dashboard pointing at https://your-app.com/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
401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
The API key is missing, malformed, or revoked. Bun loads
.env automatically; if the expected file is not being found, confirm the process working directory or select it explicitly with bun --env-file=/path/to/.env run server.ts. Check only whether the variable is present—for example, console.log(Boolean(process.env.AHASEND_API_KEY))—and never log any part of the key.Webhook verification fails with 400
Webhook verification fails with 400
Make sure you pass
new Uint8Array(await req.arrayBuffer()) to verifier.parse(), not a re-serialized JSON.stringify(await req.json()): re-serialization changes key order and whitespace, so the signature no longer matches.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.SDK works locally but you plan to deploy to an edge platform
SDK works locally but you plan to deploy to an edge platform
Edge platforms are supported, including Cloudflare workerd with no
nodejs_compat flag. See the Cloudflare Workers guide for the details there.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.

