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.
Use a send-only key from Credentials → Add → API Key v2 with messages:send:{your-domain}. Use messages:send:all when sending from many domains. For management tasks such as creating a webhook, use a separate full key from Account Settings → API Keys with the needed scopes. See send-only credentials and authentication.
Keep credentials on the server. Use sandbox mode for tests. Reuse a stable, business-event idempotency key only with the exact same payload; use different keys for sandbox and live sends. Check every recipient’s status and return a failure when any item is error; HTTP 202 alone does not mean every recipient was accepted.
For webhooks, verify the raw request bytes using the full secret as the raw UTF-8 HMAC key, including its aha-whsec- or aha-rsec- prefix. Never base64-decode the secret. The AhaSend SDK verifiers handle this. These examples use a 30,000,000-byte webhook body limit; a hosting platform may impose a smaller limit. Acknowledge verified unknown event types with 2xx. See verification and retry policy.
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
Try a Sandbox Send
Run this short SDK check locally before adding the longer server integration below. Use a verified sender, a send-only API key, and your account ID. It sends to a reserved test address withsandbox: true, so no email is delivered.
In the server project directory, add .env to .gitignore, then create .env with AHASEND_API_KEY, AHASEND_ACCOUNT_ID, and AHASEND_FROM (an address on your verified domain). Keep these values out of browser code and AI prompts. Use Node.js 22 or newer. Save this as quick-send.mjs:
quick-send.mjs
node --env-file=.env quick-send.mjs from that directory. The output lists each recipient status; queued or scheduled means accepted for sandbox processing. The process exits with a failure if the request or any recipient fails. Continue below for the full integration.
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
emailSendRateLimit.take({ request, recipient }): Promise<boolean> in app/lib/email-send-rate-limit.server.ts. It must identify the authenticated caller and enforce a per-user send limit. The webhook route also imports webhookDeliveries.enqueueOnce(id, event): Promise<boolean> from app/lib/webhook-deliveries.server.ts: atomically store the delivery ID and durable work, returning false for a duplicate. These two modules belong to your application.
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.
The example sets sandbox: true on 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
For substitutions, attachments and scheduled sends, use the sending guide. The Node.js SDK and Go SDK references explain client options, per-recipient results and retries. Use idempotency rules when your application retries a business operation.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.Related Guides
- Before sending: verify a domain and create a send-only key.
- Other ways to send: REST API, SMTP, CLI quickstart, Node.js SDK and Go SDK.
- Request rules: API authentication, scopes, idempotency, errors and rate limits.
- Testing and events: sandbox mode, CLI webhook testing, event payloads, signature verification and delivery retries.
- Data and limits: retention, tracking and plans and feature availability.

