Skip to main content
This guide deploys the Express app as a persistent Railway service. The same deployment steps apply to other Node.js frameworks such as Fastify, Koa, and NestJS.

Prerequisites

  • A Railway account and the Railway CLI installed, or a GitHub repo connected to the service
  • 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

What You’re Deploying

Use the SDK client in the completed Express app:
server.ts
A send returns a multi-status result, one entry in result.data 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, so check every entry: const rejected = result.data.filter((r) => r.status === "error"). Railway injects a PORT environment variable. Add a health endpoint, bind the server to that port on 0.0.0.0, and stop accepting new requests when Railway sends SIGTERM:
server.ts

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:
railway.json
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:
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:
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:
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 pointing at:
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 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

  • App-side detail: the Express guide covers sends, per-recipient error handling, and the webhook adapter in full.
  • Client configuration: see the Node.js SDK guide for timeouts, retries, idempotency, and the opt-in local rate pacing worth enabling on a long-lived service.
  • An alternative host: Vercel runs the same SDK on serverless functions if you’d rather not manage a process.
  • Durable webhook work: do not rely on untracked work after returning 2xx; Railway can redeploy, restart, or scale the process. Hand work to a durable queue or transactional outbox before acknowledging the delivery.
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]. Set base64: true for binary files such as PDFs.

Troubleshooting

The app isn’t listening on Railway’s assigned port and interface. Bind to process.env.PORT on host 0.0.0.0.
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.
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.