Skip to main content
The one hard requirement is the runtime: the function must be on 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:all or a least-privilege domain scope such as messages:send:{yourdomain.com} matching the domain in from.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.
Lambda environment variables are visible to anyone with read access to the function’s configuration. For production, keep the API key in AWS Secrets Manager or an SSM SecureString parameter rather than hardcoding it in your template. Fetch and cache it with an AWS SDK or Powertools parameters utility, or use the Parameters and Secrets Lambda extension during the invocation phase (the extension is not available during Lambda initialization).

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:
Never expose this send handler through an unauthenticated Function URL (AuthType: NONE) or an API Gateway route with no authorizer: anyone who finds the URL can pick their own recipients and send from your domain. Use AuthType: AWS_IAM on a Function URL, or an API Gateway authorizer (Cognito, a JWT authorizer, or a Lambda authorizer), and add reserved concurrency or route throttling so a leaked URL can’t drain your quota. The handler below still validates every field itself, because the network boundary is not the only thing that should stand between a request and your mail.
src/send.ts
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. 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:
That makes 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 until verifier.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
Create the webhook in your AhaSend dashboard pointing at the API Gateway invoke URL (e.g. 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 into index.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
Before testing, add 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 substitutions per recipient and use {{ variable }} in the subject or body.
  • Batch sends: recipients accepts up to 100 entries, and each one gets a separate, individually-substituted message.
  • Dry runs: set sandbox: true to have the API validate and accept a message without delivering it, and sandbox_result (deliver, bounce, defer, fail, or suppress) to exercise your webhook handling. The from domain still has to be verified. sandbox is a body field, so a key already spent on a sandbox send comes back 422 when the same key is replayed for the live one — give the two runs different event_ids.
  • Scheduling: set schedule: { first_attempt: new Date(Date.now() + 60 * 60 * 1000).toISOString() } to defer delivery by one hour (first_attempt must be in the future and within 7 days).
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]. Set base64: true for binary files such as PDFs.
See the API reference for every endpoint the SDK exposes, and the Node.js SDK page for client configuration in depth.

Troubleshooting

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.
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.
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.