Skip to main content
Remix has merged into React Router, so this guide uses React Router Framework Mode — the direct continuation of the Remix server, loaders, and actions. If your project is still on Remix v2, work through the upgrade guide first: the app/routes.ts and ./+types/* APIs below do not exist there. Email sends belong in server action functions, while inbound AhaSend webhooks use a resource route. Keep the SDK client and credentials in server-only modules.

Prerequisites

  • A React Router Framework Mode project deployed to a Node server with server rendering enabled
  • An AhaSend account with a verified sending domain
  • An API key with the messages:send:{yourdomain.com} scope for your sending domain, and your account ID

Install the SDK

Configure Environment Variables

Set the credentials in the Node process environment. For local development, load an ignored .env file from your server bootstrap or start the dev server with the variables set; in production, use your host’s secret storage. Never use a VITE_ prefix for secrets, because that prefix is for values exposed to browser code.
.env

Create the Client

Create the client once at module scope and reuse it across requests. The .server.ts filename makes the build fail if client code imports this module:
app/lib/ahasend.server.ts

Register the Routes

Framework Mode routes are configured in app/routes.ts. Add the UI route and webhook resource route alongside your existing routes:
app/routes.ts

Send an Email from an Action

Server action functions can import the client directly. A public form that sends email must bound its request body, validate its fields, and enforce server-side abuse controls. The example calls an application-specific emailSendRateLimit.take() backed by a durable, distributed store; implement it before deploying the route. Do not replace it with browser validation or an in-memory counter.
app/routes/signup.tsx
The limiter should combine the signals appropriate to your application, such as IP, authenticated account, and normalized recipient, and expire counters in shared storage. Deny the send when the limiter is unavailable if allowing an unbounded mail relay would be worse than temporarily rejecting signups. MAX_FORM_BYTES plus the required Content-Length header is the whole bound: a chunked request that declares no length is refused, and Node stops reading a declared body at its declared size. Rejecting anything that is not application/x-www-form-urlencoded keeps a large multipart upload from reaching this route at all. Set a matching or lower limit at your reverse proxy as well. A 202 is a multi-status response: result.data holds one entry per recipient, and an individual recipient can come back with status: "error" and a null id (a suppressed address, for example) while the call itself succeeds. Inspect every entry, not just the first — a resolved promise with nothing queued is a failed send, so the action above reports it as one instead of rendering a silent success. The SDK retries transient failures automatically, but a retry after a 5xx can still send twice. When a duplicate would be expensive, pass a stable key from the committed business operation as the second argument: ahasend.messages.send(message, { idempotencyKey: "welcome-" + signup.id }). Do not derive it from an arbitrary retry attempt. Add sandbox: true to the send request to validate it without delivering anything.

Handle Webhooks

Read the secret and build the verifier in a .server.ts module, not in the route file. Route modules are referenced by both the client and the server module graph — React Router strips their loader and action exports from the browser build, but top-level statements with side effects survive, so a secret check written at the top of a route module is emitted into a client chunk that throws in the browser:
app/lib/ahasend-webhooks.server.ts
Then handle webhooks in a resource route. Signature verification needs the exact raw bytes, and the body must be bounded while it is read so concurrent requests cannot allocate unbounded memory. This example uses a 1 MB application limit; configure the same or a lower limit at your reverse proxy and cap concurrent requests.
app/routes/webhooks-ahasend.ts
enqueueOnce() must atomically commit both the unique webhook-id and a durable job or outbox record. It returns false only when that ID was already committed. Let other storage failures throw so the resource route returns 500 and AhaSend can retry. Process the durable job with idempotent side effects outside the request; do not log the raw body, signature, secret, event, or recipient data. 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

  • Deployment: deploy the server build to a Node host and inject the three AHASEND_* variables through that host’s secret storage. A static-only deployment cannot run server actions or receive webhooks.
  • 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 to a future RFC 3339 timestamp within seven days of the request.
  • 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. The same server-side pattern works in the other full-stack frameworks: see the Next.js and SvelteKit guides.

Troubleshooting

Client code imported ahasend.server.ts directly. Keep the SDK client in .server.ts modules and import it only from server exports such as action and loader.
A file under app/routes is not registered automatically unless the project explicitly uses the file-routes convention. Add the route module to app/routes.ts, then run react-router routes to inspect the configured route tree.
The body passed to verifier.parse() must be the exact bytes AhaSend sent. Do not call request.json(), request.text(), or another body reader first, because a request body can only be read once. Also confirm AHASEND_WEBHOOK_SECRET includes the aha-whsec- prefix.
The API key is missing, malformed, or revoked. Verify AHASEND_API_KEY is set in the server environment and that the key exists in your dashboard.