Send email with Express from your server—not from browser code—so your Volanea API key stays private and each message can be tied to an authenticated application action. This guide builds a small Express endpoint that makes a JSON request to Volanea’s POST /v1/send endpoint and returns the provider response to the caller.
The integration intentionally uses the REST API rather than a fictional Express-specific SDK. Express receives and validates the application request; Node.js sends the outbound HTTPS request; Volanea handles the email send request. That division keeps the code portable, makes the request easy to inspect, and lets you use the same pattern in an existing Express API.
What you will build
You will create a minimal Express application with one protected-in-development route:
POST /send-test-emailaccepts a JSON request body.- The route reads the recipient, subject, and HTML content.
- It uses a Volanea API key from
process.env.VOLANEA_API_KEY. - It sends JSON to
https://api.volanea.com/v1/send. - It returns Volanea’s response body and HTTP status without exposing the API key.
The code uses Node.js’s built-in fetch, available in current Node.js releases, so the only package you need for this guide is Express. This is useful when you want a direct, inspectable REST integration rather than another dependency that abstracts headers, payloads, and errors.
Before you run it, make sure the sender address you use belongs to a domain that has been configured for sending in your Volanea account. Replace the example sender address with an address on your own sending domain. Do not use a customer’s address or an arbitrary public mailbox as the from address.
Prerequisites
You need the following before writing code:
- Node.js 18 or later. The sample relies on the native
fetchimplementation included with modern Node.js. - A Volanea API key. Store it in an environment variable on the server running Express.
- A configured sending domain. The
fromaddress must use a domain you control and have set up for email sending. - An Express project. The guide starts from an empty directory, but the route can also be added to an existing application.
- A safe test recipient. Use an inbox you control while confirming the integration.
Keep the API key on the server only. A browser, React client bundle, mobile app, public repository, or client-readable environment variable is not an appropriate place for a sending credential. Anyone who obtains the key could submit requests under your account.
For a broader reference of the endpoint and setup details, consult the email API reference and setup guides as you adapt the example to your application.
Install Express
Create a project directory, initialize a package manifest, and install Express:
mkdir volanea-express-email
cd volanea-express-email
npm init -y
npm install express
Create a file named server.js in that directory. The complete contents are in the next section.
This guide does not require axios, a mailer wrapper, or a provider SDK. Native fetch is enough for one HTTPS JSON request. Keeping the dependency surface small is particularly helpful for transactional email, where you want failures to be clear: an Express validation failure, a network failure, a non-success API response, and an accepted email request should be distinguishable in logs.
Configure environment variables
Create a local .env file alongside server.js:
VOLANEA_API_KEY=sk_your_volanea_api_key
EMAIL_FROM=Acme Support <support@your-verified-domain.com>
PORT=3000
Replace sk_your_volanea_api_key with an actual Volanea secret key. Replace support@your-verified-domain.com with an address on your configured sending domain.
Do not commit .env to source control. Add it to .gitignore:
.env
node_modules
For local development, Node can load the file directly with --env-file. In a deployed environment, configure VOLANEA_API_KEY, EMAIL_FROM, and PORT through your hosting platform, container runtime, process manager, or secret-management system instead of copying a local .env file to production.
A useful separation is to use different credentials for development and production when your account configuration supports it. Regardless of environment, never log the full value of VOLANEA_API_KEY. It is a secret, not an identifier.
Complete Express email sending example
Copy this entire file into server.js:
const express = require('express');
const app = express();
const port = Number(process.env.PORT || 3000);
app.use(express.json({ limit: '100kb' }));
function requireEnvironment(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
app.get('/', (req, res) => {
res.json({
ok: true,
message: 'Express email example is running.',
});
});
app.post('/send-test-email', async (req, res) => {
const { to, subject, html } = req.body;
if (typeof to !== 'string' || !to.trim()) {
return res.status(400).json({
error: 'Request body must include a non-empty "to" email address.',
});
}
if (typeof subject !== 'string' || !subject.trim()) {
return res.status(400).json({
error: 'Request body must include a non-empty "subject".',
});
}
if (typeof html !== 'string' || !html.trim()) {
return res.status(400).json({
error: 'Request body must include non-empty "html" content.',
});
}
let apiKey;
let from;
try {
apiKey = requireEnvironment('VOLANEA_API_KEY');
from = requireEnvironment('EMAIL_FROM');
} catch (error) {
console.error(error.message);
return res.status(500).json({
error: 'Server email configuration is incomplete.',
});
}
const payload = {
from,
to: to.trim(),
subject: subject.trim(),
html,
};
try {
const volaneaResponse = await fetch('https://api.volanea.com/v1/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const responseText = await volaneaResponse.text();
let responseBody;
try {
responseBody = responseText ? JSON.parse(responseText) : null;
} catch {
responseBody = { raw: responseText };
}
if (!volaneaResponse.ok) {
console.error('Volanea email request failed', {
status: volaneaResponse.status,
responseBody,
});
return res.status(volaneaResponse.status).json({
error: 'Volanea rejected the email request.',
details: responseBody,
});
}
return res.status(200).json({
message: 'Email request accepted by Volanea.',
data: responseBody,
});
} catch (error) {
console.error('Unable to reach Volanea', error);
return res.status(502).json({
error: 'Could not connect to the email provider.',
});
}
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
Start the server with Node’s environment-file support:
node --env-file=.env server.js
You should see output similar to this:
Server listening on http://localhost:3000
The Express process is now ready to accept a local request. The application does not send an email on startup. That is deliberate: sending should happen in response to a specific server-side action, such as creating an order, generating a password-reset token, accepting an invitation, or receiving a verified form submission.
Send a test transactional email
With the server running, open a second terminal and submit a request with curl:
curl -X POST http://localhost:3000/send-test-email \
-H "Content-Type: application/json" \
-d '{
"to": "your-inbox@example.com",
"subject": "Your Volanea and Express test worked",
"html": "<h1>Hello from Express</h1><p>This transactional email was sent through the Volanea REST API.</p>"
}'
Replace your-inbox@example.com with an inbox you can check. The request body sent to your Express application is not the same as the request it sends to Volanea. Your route validates the incoming values, adds the trusted from value from server configuration, adds the authorization header, serializes the outbound payload, and then calls the email endpoint.
If Volanea accepts the request, the route returns a JSON response containing the provider response under data. Treat that response as an API acceptance result. It confirms the provider accepted or processed the send request; it does not by itself prove that a recipient opened the message or that every downstream mailbox provider will display it in the inbox.
For production logging, record a correlation value from your own application—such as an order ID, reset-token record ID, or notification ID—alongside the returned API response. Do not log the whole message body by default if it might contain personal, financial, health, or security-sensitive information.
How the send email with Express flow works
The key architectural rule is simple: the client asks your application to perform a business action, and your server decides whether email should be sent. The client should never call the email provider directly with your account credential.
1. Express parses the client request
app.use(express.json()) adds JSON parsing middleware. Without it, req.body will usually be undefined for a JSON request, and destructuring const { to, subject, html } = req.body will fail or produce incorrect behavior.
The limit: '100kb' setting gives the sample a bounded request size. A small limit is appropriate for a simple HTML transactional message. If your use case requires attachments or large generated HTML documents, do not simply increase limits without considering the memory, abuse-prevention, and attachment-handling implications.
2. The route validates basic inputs
The sample checks that to, subject, and html are non-empty strings. This is intentionally basic validation, not a full authorization model. In an actual product, the route should normally derive the recipient and content from trusted server-side records rather than accept arbitrary addresses and HTML from an unauthenticated public request.
For example, a password-reset route should receive an account identifier or email address, rate-limit the request, create a short-lived token, and build the reset URL server-side. It should not accept an HTML string from the browser and forward it directly to the email provider.
3. The application reads server-only configuration
VOLANEA_API_KEY and EMAIL_FROM are accessed only inside the Node.js process. The code fails clearly if either variable is missing. This prevents a dangerous fallback where a deployment accidentally starts with an empty credential or a placeholder sender address.
Using EMAIL_FROM also avoids hard-coding sender values in route logic. When you move from a development domain to a production domain, configuration changes independently of the application code. It is also easier to review allowed sender addresses in deployment configuration than to search every handler in a repository.
4. Native fetch makes the REST request
The request includes three important parts:
method: 'POST'tells the server this is a send request.Authorization: Bearer ...supplies the Volanea secret key without placing it in the URL.Content-Type: application/jsontells the API that the request body is JSON.
JSON.stringify(payload) is required because fetch does not automatically serialize plain JavaScript objects into JSON request bodies. Sending the object directly produces the wrong body format. Sending JSON without the Content-Type header can also cause an API to reject or misinterpret the request.
5. The route separates provider errors from network failures
A response such as an authentication, validation, sender, or rate-limit error still means the HTTP request reached the provider. The sample handles that with if (!volaneaResponse.ok) and returns the provider status plus a parsed response body.
A thrown error in the catch block means the request did not complete normally at the network layer—for example, a DNS issue, TLS problem, connection interruption, or outbound firewall restriction. The sample returns 502 to show that the application could not successfully reach its upstream email service.
That distinction makes troubleshooting much faster. “The provider rejected this request” and “our runtime could not connect to the provider” require different fixes.
Use this route safely in a real application
The /send-test-email route is a development example. It accepts recipient, subject, and HTML content so you can verify the integration quickly. Exposing that exact route publicly in production would allow an attacker to use your system as an email relay.
Replace it with a business-specific route or service function. The client should request an action; the server should authorize it, load trusted records, render approved content, and send to a recipient selected by server-side rules.
A safer application shape
For a typical transactional flow, use these steps:
- Authenticate the person or service requesting the action.
- Authorize that action for the current account, organization, or resource.
- Load recipient data from your database or another trusted source.
- Create any required record first, such as an order, invite, reset token, or verification code.
- Build subject and HTML on the server from approved content and escaped dynamic values.
- Send the email after the transaction is committed, or enqueue a durable background job.
- Record the provider response and your internal notification identifier.
- Process subsequent delivery events when your application needs lifecycle visibility.
This design protects against arbitrary-content injection, accidental mail relay behavior, and confusing state where a message says an action occurred before the database transaction actually completed.
Avoid sending inside fragile request paths
For a low-volume administrative tool, awaiting the provider request inside the Express route may be acceptable. For user-facing production flows, consider a job queue or durable outbox pattern. The HTTP response can confirm that the account action succeeded while a worker reliably attempts the email send with controlled retries.
A queue is especially valuable for receipts, provisioning notices, subscription changes, and other messages that should survive an application restart. It also gives you a natural place to control concurrency, inspect failed jobs, and avoid retrying a request from the browser in a way that could create duplicate messages.
If you do retry, design retries around the type of failure. Do not retry an authentication failure, malformed JSON payload, or invalid sender address without a configuration or code change. A temporary network failure or upstream server error may be retryable, but retries should use bounded attempts and backoff.
Compose transactional email content carefully
The sample sends HTML because transactional messages are usually easier to scan with a structured layout. HTML email is not the same as web-page HTML, however. Mail clients vary substantially in CSS support, and many security-sensitive message types should remain visually simple.
Use semantic content first: a clear heading, concise explanation, one obvious action, and a plain-language fallback path. Avoid placing vital information only in an image or relying on advanced browser JavaScript, which email clients generally do not execute.
Escape dynamic content
Never concatenate untrusted user-provided strings directly into an HTML message. A user’s display name, organization name, support ticket title, or free-form input can contain markup characters. Escape those values before inserting them into HTML, or use a template system that performs contextual HTML escaping by default.
The same principle applies to links. Build application URLs on the server from trusted base URLs and opaque tokens. Do not let a browser submit a full destination URL for a password reset, invitation, or security notification unless the server strictly validates it against an allowlist.
Keep transactional messages focused
A transactional email should explain a real user or account event: reset a password, verify an address, review a receipt, accept an invitation, or investigate a login alert. Give the recipient enough context to recognize why they received it and what they should do next.
For sensitive actions, include a plain-text alternative path where appropriate, such as copying a code or visiting your application directly. Do not include secrets in log output, and be cautious about putting sensitive details into the subject line because mail clients may display subjects in notifications and shared-device previews.
Common errors
401 or another authentication failure
An authentication failure usually means the Authorization header is missing, malformed, or contains the wrong key. Confirm that the server process has VOLANEA_API_KEY available and that the code uses this exact header shape:
Authorization: `Bearer ${apiKey}`
Do not add quotation marks around the key value in your deployment configuration unless your platform specifically requires them. Restart the Node process after changing environment variables. Also verify you did not accidentally use a client-side variable, a revoked key, or a key from the wrong environment.
400 or validation errors from the send endpoint
A validation error commonly means one of the required email fields is empty, the JSON is malformed, or the sender address is not acceptable for the account configuration. Start by logging the HTTP status and response body, as the sample does, but redact personal data and secrets.
Confirm that the outbound payload contains from, to, subject, and html as expected. The from value should be a sender address on your configured sending domain. Make sure the recipient is a valid single email address when using this one-message example.
Wrong Content-Type
If the request body is JSON, the outbound request must include:
'Content-Type': 'application/json'
A frequent mistake is using express.urlencoded() for an inbound HTML form and then assuming it changes the outbound provider request. These are separate concerns. Express middleware parses what your application receives; the fetch headers and JSON.stringify() call determine what your application sends to Volanea.
req.body is undefined
If req.body is undefined, ensure this middleware appears before the route declaration:
app.use(express.json());
Also ensure the caller sends Content-Type: application/json. A request sent as form data or plain text will not be parsed by express.json() in the same way. For an API route, keep the client and server explicit about the content type.
await is missing
fetch() returns a promise. If you omit await, your route may try to use a Promise as though it were an HTTP response, or it may return success before the upstream email request completes.
This is wrong:
const volaneaResponse = fetch('https://api.volanea.com/v1/send', options);
This is correct inside an async route handler:
const volaneaResponse = await fetch('https://api.volanea.com/v1/send', options);
The callback passed to app.post() must also be marked async when it uses await.
The server cannot connect to Volanea
A fetch error that reaches the catch block is different from a normal error response. Check whether your server has outbound HTTPS access, whether a corporate proxy or firewall blocks the request, and whether the runtime has a DNS or TLS configuration problem.
Do not assume every failure is an API-key problem. If there is no HTTP response object, focus first on network connectivity and application-runtime logs.
The email request succeeds but the message is not visible in the inbox
First check spam, junk, and any mailbox rules. Then verify the sender domain configuration and inspect the provider response and delivery information available to your account. An accepted send request and an inbox placement outcome are different stages of email delivery.
Also inspect the message itself. A misleading sender, sparse content, broken links, or a large volume of similar messages can create delivery and recipient-trust problems. Start with a clear sender identity and concise transactional content before optimizing visual design.
Duplicate messages after retries
Duplicates often happen when an application retries a request after a timeout without knowing whether the original request reached the provider. Store an internal notification record before sending, give it a stable business identifier, and ensure a worker does not process the same notification repeatedly.
For important flows, use a durable outbox or queue with explicit job state. Do not solve duplicates by disabling all retries; instead, make retries deliberate, bounded, and traceable.
Production checklist
Before treating the integration as production-ready, review this checklist:
- The API key is present only in server-side secret configuration.
-
.envfiles and keys are excluded from version control. - The
fromaddress uses a configured domain you control. - Public clients cannot submit arbitrary recipient, subject, and HTML values to a sending route.
- Routes that cause sends require authentication, authorization, and rate limiting where appropriate.
- Dynamic HTML values are escaped before rendering.
- Application logs redact API keys, reset tokens, and unnecessary recipient data.
- Retries distinguish between invalid requests and temporary delivery-service or network failures.
- Important send attempts have an internal notification ID or job ID.
- Your team has a process for reviewing bounces, complaints, and delivery events.
The goal is not merely to make one request return success. A reliable transactional email system makes send behavior observable, prevents unauthorized use, preserves consistency with application data, and can be debugged after an incident.
Next steps
After the first send works, move the request-building logic into an email service module so business routes do not duplicate authorization headers, sender configuration, error handling, and logging. That makes it easier to test and to change the sending implementation later without editing every controller.
Next, add webhooks to receive event notifications from the email system. Webhooks let your application react to downstream events such as delivery outcomes or recipient interactions when those events matter to your product. Treat webhook endpoints as public-facing infrastructure: verify their authenticity according to the provider documentation, return responses promptly, store event identifiers, and make processing idempotent because the same event can be delivered more than once.
Then adopt templates for recurring message types such as welcome messages, receipts, password resets, and invitations. Templates keep presentation consistent and separate reusable email content from request-handling code. Keep variable names intentional, validate required template data before sending, and test rendering with realistic names, long values, and missing optional fields.
Finally, replace the open-ended test route with one or more purpose-built flows: sendPasswordReset, sendReceipt, sendInvite, or sendSecurityAlert. Each flow should own its authorization rules, trusted data lookup, content construction, and retry policy.
FAQ
Do I need a Volanea SDK to send email with Express?
No. Express can call Volanea’s REST API directly with Node.js fetch. This guide installs Express and uses a standard HTTPS POST request to https://api.volanea.com/v1/send.
Should I send email from an Express route or from the browser?
Send from the Express server. The server can keep the Volanea API key private, enforce authorization, select trusted recipients, and build approved transactional content. Never expose the sending key in browser JavaScript.
Why does the sample use await fetch()?
Email sending is an asynchronous network operation. await pauses the route’s async function until the provider responds or the request fails, allowing the application to handle the actual response status and body correctly.
Can I use this pattern for password resets and receipts?
Yes, but do not keep the example’s arbitrary-input test route. Build a dedicated server-side flow that creates the reset token or receipt record first, loads trusted recipient data, renders safe content, and records the send attempt.
Does a successful API response mean the email reached the inbox?
No. It means the API request was accepted or processed by the provider. Delivery and inbox placement occur later and can be affected by recipient mailbox rules, sender-domain configuration, message content, and other delivery factors.