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 inapp/routes.ts. Add the UI route and webhook resource route alongside your existing routes:
app/routes.ts
Send an Email from an Action
Serveraction 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
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
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
substitutionsper recipient and use{{ variable }}in the subject or body. - Batch sends:
recipientsaccepts up to 100 entries; each gets a separate, individually-substituted message. - Scheduling: set
schedule.first_attemptto a future RFC 3339 timestamp within seven days of the request. - Attachments: pass
attachments: [{ data, content_type, file_name, base64: true }]. Setbase64: truefor binary files such as PDFs.
Troubleshooting
Build error: server-only module referenced by client
Build error: server-only module referenced by client
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.The new route returns 404
The new route returns 404
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.Webhook verification always returns 400
Webhook verification always returns 400
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.401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
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.
