Skip to main content
Vite builds browser applications, so AhaSend credentials and SDK calls belong in a backend. This guide adds an Express server and uses Vite’s development proxy. On a meta-framework, use its server integration instead, for example Astro.

Prerequisites

  • A Vite project
  • Node.js 22 or newer for the backend, which the SDK requires
  • Express 5 for the backend. Express 5 forwards a rejected promise from an async route handler to the error middleware below; on Express 4 the same rejection becomes an unhandled rejection and the request never completes. npm install express installs 5.
  • An application authentication system that can identify the current user on the backend
  • An AhaSend account with a verified sending domain
  • An API key permitted to send from that domain, and your account ID

1. Add a Backend

Install the SDK and Express in the package that runs your backend. A single repository can keep them at its root; what matters is that no module imported by the browser application imports @ahasend/sdk.
Create an uncommitted backend environment file. Do not use a VITE_ prefix:
server/.env
Vite exposes variables with its client prefix through import.meta.env and replaces them in the browser bundle. Never create VITE_AHASEND_API_KEY. If the project customizes envPrefix or define, ensure it does not expose or replace any AHASEND_* value. Rotate the key immediately if it is exposed.
Add server/.env to .gitignore. The backend below expects your authentication integration to export authorizeWelcome. That middleware must validate the user’s server-side session, authorize this mutation, reject the request when the CSRF token header sent in step 3 is missing or does not match the session, load the user from trusted storage, and set res.locals.user. Do not populate the user from request-body fields.
server/index.mjs
Bind the backend to loopback. Vite’s proxy connects over loopback, so nothing is lost, and the backend port stays off the local network. The bind does not hide the endpoint behind vite --host: Vite then accepts requests from the network and proxies /api into the loopback backend, so the authentication and rate limit below are what keep a live API key from becoming an open mail relay for anyone who can reach the development server. Rate-limit the mutation in addition to authenticating it: cap sends per session and per user, and reject over the cap before calling messages.send. Authentication alone bounds who can trigger a send, not how often. The accepted send response is multi-status, so inspect every entry in result.data; a resolved request can still contain an entry with status: "error". Anything that is not an AhaSend error is a local fault, so it is rethrown to the error middleware rather than reported to the browser as an upstream failure. Reuse the same signupId only for retries of the same welcome-email payload. The stable, hashed key lets AhaSend replay a stored outcome without putting the raw identifier in request metadata. AhaSend matches a key against the exact request body, so editing the subject or the HTML and then retrying for a user who already received one is rejected with AhaSendIdempotencyMismatchError (HTTP 422) rather than replayed; change the key prefix alongside the content. A stored result is replayed for 24 hours and a server-error outcome releases the key for re-execution, so the surrounding workflow must tolerate an uncertain duplicate.

2. Proxy /api During Development

Vite’s development server can forward /api requests to the backend, keeping the browser on one origin:
vite.config.ts
Run the frontend and backend as separate development processes:
The server.proxy setting covers Vite’s own servers only — the development server, and vite preview, whose preview.proxy defaults to it. For production, build the frontend and serve dist/ from static hosting; configure the hosting platform or reverse proxy to send /api to the backend. Do not use vite preview as a production server.

3. Call the Authenticated Endpoint

The browser sends no recipient or AhaSend credential. The backend derives the recipient from the authenticated session:
src/lib/sendWelcome.ts
Use the header name and token your authentication integration issues. The custom header is what makes the CSRF check in authorizeWelcome enforceable: a request carrying one is no longer a simple cross-origin request, so another site cannot forge it without CORS permission your backend never grants. A bodiless POST with no custom header, by contrast, gives the server nothing to validate. Call sendWelcome() from your React, Vue, or Svelte UI only as part of the authorized application flow. Keep the endpoint on the same origin in production as well as development, and configure the session cookie in your authentication integration.

Webhooks

Webhooks also belong on the backend. Mount the SDK’s expressWebhookHandler directly on its route, with no express.json(), express.raw(), or other body parser in front of it. The adapter reads and caps the exact raw stream before it verifies the signature and timestamp. The Express webhook guide contains the handler. Add the route above the error middleware in server/index.mjs. Express only routes an error to a handler registered after the failing route, so an adapter setup error — a body parser that ran first, for instance — bypasses a handler mounted earlier in the file. AhaSend delivers to the webhook path over HTTPS from the public internet rather than through the browser’s origin, and that path is not under /api, so route it to the backend explicitly when you configure production hosting. Timestamp verification is not replay deduplication. Before returning a 2xx, atomically persist the verified webhook-id with durable queue or outbox work. Acknowledge an already claimed ID without processing it again, and make the worker idempotent. Keep event.data out of your logs: it carries the recipient address, sender, and subject, plus the opener’s IP and user agent on open and click events.

Going Further

  • Backend details: see Express for the webhook adapter and Hono for another backend option.
  • Meta-frameworks: Next.js, SvelteKit, Nuxt, and Remix provide their own server integrations.
  • Client configuration: see the Node.js SDK guide for backend timeouts, retries, and idempotency options.
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]; binary data must be base64 encoded.

Troubleshooting

A module in the browser import graph imports the SDK. Constructing the client there throws AhaSendConfigurationError: the SDK detects browser globals and refuses, so the key cannot reach a page even by accident. Move that module into the backend package or directory and communicate with it through the authenticated HTTP endpoint. Having the dependency in a shared package.json does not bundle it by itself.
During development, confirm the server.proxy entry is in vite.config.ts, restart Vite after changing the config, and confirm the backend listens on port 3000. If Vite and the backend run in separate containers or virtual machines, the loopback bind is why the proxy cannot connect: point server.proxy at the backend’s reachable address and keep the port off the public network another way. In production, configure the hosting platform or reverse proxy separately; Vite’s development proxy is not deployed.
Remove the variable and rotate the exposed key in the dashboard. Keep the replacement only in the backend environment. Also inspect custom envPrefix and define settings, and never print the key while troubleshooting.
A body parser ran before the adapter. Mount expressWebhookHandler directly before any global parser, or scope parsers to routes that need them. Do not add express.raw() in front of the adapter.