Skip to main content
Bun is one of the runtimes the SDK officially supports, alongside Node.js and Deno. The package runs unchanged on the latest Bun, webhook signature verification included.

Prerequisites

  • Bun installed
  • An AhaSend account with a verified sending domain
  • An API key with the messages:send:all scope, and your account ID

Install the SDK

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
Run it with 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. Add sandbox: true to the send request 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.
server.ts
Narrow the catch to AhaSendWebhookVerificationError and rethrow anything else. Every non-2xx answer counts as a failed delivery: retried 6 times over 16+ minutes, with a webhook disabled after 100 consecutive failures. 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

  • Templating: pass substitutions per recipient and use {{ variable }} in the subject or body.
  • Batch sends: recipients accepts up to 100 entries; each gets a separate, individually-substituted message.
  • Scheduling: set schedule: { first_attempt: new Date(Date.now() + 60 * 60 * 1000).toISOString() } to defer delivery by an hour.
  • Your own idempotency keys: pass { idempotencyKey: "order-123" } as the second argument to send() to dedupe against your own identifiers.
  • Attachments: pass attachments: [{ data: pdfBase64, content_type: "application/pdf", file_name: "document.pdf", base64: true }]. When base64 is true, data must already be base64-encoded.
See the API reference for every endpoint the SDK exposes. Prefer a framework on top of Bun? The ElysiaJS guide uses the same SDK with typed routes.

Troubleshooting

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.
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.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.
Edge platforms are supported, including Cloudflare workerd with no nodejs_compat flag. See the Cloudflare Workers guide for the details there.