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.
Install
The SDK is v0.x; pin an exact version in production. The examples were checked with@ahasend/sdk 0.2.1 (Node.js 22+).
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.
Runtime Compatibility
TypeScript declarations ship with the package, so there is no SDK-specific
@types package to add. Resolve them with moduleResolution set to bundler, node16, or nodenext; the package exports map is what makes @ahasend/sdk/webhooks resolvable. The declarations also name four WHATWG globals a fetch client cannot hide — fetch, Request, Response, and AbortSignal — so your project needs either @types/node or "lib": ["DOM"]. With neither, compilation fails inside node_modules rather than in your own code.
Send Your First Email
Create a least-privilege API key withmessages:send:{your-domain}, and keep the key and account ID in an uncommitted .env file or your deployment secret store.
send.mjs
node --env-file=.env send.mjs. Change sandbox to false only after you have intentionally reviewed the sender, recipients, and production credentials.
Note what the result handling is doing. recipients takes up to 100 addresses and each one becomes its own message, so result.data holds one entry per recipient. A single recipient can come back status: "error" with a null id, a suppressed address for instance, while the call itself succeeds. Reading data[0] would miss that.
Body fields are snake_case, matching the API. Beyond the ones above: reply_to, attachments, headers, substitutions, tags, tracking, retention, and schedule. For one conversation message with visible To and Cc recipients and hidden Bcc recipients, use messages.sendConversation() instead.
Configure the Client
Build the client once at module scope and share it. Client-level retry, telemetry, idempotency, and local rate-pacing configuration then stays consistent, and any enabled pacing queue is shared by your handlers in that process.lib/ahasend.ts
AhaSendClient.fromEnv() builds the same client from AHASEND_API_KEY (or AHASEND_TOKEN) and AHASEND_ACCOUNT_ID, along with the other AHASEND_* variables. It reads process.env by default and accepts a string record instead. In Cloudflare Workers, use the explicit constructor with bound fetch shown in the Worker guide; it passes fetch.bind(globalThis) along with the credential bindings.
Every method also takes a trailing options object:
Errors
Every non-2xx response throws a typed error:AhaSendError is the root. AhaSendAPIError carries .status, .code, .requestId, and .body, and branches into AhaSendBadRequestError, AhaSendAuthenticationError, AhaSendPermissionError, AhaSendNotFoundError, AhaSendConflictError, AhaSendIdempotencyConflictError, AhaSendUnprocessableEntityError, AhaSendIdempotencyMismatchError, AhaSendRateLimitError, and AhaSendServerError. Alongside it sit AhaSendConnectionError with its AhaSendTimeoutError subclass, plus AhaSendAbortError, AhaSendConfigurationError, AhaSendRateLimitQueueFullError, AhaSendResponseTooLargeError, and AhaSendResponseParseError.
Match on error.code or error.status, never on message text. Logs, metrics, traces, and exception tags should allowlist only aggregate counts, appropriate opaque IDs, HTTP status, SDK error code, and request ID. Do not serialize whole requests, responses, events, or errors, and do not log .body, .message, credentials, addresses, content, or idempotency keys.
Retries and Idempotency
The SDK retries only when the generated operation profile marks the call safe, idempotent, or protected by an idempotency key. For an eligible call it retries408, 429, 5xx, network failures, timeouts, and an idempotency-in-progress 409 carrying the required replay and retry headers. Caller cancellation and other 4xx responses are terminal. Create operations declared idempotent receive an automatically generated UUID Idempotency-Key, which is reused across the logical call’s internal retries.
Stored responses replay for 24 hours, covering 2xx and deterministic 4xx responses. The two API-key create operations are the exception: their replay carries the same one-time
secret_key for only 5 minutes, so persist that value the moment it arrives. Server errors are not stored, so the same key can execute again after a 5xx. Pass your own idempotencyKey derived from a stable business identifier when your application may retry later, reuse it only with the exact same payload, and reconcile an uncertain result before another send when duplicates are unacceptable.rateLimit: { enabled: true }.
Resources
List endpoints paginate. Take a page at a time with
list(), or let iterate() walk everything lazily:
Webhooks
@ahasend/sdk/webhooks is a separate entry point holding the Standard Webhooks HMAC-SHA256 verifier, typed parsers for webhook and inbound route event types, and adapters for Express, Fastify, and Next.js. It does not pull in the API client.
expressWebhookHandler hands that to next, fastifyWebhookHandler answers an opaque 400, and nextRouteHandler reads and bounds the stream itself. Pass the webhook secret exactly as the dashboard shows it, aha-whsec- prefix included. Every adapter answers a failed verification with an empty 400, or 413 when the body was too large, so nothing about the failed check reaches the sender.
Timestamp checking rejects any delivery more than five minutes from your clock, adjustable with new WebhookVerifier(secret, { toleranceSeconds }), but it does not deduplicate a valid one replayed inside that window. After verification, atomically commit each webhook-id together with durable work or an outbox record. A unique ID insert by itself can lose an event if the process stops before performing the side effect. Acknowledge duplicates without enqueuing again, retry storage failures with a non-2xx response, and process the durable work idempotently outside the request.
Direct verification and every adapter enforce a fixed 30,000,000-byte body ceiling. Adapters buffer the whole body before verifying it, and decoding it costs more memory again, so narrow that ceiling to what your payloads actually need: maxBodyBytes is a trailing option on expressWebhookHandler, fastifyWebhookHandler, and nextRouteHandler, and it only lowers the fixed limit, never raises it. WebhookVerifier takes no such option — on the direct path above, bound the read that produces rawBody yourself. Either way, set matching request-size and concurrency limits at the reverse proxy.
Framework Guides
The JavaScript and TypeScript guides use this SDK. The Go framework cards use the Go SDK.Express
Fastify
NestJS
Next.js
Nuxt
SvelteKit
Hono
Koa
ElysiaJS
Encore.ts
Remix / React Router
Astro
Vite
Gin (Go)
Echo (Go)
Fiber (Go)
chi (Go)
gorilla/mux (Go)
Source and Support
MIT licensed, developed at github.com/AhaSend/ahasend-ts. Report reproducible bugs as GitHub issues, without credentials, message content, or webhook payloads. Use Nodemailer over SMTP if your Node.js app already has an SMTP mailer.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.

