@ahasend/sdk is the official Node.js and TypeScript SDK for AhaSend. Typed models, retries that carry an idempotency key, and a separate webhooks entry point that verifies signatures for you.
This page is the SDK reference. To wire it into a specific framework, pick a guide from the sidebar.
Install
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 any string record instead, which is how runtimes that hand you bindings rather than a process environment — Cloudflare Workers, for one — supply the same variables: AhaSendClient.fromEnv(env).
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 non-secret outcomes 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 all 11 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.

