Send email with Railway by keeping the Volanea API key in Railway environment variables and making the REST request from a server-side Node.js service. This guide provides a complete Express application you can deploy to Railway to send one transactional email safely.
What you will build
You will build a minimal Node.js and Express service with a single protected development endpoint. When the endpoint receives a request, the service sends a transactional email through Volanea’s POST /v1/send endpoint.
The example deliberately uses Volanea’s REST API rather than a provider-specific JavaScript SDK. That keeps the integration portable: it works in a Railway service, a worker, a queue consumer, or another Node.js runtime that supports the built-in fetch API. It also avoids relying on an unverified SDK package name or a made-up method such as emails.send().
The finished application does four important things:
- Reads the Volanea API key from
VOLANEA_API_KEYat runtime. - Sends the key only in a server-to-server HTTP request.
- Sends both HTML and plain-text versions of one transactional message.
- Listens on Railway’s assigned
PORT, so Railway can route public HTTP traffic to the service.
Do not run this code in browser JavaScript, a mobile application, or any client-exposed environment. Anyone who can inspect a client bundle can retrieve the key and send mail from your account. Email sending belongs behind your application’s authenticated server-side boundary.
Prerequisites
Before creating the project, make sure you have the following:
- A Volanea account and an API key with permission to send email.
- A sender address or domain that is configured for sending in Volanea.
- A Railway account and a new Railway project or service.
- Node.js 18 or newer for local testing. The example uses Node’s built-in
fetch, which is available in current Node.js releases. - A test recipient address you control.
You should also decide what event will trigger the send. In a production application, typical triggers include a password-reset request, account verification, an invitation, a receipt, or a security alert. The public /send-test-email route in this guide is intentionally simple so that you can verify the integration. It is not a pattern to leave publicly callable in production.
Volanea’s send endpoint accepts one message request and can send to one recipient or multiple recipients, subject to the API’s documented limits. For this first deployment, use one controlled test recipient. It makes it much easier to distinguish an API configuration problem from a recipient-address, suppression, or mailbox-delivery problem.
Create the Node.js project
Create a new directory and initialize a Node.js project:
mkdir volanea-railway-email
cd volanea-railway-email
npm init -y
Install Express:
npm install express
This is the exact dependency installation command for this example. Express provides the HTTP server Railway runs. The email request itself uses Node’s built-in fetch, so no unverified Volanea SDK package is required.
Next, update the scripts section of package.json so Railway has an explicit start command:
{
"name": "volanea-railway-email",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
The important parts are "type": "module", which enables import syntax in server.js, and "start": "node server.js", which gives Railway a predictable command to run. Railway can detect many Node.js applications automatically, but an explicit start script removes ambiguity.
Do not commit a real API key to package.json, a source file, a sample configuration file, or a Git repository. Use a local .env file for local development if you want, but keep it ignored by Git. On Railway, use service variables instead.
Configure environment variables
The application needs five environment variables:
| Variable | Purpose |
|---|---|
VOLANEA_API_KEY | Secret API key used to authorize the send request. |
VOLANEA_API_URL | Full Volanea send endpoint URL. Set it to the API host and /v1/send endpoint shown in your Volanea API documentation. |
VOLANEA_FROM | A configured sender, such as Example App <notifications@example.com>. |
TEST_RECIPIENT | A recipient address you control while testing. |
SEND_TEST_SECRET | A random secret required by the sample test route. |
The API key is the credential the application uses to authenticate to Volanea. Treat it like a password. It should be available to the running Railway service, but never rendered into a page, returned in JSON, logged, or sent to a browser.
For local testing, create a .env file only on your machine:
VOLANEA_API_KEY=replace-with-your-api-key
VOLANEA_API_URL=https://api.volanea.com/v1/send
VOLANEA_FROM=Example App <notifications@your-verified-domain.com>
TEST_RECIPIENT=your-inbox@example.com
SEND_TEST_SECRET=replace-with-a-long-random-value
PORT=3000
The example API URL uses Volanea’s documented /v1/send route. If your Volanea account documentation supplies a different API host for your environment, use that documented host in VOLANEA_API_URL; do not guess an SMTP host, API region, or alternate endpoint.
Add .env to .gitignore before making your first commit:
node_modules
.env
For a quick local run without adding another package, export the variables in your shell instead of loading .env automatically:
export VOLANEA_API_KEY="replace-with-your-api-key"
export VOLANEA_API_URL="https://api.volanea.com/v1/send"
export VOLANEA_FROM="Example App <notifications@your-verified-domain.com>"
export TEST_RECIPIENT="your-inbox@example.com"
export SEND_TEST_SECRET="replace-with-a-long-random-value"
export PORT=3000
npm start
On Railway, add the same values in the service’s Variables area. Railway makes service variables available as environment variables to both builds and running deployments. Add the values to the service that runs this application, then deploy the staged configuration changes.
Add the complete Railway email service
Create a file named server.js in the project root and copy the following code exactly. Replace only the environment-variable values, not the JavaScript source.
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
const requiredVariables = [
"VOLANEA_API_KEY",
"VOLANEA_API_URL",
"VOLANEA_FROM",
"TEST_RECIPIENT",
"SEND_TEST_SECRET",
];
for (const name of requiredVariables) {
if (!process.env[name]) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
const volanea = {
apiKey: process.env.VOLANEA_API_KEY,
sendUrl: process.env.VOLANEA_API_URL,
from: process.env.VOLANEA_FROM,
};
function safeEqual(left, right) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
async function sendTransactionalEmail() {
const payload = {
from: volanea.from,
to: process.env.TEST_RECIPIENT,
subject: "Your Railway email test succeeded",
text: "Volanea successfully sent this transactional email from a Railway service.",
html: `
<h1>Your Railway email test succeeded</h1>
<p>Volanea successfully sent this transactional email from a Railway service.</p>
<p>You can now replace this test message with an application-triggered email.</p>
`,
};
const response = await fetch(volanea.sendUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${volanea.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const responseText = await response.text();
let responseBody = responseText;
try {
responseBody = responseText ? JSON.parse(responseText) : null;
} catch {
// Keep a non-JSON response as text for server-side diagnostics.
}
if (!response.ok) {
const error = new Error(`Volanea send request failed with status ${response.status}`);
error.status = response.status;
error.details = responseBody;
throw error;
}
return responseBody;
}
app.get("/", (_request, response) => {
response.status(200).json({
ok: true,
message: "Volanea Railway email service is running.",
});
});
app.post("/send-test-email", async (request, response) => {
const providedSecret = request.get("x-send-test-secret");
if (!providedSecret || !safeEqual(providedSecret, process.env.SEND_TEST_SECRET)) {
return response.status(401).json({
error: "Unauthorized",
message: "Provide a valid x-send-test-secret header.",
});
}
try {
const result = await sendTransactionalEmail();
return response.status(202).json({
ok: true,
message: "Volanea accepted the transactional email request.",
result,
});
} catch (error) {
console.error("Volanea send failed", {
status: error.status,
details: error.details,
message: error.message,
});
return response.status(502).json({
ok: false,
error: "Email send failed",
providerStatus: error.status ?? null,
providerDetails: error.details ?? null,
});
}
});
const port = Number(process.env.PORT || 3000);
app.listen(port, "0.0.0.0", () => {
console.log(`Email service listening on port ${port}`);
});
This code creates a small Volanea client configuration object when the process starts. Its API key comes from process.env.VOLANEA_API_KEY; it is not hard-coded. The request then uses POST, an Authorization: Bearer header, and Content-Type: application/json to submit JSON to the send endpoint.
The payload includes from, to, subject, text, and html. Sending both text and html is a practical transactional-email baseline. HTML gives capable clients a richer presentation, while plain text provides a useful fallback for text-only clients and certain accessibility, security, or privacy-focused environments.
The route responds with 202 only after Volanea has accepted the request. Acceptance is not the same as inbox placement or an opened message. A message can be accepted for processing and later be deferred, bounced, suppressed, rejected by a receiving server, or delivered to a folder other than the inbox. That distinction matters when you later add delivery event processing.
Test the service locally
Start the service:
npm start
You should see output similar to this:
Email service listening on port 3000
In a second terminal, check the health endpoint:
curl http://localhost:3000/
Then trigger the test send. Substitute the secret value you set in SEND_TEST_SECRET:
curl -X POST http://localhost:3000/send-test-email \
-H "x-send-test-secret: replace-with-a-long-random-value"
A successful request returns JSON indicating that the application successfully submitted the message to Volanea. The exact response object is provider-generated, so preserve it in logs only when it does not contain sensitive data. It can be useful for correlating an application action with a later event from an email webhook.
Check the recipient inbox and spam or junk folder. If the response indicates acceptance but the message is not visible, do not immediately resend repeatedly. First verify the sender identity, recipient address, suppression state, and domain authentication. Repeated retries for a configuration error can create duplicates once the problem is corrected.
Deploy the service to Railway
Commit the project to a Git repository and deploy it as a Railway service. Railway supports deploying Node.js services from a connected GitHub repository, the Railway CLI, or other supported deployment workflows.
A straightforward repository layout looks like this:
volanea-railway-email/
├── .gitignore
├── package.json
├── package-lock.json
└── server.js
After connecting the repository to a Railway service, set the Start Command to the npm start script if Railway does not detect it automatically. The application must listen on process.env.PORT, which the sample does. Do not force the service to bind only to localhost; the sample binds to 0.0.0.0 so Railway can reach the web process.
In the Railway service variables, add:
VOLANEA_API_KEY=your-real-api-key
VOLANEA_API_URL=https://api.volanea.com/v1/send
VOLANEA_FROM=Example App <notifications@your-verified-domain.com>
TEST_RECIPIENT=your-inbox@example.com
SEND_TEST_SECRET=a-long-random-secret
Deploy the service. Once it is running, use the public Railway domain to test the health endpoint and then call the protected test route:
curl -X POST https://your-railway-domain/send-test-email \
-H "x-send-test-secret: a-long-random-secret"
Do not put the test secret in a front-end application. It protects this demonstration route from casual public use, but the best production design is usually to remove /send-test-email after verification and call sendTransactionalEmail() only from authenticated application workflows.
For example, a password-reset route would first validate the request, identify the account, generate a one-time reset token, save the token securely, and then build an email from trusted server-side data. It should not accept arbitrary from, to, subject, or HTML content from an unauthenticated HTTP request.
Understand the transactional send flow
A reliable email implementation has more states than “send succeeded” and “send failed.” The server-side request is only the first boundary. The typical lifecycle is:
- Your application decides an email should be sent after a meaningful event.
- Your Railway service validates inputs and constructs the recipient-specific content.
- The service sends the JSON request to Volanea.
- Volanea authenticates the request and evaluates the sender, recipient, and message.
- Volanea accepts, rejects, suppresses, or queues the message.
- Receiving infrastructure may accept, defer, reject, filter, or route the message.
- Delivery and engagement events, where available, provide later evidence of what happened.
This is why the code treats a non-2xx HTTP response as a request failure but does not claim that a successful HTTP response means “delivered.” Use the initial response to confirm that your application submitted a valid request. Use later events and application monitoring to understand what happens after that point.
For account-critical messages, store an internal correlation record before or immediately after enqueueing the send. A record might include your own event ID, recipient user ID, message purpose, template version, creation time, and the provider response identifier when available. Avoid storing email body content unnecessarily, especially if it can contain personal or sensitive information.
Sender identity and deliverability checks
A valid API key alone is not enough for dependable transactional sending. The from value must use a sender identity that your Volanea account is configured to send from. Use a domain you control rather than a random public mailbox address.
Before testing, confirm these fundamentals:
- The domain or sender identity is verified in Volanea.
- The address in
VOLANEA_FROMmatches the sender format and identity you configured. - Domain authentication records required by your Volanea setup are published correctly.
- The recipient is spelled correctly and can receive mail.
- The message is transactional and expected by the recipient.
- Your application has a clear policy for retries and duplicate prevention.
Do not guess DNS record names, values, SMTP ports, or API hosts. Copy the domain-authentication values from the Volanea setup experience or API reference and setup guides, then publish them exactly as instructed by your DNS provider.
Also separate transactional and promotional intent. A password reset, receipt, login alert, and account verification are normally transactional messages driven by a user action or account state. Marketing content should follow its own consent, unsubscribe, segmentation, and sending policy. Combining the two carelessly can confuse recipients and make delivery troubleshooting harder.
Make the route safe for real applications
The sample endpoint exists to prove that Railway can reach Volanea. It should not become a generic “send any email” API. A public endpoint that allows callers to choose recipients or HTML content can be abused for spam, phishing, or unexpected spend.
When incorporating the function into an application, follow these rules:
- Authenticate the requester before initiating an account-related message.
- Authorize the action: a signed-in user should only trigger email for their own account unless they have an appropriate role.
- Construct the sender, recipient, subject, and content on the server.
- Validate recipient addresses before saving or sending to them.
- Rate-limit sensitive actions such as password resets and verification emails.
- Use a stable idempotency strategy for events that could be retried by your application or queue.
- Log provider status and your own event ID, but never log the API key.
For sign-up flows, typoed or disposable addresses can turn into avoidable bounces. Consider checking an address before committing it to a high-value workflow with the email address verification tool. Verification is a useful quality check, not permission to email someone; consent and transactional relevance still matter.
A queue is often the next reliability improvement. Instead of making a user wait for an outbound email request, your web route can write the necessary work to a durable queue and return after the application state is safely updated. A worker then sends the message and retries temporary failures with a controlled backoff. This also gives you a natural location to deduplicate jobs.
Common errors
401 Unauthorized or 403 Forbidden
These responses usually mean the API key is absent, malformed, revoked, scoped incorrectly, or being sent with the wrong authorization format. First verify that VOLANEA_API_KEY exists in the Railway service variables for the environment you deployed.
Do not print the key to deployment logs. Instead, log whether the variable is present with a boolean check during a private diagnostic session, then remove that diagnostic. Confirm that the request sends Authorization: Bearer <key> exactly once and that no extra quotation marks, trailing spaces, or copied line breaks are included in the Railway variable value.
A frequent Railway issue is editing a variable but not deploying the staged changes. Redeploy after setting or rotating the variable. If you have multiple Railway environments, verify that you edited the same environment where the service is running.
400 Bad Request or 422 Unprocessable Entity
This generally indicates that Volanea could read the request but rejected its shape or values. Confirm that the body is valid JSON and that the application uses JSON.stringify(payload).
Also verify that the request header is exactly Content-Type: application/json. If this header is missing or says application/x-www-form-urlencoded, the API may not parse the fields as JSON. Check the sender and recipient values for invalid formatting, and ensure the sender identity is configured in Volanea.
Do not replace JSON with a manually concatenated string. Building a plain JavaScript object and serializing it with JSON.stringify prevents common escaping mistakes in HTML, subjects, names, and recipient addresses.
415 Unsupported Media Type
A 415 error is specifically associated with the content type. It usually means the request body is JSON but the Content-Type header is absent or incorrect.
Use this header in the fetch request:
"Content-Type": "application/json"
Do not use multipart/form-data for this payload unless the endpoint documentation explicitly requires it. The sample sends a JSON message request, so application/json is the correct content type.
fetch is not defined
The sample relies on Node’s built-in fetch. If your deployment uses an old Node.js runtime, fetch may not exist. Upgrade the project runtime to a current supported Node.js release rather than silently adding a different HTTP library.
You can declare an engine range in package.json when your team wants to make the runtime expectation explicit:
{
"engines": {
"node": ">=18"
}
}
Keep runtime choices consistent between local development, CI, and Railway. A feature that works locally on a modern Node release can fail after deployment if the deployment runtime is much older.
The request returns before email is sent
This is usually an async and await mistake. The call to fetch() returns a promise. If your route calls sendTransactionalEmail() without await, the route can send a success response before the provider request completes, and unhandled failures can be lost.
The sample uses:
const result = await sendTransactionalEmail();
Keep the route handler marked async, await the sending function, and wrap it in try and catch. In a queue worker, use the same rule: do not acknowledge a job until you have recorded the result appropriate to your retry policy.
Railway reports a deployment failure or no open port
Railway services need a process that stays alive and listens on the port Railway provides. The sample uses:
const port = Number(process.env.PORT || 3000);
app.listen(port, "0.0.0.0");
Do not hard-code a production-only port such as 3000, and do not bind solely to 127.0.0.1 or localhost. Confirm that package.json contains "start": "node server.js", that the file name matches, and that the service logs show the listening message.
The API accepted the request but no email appears
A successful API response means the request was accepted for processing; it does not prove inbox placement. Check the recipient’s spam or junk folder, confirm the address is correct, review the sender setup and authentication, and inspect any available delivery events.
Avoid repeatedly pressing the test endpoint. If you need to troubleshoot a recipient, make one controlled send, capture the application timestamp and response metadata, then use event data to determine whether the message was delivered, deferred, bounced, or suppressed.
Duplicate transactional emails after retries
Network failures are ambiguous. Your service might time out after Volanea accepted a request, so retrying blindly can create a duplicate. This becomes more likely when a load balancer, queue, or serverless retry mechanism retries requests automatically.
Use an application-level idempotency record. For example, store a unique key based on the business event: password-reset:user-123:reset-token-456. Mark it as sent or in progress under a transaction, and have retries reuse that record instead of generating a fresh message every time.
Replace the test route with a real trigger
Once the test message works, move the sending call into a real server-side workflow. The following pattern shows the shape of an account-verification action. It is intentionally pseudocode for the authentication and database pieces, because those must match your application’s identity model.
app.post("/account/request-verification", requireAuthenticatedUser, async (request, response) => {
const user = request.user;
const verificationToken = await createVerificationTokenForUser(user.id);
await sendVerificationEmail({
recipient: user.email,
verificationUrl: `https://app.example.com/verify?token=${encodeURIComponent(verificationToken)}`,
});
response.status(204).end();
});
The important design principle is that the client requests an action, while the server decides the recipient and content from trusted data. Do not accept an arbitrary to address and HTML body from the browser simply because you have an authenticated session.
For high-volume or latency-sensitive paths, place the email work behind a queue. The application can create the verification token and queue an email job in the same reliable workflow. A worker on Railway then reads the job, sends through Volanea, persists the provider result, and retries only errors your policy classifies as temporary.
Next steps
After the initial send works, build the surrounding email workflow rather than treating the API call as the final integration step.
First, add webhooks. Webhooks are HTTP requests Volanea can send to your application when email-related events occur. A webhook receiver should verify the provider’s authenticity mechanism, parse the event, store it idempotently, and return a fast successful response. Process expensive follow-up work asynchronously. Delivery, bounce, complaint, and suppression events can help you keep account state and recipient data accurate.
Second, move reusable markup into templates. Templates help keep account emails consistent, separate content maintenance from application code, and make a versioned email design easier to review. Whether rendering occurs in Volanea or your own application, make template variables explicit, validate them before sending, and provide a plain-text alternative for every important HTML message.
Finally, establish observability. Track send attempts, accepted requests, provider message identifiers, webhook events, and business outcomes such as completed verification or password-reset completion. That gives your team a way to answer the questions that matter operationally: was a message requested, accepted, delivered, acted on, or blocked by a recipient-state problem?
FAQ
Do I need a Volanea Node.js SDK to send email from Railway?
No. This guide uses the documented REST send endpoint with Node’s built-in fetch, so it does not depend on a provider-specific Node.js SDK package. The only installed dependency is Express for the Railway HTTP service.
Where should I store the Volanea API key on Railway?
Store it as a Railway service variable named VOLANEA_API_KEY. Read it from process.env in server-side code. Never place it in browser code, a public repository, or a client-exposed build variable.
Why does the example send both text and html?
HTML provides richer formatting, while plain text provides a robust fallback for clients that do not render HTML or users who prefer text-only mail. Including both is a sound default for transactional email.
Does a successful send request guarantee inbox delivery?
No. It confirms that the provider accepted the request. Final delivery depends on sender setup, recipient state, receiving-server policies, and mailbox filtering. Use event data and controlled testing to understand later delivery outcomes.
Can I leave /send-test-email deployed?
You can keep it temporarily if it remains protected, but it is better to remove it or restrict it to internal administration after testing. Production email should be triggered by authenticated, authorized application workflows rather than by a general public sending endpoint.