nodejs22.x or newer. Everything after that is ordinary Lambda work, and the handler below covers both API Gateway and Function URL event shapes.
Prerequisites
- An AWS account and a deployment tool of your choice (console, SAM, CDK, Terraform)
- A Lambda function using the current Node.js runtime
- An AhaSend account with a verified sending domain
- An API key with either
messages:send:allor a least-privilege domain scope such asmessages:send:{yourdomain.com}matching the domain infrom.email(the curly braces are part of the scope string), and your account ID
Install the SDK
Configure Environment Variables
In the console: Lambda → your function → Configuration → Environment variables. Give each function only what it needs —AHASEND_API_KEY and AHASEND_ACCOUNT_ID on the send function, AHASEND_WEBHOOK_SECRET on the webhook function. With SAM, take the secrets as NoEcho parameters instead of writing them into a file you commit:
template.yaml
sam deploy --guided prompts for both parameters. The default function timeout is 3 seconds, which is shorter than a single SDK attempt, so Timeout: 15 above is the minimum worth deploying with. AuthType: AWS_IAM is what keeps the send endpoint from being an open relay — see the warning below.
CDK (environment: {...} on NodejsFunction) and Terraform (environment { variables = {...} } on aws_lambda_function) work the same way.
Send an Email from a Lambda Handler
Initialize the client outside the handler: module scope survives across warm invocations, so configuration is validated and the client built once per execution environment instead of once per request, and the runtime’s HTTP connection pool (plus the client’s own rate-limiter buckets, if you enable pacing) stays warm between invocations:src/send.ts
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. Checking every entry — and failing the response when any is rejected — is what stops dropped mail from being reported to your caller as sent.
The event_id the caller supplies becomes a stable Idempotency-Key, which matters on Lambda specifically: an async invocation is retried up to two times by default, and any client in front of an API Gateway route may retry a 5xx. Without a caller-owned key the SDK generates a fresh one per call, so each retry is a new send. Reuse a key only for the identical payload — the same key with different content returns 422. Stored results expire after 24 hours and a 5xx releases the key, so persist event_id yourself if the welcome email must never go out twice.
The logging above is deliberately thin: a status, a request ID, and a count. Don’t log the recipient address, the rendered body, or the full API response — Lambda ships everything on stdout to CloudWatch Logs, where it outlives the request.
The event type above is correct for Lambda Function URLs and API Gateway HTTP APIs configured with payload format 2.0. API Gateway REST APIs use the payload-v1 event shape instead: swap in APIGatewayProxyEvent and APIGatewayProxyResult, and read the method from event.httpMethod — v1 has no requestContext.http, so TypeScript will flag that line for you.
Bundling
@ahasend/sdk ships both ESM and CJS builds, so it bundles cleanly with esbuild, whether you run esbuild directly, use SAM’s esbuild build method, or CDK’s NodejsFunction:
dist/ the deployment package, so the function’s handler is send.handler, not src/send.handler.
With SAM, let the CLI run esbuild for you instead. Add this alongside Properties on SendEmailFunction, and keep Handler: src/send.handler — SAM derives the entry point from the handler path:
Handle Webhooks
Put a second Lambda (or a second route on the same one) behind API Gateway and point your AhaSend webhook at its URL. This route is the one endpoint that has to accept anonymous callers — AhaSend can’t hold an IAM credential — so the HMAC signature is the authentication, and nothing may read the body as trusted untilverifier.parse() has returned.
Verification runs over the raw bytes AhaSend signed. Never re-serialize the body first: JSON.stringify(JSON.parse(body)) produces different bytes and can never verify. API Gateway hands you the body as a string on event.body, base64-encoded when the content type is binary, so decode it to bytes when event.isBase64Encoded is set and pass those bytes through untouched. The read is already bounded — Lambda caps a synchronous request payload at 6 MB, and the verifier refuses anything over 30 MB — so there is no unbounded stream to guard here. verifier.parse() is asynchronous, so await it:
src/webhook.ts
https://abc123.execute-api.us-east-1.amazonaws.com/webhooks/ahasend), and copy the secret into AHASEND_WEBHOOK_SECRET exactly as shown, including the aha-whsec- prefix.
Signature and timestamp verification do not prevent a valid delivery from being replayed inside the timestamp window (the verifier’s default tolerance is 5 minutes). After verification, record the webhook-id header in the same transaction as the durable work it guards — for example, the unique ID row and an SQS outbox record committed together — and acknowledge duplicate IDs without enqueueing or processing them again. Recording the ID in its own transaction is not enough: if the process dies between that insert and the work, the retry looks like a duplicate and the event is lost. Keep those IDs for at least your webhook retry horizon.
Return the 2xx only after that durable write commits, and hand slower processing to SQS or a separate async Lambda invocation. Do not start work and return without awaiting it: Lambda freezes the execution environment once the handler responds, and an unawaited promise resumes only if that environment happens to be reused for another request — otherwise it never finishes. The example above only demonstrates verification and event narrowing; add the durable write and deduplication before using it for side effects.
Deploying serverless elsewhere too? See the Vercel and Cloudflare Workers guides.
Quick Test in the Console
If you want to confirm your credentials work before wiring up a project, you can send one message straight from the console with no build step at all. Create a function with the current Node.js runtime in the Lambda console, paste this intoindex.mjs, choose Deploy, and then choose Test. No packages, no build step: it calls the AhaSend REST API with the fetch built into the Node runtime:
index.mjs
AHASEND_API_KEY and AHASEND_ACCOUNT_ID under Configuration → Environment variables. Don’t paste credentials into the code editor, where they end up in source control and console history.
The returned JSON carries one entry per recipient, so read the status on each one: a 202 with "status": "error" means that address was not queued.
This is a smoke test, not a pattern to build on. It has no retries, no idempotency key, and no typed errors, which is what the SDK adds above.
Going Further
- Templating: pass
substitutionsper recipient and use{{ variable }}in the subject or body. - Batch sends:
recipientsaccepts up to 100 entries, and each one gets a separate, individually-substituted message. - Dry runs: set
sandbox: trueto have the API validate and accept a message without delivering it, andsandbox_result(deliver,bounce,defer,fail, orsuppress) to exercise your webhook handling. Thefromdomain still has to be verified.sandboxis a body field, so a key already spent on a sandbox send comes back422when the same key is replayed for the live one — give the two runs differentevent_ids. - Scheduling: set
schedule: { first_attempt: new Date(Date.now() + 60 * 60 * 1000).toISOString() }to defer delivery by one hour (first_attemptmust be in the future and within 7 days). - Attachments: pass
attachments: [{ data, content_type, file_name, base64: true }]. Setbase64: truefor binary files such as PDFs.
Troubleshooting
401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
The environment variables aren’t set on that function: each Lambda has its own configuration, so a key added to the send function doesn’t exist on the webhook function. Check Configuration → Environment variables for the exact function that’s failing.
Webhook verification always returns 400
Webhook verification always returns 400
Three usual causes, in order of likelihood. The body was re-serialized before verification (
JSON.stringify(JSON.parse(body)), or a middleware that parsed it) — the signature covers the exact bytes AhaSend sent, so pass them through unchanged. The secret doesn’t match the dashboard value, including the aha-whsec- prefix and any trailing newline picked up from a file or shell. Or the body arrived base64-encoded and was verified as-is: check event.isBase64Encoded and decode with Buffer.from(event.body, "base64") first. HTTP APIs only base64-encode binary content types, so a JSON webhook usually arrives as text, but a REST API with binaryMediaTypes set to */* will encode it.Function times out during sends
Function times out during sends
The default Lambda timeout (3s) is shorter than even one SDK attempt, whose default timeout is 30s. The SDK can retry transient failures up to 3 times, and retry backoff sits outside the per-attempt timeout. Raise the function timeout, then cap the complete SDK call below it—for example, with a 15s Lambda timeout, pass
{ signal: AbortSignal.timeout(12_000), retry: { maxRetries: 1 }, timeoutMs: 5_000 } as the trailing options argument to send(). Behind API Gateway there’s a ceiling on how far you can raise the function timeout: an HTTP API’s integration timeout is a fixed 30 seconds, and the client gets a 504 at that point no matter what the function is still doing.
