> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# How to Send Email from Railway (Node.js)

> Deploy a Node.js app that sends transactional email with the AhaSend SDK to Railway: set variables, expose a public webhook URL, and go live.

This guide shows how to send transactional email from a Node.js app on Railway with AhaSend, keep credentials on the server, and test a sandbox send.

This guide deploys the [Express app](https://ahasend.com/docs/guides/express.md) as a persistent Railway service. The same deployment steps apply to other Node.js frameworks such as [Fastify](https://ahasend.com/docs/guides/fastify.md), [Koa](https://ahasend.com/docs/guides/koa.md), and [NestJS](https://ahasend.com/docs/guides/nestjs.md).

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](https://ahasend.com/docs/api-reference/scopes.md). See [send-only credentials](https://ahasend.com/docs/send-api/credentials.md) and [authentication](https://ahasend.com/docs/api-reference/authentication.md).

Keep credentials on the server. Use [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md) 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](https://ahasend.com/docs/api-reference/webhooks/security.md) and [retry policy](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).

## Prerequisites

* A [Railway account](https://railway.com) and the [Railway CLI](https://docs.railway.com/guides/cli) installed, or a GitHub repo connected to the service
* An [AhaSend account](https://dash.ahasend.com/user/register) with a verified sending domain
* An [API key](https://dash.ahasend.com/account/-/transactional/credentials) with the `messages:send:{yourdomain.com}` scope for your sending domain, and your account ID

## Install the SDK

In your local Node.js 22 or newer backend project, run:

```bash theme={null}
npm install @ahasend/sdk express
```

## Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a [verified sender](https://ahasend.com/docs/domains.md), a [send-only API key](https://ahasend.com/docs/send-api/credentials.md), and your account ID. It sends to a reserved test address with `sandbox: 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`:

```js quick-send.mjs theme={null}
import { AhaSendClient } from "@ahasend/sdk";
try {
  const client = AhaSendClient.fromEnv();
  const result = await client.messages.send({
    from: { email: process.env.AHASEND_FROM },
    recipients: [{ email: "recipient@example.com" }],
    subject: "My first sandbox email",
    text_content: "Hello from AhaSend",
    sandbox: true,
  });
  const statuses = result.data.map((entry) => entry.status);
  console.log({ statuses });
  if (!statuses.length || statuses.some((s) => !["queued", "scheduled"].includes(s)))
    throw new Error("A recipient was rejected");
} catch {
  console.error("Sandbox send failed; check your credentials, sender and account.");
  process.exitCode = 1;
}
```

Run `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.

## What You're Deploying

Install `@ahasend/sdk` and `express` in a Node.js 22 or newer project. This complete server uses a dedicated server-to-server token and sends in sandbox mode. Replace the sender with a verified domain.

```ts server.ts theme={null}
import { createHash, timingSafeEqual } from "node:crypto";
import express from "express";
import { AhaSendClient, AhaSendAPIError } from "@ahasend/sdk";
import { WebhookVerifier, expressWebhookHandler } from "@ahasend/sdk/webhooks";

const ahasend = AhaSendClient.fromEnv();
const token = process.env.WELCOME_ENDPOINT_TOKEN;
if (!token) throw new Error("WELCOME_ENDPOINT_TOKEN is required");
const expected = createHash("sha256").update(token).digest();
const app = express();

app.get("/health", (_req, res) => { res.sendStatus(200); });
app.post("/api/welcome", express.json({ limit: "16kb" }), async (req, res) => {
  const authorization = req.get("authorization") ?? "";
  const supplied = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
  if (!supplied || !timingSafeEqual(createHash("sha256").update(supplied).digest(), expected)) {
    res.sendStatus(401);
    return;
  }
  const { email, eventId } = req.body ?? {};
  if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ||
      typeof eventId !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(eventId)) {
    res.sendStatus(400);
    return;
  }
  try {
    const result = await ahasend.messages.send({
      from: { email: "hello@yourdomain.com" },
      recipients: [{ email }],
      subject: "Welcome to Your App!",
      text_content: "Thanks for signing up.",
      sandbox: true,
    }, { idempotencyKey: `sandbox-welcome-${eventId}` });
    const rejected = result.data.some((entry) => entry.status === "error");
    res.status(rejected ? 422 : 202).json({ accepted: !rejected });
  } catch (error) {
    if (error instanceof AhaSendAPIError) {
      console.error("Send failed", { status: error.status, requestId: error.requestId });
    }
    res.sendStatus(502);
  }
});
if (process.env.AHASEND_WEBHOOK_SECRET) {
  app.post("/webhooks/ahasend", expressWebhookHandler(
    new WebhookVerifier(process.env.AHASEND_WEBHOOK_SECRET),
    async (_event, _req, res) => { res.statusCode = 204; res.end(); },
    { maxBodyBytes: 30_000_000 },
  )); // Verification only; add a durable handoff before business side effects.
}
app.use((_error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  res.sendStatus(500);
});
const port = Number(process.env.PORT ?? "3000");
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error("Invalid PORT");
const server = app.listen(port, "0.0.0.0");
process.once("SIGTERM", () => {
  server.close((error) => { if (error) process.exitCode = 1; });
});
```

Run TypeScript with your project's runner (for example `npx tsx server.ts`). Call `/api/welcome` with your server token and JSON containing `email` and a stable `eventId`. Add a per-caller send limit before production use. An AhaSend 202 contains one result per recipient; this route returns 422 if any recipient is rejected.

## Configure Deployment Health and Draining

Commit a `railway.json` file so Railway waits for the health endpoint before activating a new deployment and gives the previous process time to close in-flight requests after `SIGTERM`:

```json railway.json theme={null}
{
  "$schema": "https://railway.com/railway.schema.json",
  "deploy": {
    "healthcheckPath": "/health",
    "drainingSeconds": 30
  }
}
```

Railway health checks gate a deployment; they are not continuous monitoring after it goes live. Draining only gives active HTTP requests time to finish, so put work that must survive a restart in a durable queue or outbox.

A send still in flight when the drain window ends is killed without answering its caller, and a timeout never proves the message did not go out. Pass a stable `idempotencyKey` derived from the action itself — `welcome-${eventId}`, not a fresh UUID per attempt — so the caller's retry replays the stored result instead of sending a second email. Reuse a key only with an identical request body; the same key with different content is rejected as a mismatch.

## Deploy

**1. Create or link the service**

With the CLI, create a project and empty service, or link the current directory to the intended service in an existing project:

```bash theme={null}
railway init # new project
railway add --service ahasend-app
# or, inside an existing project's repo:
railway link
```

When `railway link` prompts, select the service that should receive the variables and deployment. To deploy from GitHub without the CLI, click **New Project** in the Railway dashboard, choose the GitHub repo option, and select the repository; Railway creates the service from it. Use **Add variables** rather than **Deploy Now** so the first build already has the credentials.

**2. Set the variables**

In the Railway dashboard, open your service and go to **Variables**. Add `AHASEND_API_KEY`, `AHASEND_ACCOUNT_ID`, and the Express app's `WELCOME_ENDPOINT_TOKEN`, plus `AHASEND_WEBHOOK_SECRET` if the app receives webhooks. The Express app throws at startup when `WELCOME_ENDPOINT_TOKEN` is missing, so a deployment without it never passes its health check. Seal the API key, webhook secret, and endpoint token after setting them so their values cannot be read back through the dashboard or API.

With the CLI, use the `variable set` command. Sending secrets over standard input keeps their values out of the command line and your shell history:

```bash theme={null}
railway variable set AHASEND_ACCOUNT_ID=your-account-uuid --skip-deploys
railway variable set AHASEND_API_KEY --stdin --skip-deploys
railway variable set AHASEND_WEBHOOK_SECRET --stdin --skip-deploys
railway variable set WELCOME_ENDPOINT_TOKEN --stdin --skip-deploys
```

Paste one secret into each `--stdin` command and then send end-of-file. The `--skip-deploys` flags prevent deployments with only part of the configuration; the next step deploys the full set together. Dashboard changes are staged until you review and deploy them.

Sealing itself has no CLI or API equivalent — set the values however you like, then seal each one from its three-dot menu in the **Variables** tab. Sealing is one-way: a sealed value cannot be un-sealed or read back, is not returned to `railway variable list` or `railway run`, and is not copied into PR environments, duplicated environments, or duplicated services. Keep your own copy in a secret manager. If several services in the same environment need the credentials, define shared variables and reference them only from those services.

**3. Deploy**

Push straight from your machine:

```bash theme={null}
railway up
```

`railway up` compresses and uploads the current directory, honouring `.gitignore` and `.railwayignore`. Keep `.env` listed in both so your local credentials are never committed and never reach the builder, and never pass `--no-gitignore` from a directory that holds one.

For a GitHub service, review and deploy any staged variable changes in the dashboard, then push the app to the linked branch. Railway deploys subsequent pushes to that branch automatically.

## Expose a Public Webhook URL on Railway

Your service is private by default. To receive AhaSend webhooks, generate a public domain: open the service, go to **Settings → Networking → Public Networking**, and click **Generate Domain**. Then create the webhook in your [AhaSend dashboard](https://dash.ahasend.com) pointing at:

```text theme={null}
https://your-app.up.railway.app/webhooks/ahasend
```

> [!WARNING]
>
> A generated domain publishes **every** route on the service, not just the webhook path. `/api/welcome` becomes reachable by anyone who finds the hostname, so the `Authorization: Bearer <WELCOME_ENDPOINT_TOKEN>` check, the `16kb` JSON limit, and the recipient validation from the Express guide are the only things standing between the internet and your sending domain. Deploying that route without them turns the service into an open mail relay. Rate-limit sends per caller as well, and keep unauthenticated routes to the health endpoint.

Mount the SDK's `expressWebhookHandler` directly on the route, with no `express.raw()` and no earlier global JSON parser. The adapter reads and verifies the raw request stream itself. Set a `maxBodyBytes` limit appropriate for your endpoint, enforce the same or a lower limit at any proxy, and cap concurrent requests. Treat the signed timestamp only as a freshness check: atomically claim the webhook ID before side effects, enqueue durable work, return `2xx` only after that durable handoff, and return `2xx` for an already-claimed duplicate. See [Handle webhooks](https://ahasend.com/docs/guides/express.md#handle-webhooks) for the full example.

Anything your handler writes to standard output or standard error is captured into Railway's logs, browsable through the deploy panel, the **Observability** tab's Log Explorer, and `railway logs`, and retained for days to months depending on your plan. Keep `event.data` out of it — it carries the recipient address, sender, and subject, plus the opener's IP and user agent on open and click events — and log a status, an error code, and the AhaSend request ID rather than a whole error, event, or response body.

## Going Further

For substitutions, attachments and scheduled sends, use the [sending guide](https://ahasend.com/docs/send-api/send-email.md). The [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md) references explain client options, per-recipient results and retries. Use [idempotency rules](https://ahasend.com/docs/api-reference/idempotency.md) when your application retries a business operation.

## Troubleshooting

> [accordion] Deployment is live but the URL returns 502
>
> The app isn't listening on Railway's assigned port and interface. Bind to `process.env.PORT` on host `0.0.0.0`.

> [accordion] 401 AhaSendAuthenticationError in production
>
> The variable is set on a different service or environment than the one that deployed. Check the service's **Variables** tab and confirm the latest deploy happened after you set them.

> [accordion] Webhook verification fails on Railway but works locally
>
> `AHASEND_WEBHOOK_SECRET` must be copied exactly as shown in the AhaSend dashboard, including the `aha-whsec-` prefix. Re-enter it with `railway variable set AHASEND_WEBHOOK_SECRET --stdin` to avoid shell quoting problems, or, once it is sealed, through the edit option in its three-dot menu — a sealed variable cannot be updated through the Raw Editor. Never print the secret or the raw signed body while diagnosing this.

## Related Guides

* Before sending: [verify a domain](https://ahasend.com/docs/domains.md) and [create a send-only key](https://ahasend.com/docs/send-api/credentials.md).
* Other ways to send: [REST API](https://ahasend.com/docs/send-api/send-email.md), [SMTP](https://ahasend.com/docs/smtp.md), [CLI quickstart](https://ahasend.com/docs/cli/quickstart.md), [Node.js SDK](https://ahasend.com/docs/guides/nodejs-sdk.md) and [Go SDK](https://ahasend.com/docs/guides/go-sdk.md).
* Request rules: [API authentication](https://ahasend.com/docs/api-reference/authentication.md), [scopes](https://ahasend.com/docs/api-reference/scopes.md), [idempotency](https://ahasend.com/docs/api-reference/idempotency.md), [errors](https://ahasend.com/docs/api-reference/errors.md) and [rate limits](https://ahasend.com/docs/api-reference/rate-limits.md).
* Testing and events: [sandbox mode](https://ahasend.com/docs/send-api/sandbox.md), [CLI webhook testing](https://ahasend.com/docs/cli/webhook-testing.md), [event payloads](https://ahasend.com/docs/api-reference/webhooks.md), [signature verification](https://ahasend.com/docs/api-reference/webhooks/security.md) and [delivery retries](https://ahasend.com/docs/api-reference/webhooks/retry-policy.md).
* Data and limits: [retention](https://ahasend.com/docs/retention.md), [tracking](https://ahasend.com/docs/tracking/open-tracking.md) and [plans and feature availability](https://ahasend.com/docs/facts.md#plans-and-features).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.