Send email with Vercel Functions by calling Volanea’s REST API from a server-side endpoint. This guide gives you a complete TypeScript function, the required install command, environment-variable setup, test requests, production safeguards, and stack-specific troubleshooting.
The implementation deliberately uses the standard fetch API rather than an unverified provider-specific SDK. Vercel Functions run server-side code, so they are the right place to keep a Volanea secret key and make authenticated email requests. Your browser or mobile client should call your function; it should never call the Volanea API directly with a secret key.
What you will build
You will create a Vercel Function at api/send-email.ts. It accepts a JSON POST request containing a recipient address, then sends one transactional email through Volanea.
The function will:
- Accept only
POSTrequests. - Require
Content-Type: application/json. - Parse and validate a recipient email address.
- Read
VOLANEA_API_KEYandEMAIL_FROMfrom server-side environment variables. - Send an authenticated
POSTrequest to Volanea’s/v1/sendendpoint. - Return a safe JSON response to the caller.
- Avoid returning your API key or logging full message content.
- Use an idempotency key so a retried request can be handled safely by the sending API.
This is a useful starting point for receipts, verification emails, password resets, team invitations, account alerts, and other messages caused by an individual application event. It is not a browser-side example and is not intended for bulk marketing sends.
Prerequisites
Before you send email with Vercel Functions, make sure you have the following pieces in place.
A Vercel project with a Node.js or TypeScript codebase
This guide uses the conventional api/ directory for a Vercel Function. Vercel can deploy functions written in JavaScript or TypeScript, and its function runtime handles the incoming HTTP request for the route. You do not need to create an Express server or call server.listen() for this function format.
If you already have a Next.js application, this api/send-email.ts approach can still be deployed as a Vercel Function. If your application already uses Next.js App Router route handlers, you can apply the same Volanea request logic in an app/api/.../route.ts handler instead. The security principles are identical: keep the API key server-side, validate input, await the send request, and return a controlled response.
A Volanea API key
Create a secret API key in your Volanea account. The send endpoint accepts Volanea secret keys, including test-mode keys where applicable. Treat the key as a password for sending email: do not commit it to Git, do not add it to client-side JavaScript, and do not prefix it with NEXT_PUBLIC_ or another client-exposed environment-variable convention.
For the current endpoint fields, authentication details, and optional sending parameters, refer to the email API reference and setup guides.
A verified sender domain and sender address
Set EMAIL_FROM to an address at a domain you have configured for sending. For production traffic, use a sender address that belongs to a verified domain. A request can be syntactically correct while still being rejected or limited if the sender domain has not been verified.
Use a stable sender identity for a message stream. For example, transaction-related messages may come from Acme Accounts <accounts@updates.example.com>, while support replies could use Acme Support <support@example.com>. The exact display name is optional from an application perspective, but the domain portion of the sender address needs to match a domain you control and have configured for sending.
Node.js dependencies installed
Install the Vercel Functions package used in this example for its waitUntil helper:
npm install @vercel/functions
The email request itself does not need a separate Volanea SDK. Modern Node.js runtimes provide fetch, Request, Response, Headers, and crypto.randomUUID() as platform APIs, so the function can call the REST endpoint directly.
Configure environment variables
Create a local environment file for development. For a Vercel project, .env.local is a common choice and should remain uncommitted.
VOLANEA_API_KEY=sk_your_volanea_secret_key
EMAIL_FROM="Acme Notifications <notifications@your-verified-domain.com>"
Replace both placeholder values before testing. VOLANEA_API_KEY must be a Volanea secret key, and EMAIL_FROM must be a sender address you are permitted to use.
Add the same values in Vercel
Local variables do not automatically become deployed variables. Add VOLANEA_API_KEY and EMAIL_FROM to your Vercel project’s environment-variable configuration for every environment that will send email, such as Preview and Production.
Keep the values server-only:
- Add
VOLANEA_API_KEYas a secret environment variable. - Add
EMAIL_FROMas a normal server environment variable. - Select the environments where the values should exist.
- Redeploy after changing deployed environment variables so new function invocations receive the updated configuration.
Do not use a variable name that your frontend framework exposes to the browser. In Next.js, for example, variables beginning with NEXT_PUBLIC_ are intended for browser bundles and are not appropriate for a Volanea API key.
Why two variables are better than hardcoded values
A hardcoded API key creates an incident waiting to happen: it can enter Git history, build logs, screenshots, or copied code. A hardcoded sender address is less severe but still makes it harder to use separate senders for development, staging, and production.
Configuration also makes rotation straightforward. If you need to replace a key, update the environment variable, redeploy, and revoke the old key after confirming the new deployment works. No application source change is required.
Create the Vercel Function
Create a file named api/send-email.ts in the root of your project. Then copy the following code exactly and replace only the values in your environment variables and test request.
import { waitUntil } from '@vercel/functions';
type SendEmailRequest = {
to?: unknown;
};
const VOLANEA_SEND_URL = 'https://api.volanea.com/v1/send';
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
}
function isEmail(value: string): boolean {
// Basic input validation only. It is not a substitute for delivery validation.
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function parseJsonSafely(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
export default {
async fetch(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return json(
{
error: 'method_not_allowed',
message: 'Use POST for this endpoint.',
},
405,
);
}
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
return json(
{
error: 'unsupported_media_type',
message: 'Set Content-Type to application/json.',
},
415,
);
}
let input: SendEmailRequest;
try {
input = (await request.json()) as SendEmailRequest;
} catch {
return json(
{
error: 'invalid_json',
message: 'The request body must contain valid JSON.',
},
400,
);
}
const to = typeof input.to === 'string' ? input.to.trim() : '';
if (!isEmail(to)) {
return json(
{
error: 'invalid_recipient',
message: 'Provide one valid recipient email address in the to field.',
},
400,
);
}
const apiKey = process.env.VOLANEA_API_KEY;
const from = process.env.EMAIL_FROM;
if (!apiKey || !from) {
console.error('Missing VOLANEA_API_KEY or EMAIL_FROM environment variable.');
return json(
{
error: 'server_misconfigured',
message: 'Email sending is not configured.',
},
500,
);
}
const idempotencyKey = crypto.randomUUID();
let volaneaResponse: Response;
try {
volaneaResponse = await fetch(VOLANEA_SEND_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from,
to: [to],
subject: 'Welcome to Acme',
html: `
<h1>Welcome to Acme</h1>
<p>Your account is ready.</p>
<p>If you did not request this email, you can ignore it.</p>
`,
}),
});
} catch (error) {
console.error('Could not reach the Volanea send endpoint.', error);
return json(
{
error: 'email_provider_unavailable',
message: 'The email service could not be reached. Try again shortly.',
},
502,
);
}
const responseText = await volaneaResponse.text();
const responseBody = parseJsonSafely(responseText);
if (!volaneaResponse.ok) {
console.error('Volanea rejected the send request.', {
status: volaneaResponse.status,
responseBody,
});
return json(
{
error: 'email_not_accepted',
message: 'The email provider did not accept the message.',
},
502,
);
}
// Non-critical logging can happen after the HTTP response is created.
// The actual provider request above is awaited before reporting success.
waitUntil(
Promise.resolve().then(() => {
console.info('Transactional email accepted by Volanea.', {
idempotencyKey,
recipientDomain: to.split('@')[1],
});
}),
);
return json(
{
ok: true,
message: 'Email accepted for sending.',
},
202,
);
},
};
This is a complete Vercel Function using Volanea’s REST endpoint. It does not expose the provider response verbatim to the client because provider errors can include details that are useful to an operator but not appropriate to disclose publicly.
Understand the email send request
The core send operation is the outbound fetch call. It performs an HTTP POST to https://api.volanea.com/v1/send and includes three important headers.
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
}
Authorization
The Authorization header authenticates the request with your Volanea secret key. The server reads the key from process.env.VOLANEA_API_KEY, which is available to the function runtime but should not be made available to a browser bundle.
If this header is absent, malformed, or contains a revoked key, the provider will reject the request. Do not attempt to move this request into a React component, client-side form handler, browser extension, or mobile app unless your own trusted backend is issuing the provider request instead.
Content type
The request body is serialized with JSON.stringify(...), so the outbound request must declare Content-Type: application/json. The receiving Volanea endpoint uses that header to interpret the body correctly.
The function also requires JSON from its own callers. That is separate from the outbound provider request: your frontend must send JSON to /api/send-email, and the function then sends JSON onward to Volanea.
Idempotency key
The function creates a unique Idempotency-Key header with crypto.randomUUID(). Volanea supports idempotency keys for safe retries, which is important because networks fail in ambiguous ways. A function may finish submitting a message just as the caller loses its connection; blindly retrying with a different key can create a duplicate email.
For a production workflow, derive the key from the business event rather than generating a fresh random value inside the endpoint. For example, a receipt could use an immutable order ID, and a password-reset email could use a reset-token ID. That gives repeated attempts for the same event the same idempotency identity.
const idempotencyKey = `order-receipt:${order.id}`;
Only do this when the identifier is stable and represents exactly one intended message. Do not use a recipient address alone: one customer may legitimately receive many different transactional emails.
Test the function locally
Start your Vercel project locally using the development command configured by your project. If you use the Vercel CLI, you can run:
vercel dev
Then call the endpoint from another terminal. Replace the port if your local server uses a different one.
curl -i -X POST http://localhost:3000/api/send-email \
-H "Content-Type: application/json" \
-d '{"to":"you@example.com"}'
A successful request returns HTTP 202 with a JSON response similar to this:
{
"ok": true,
"message": "Email accepted for sending."
}
The 202 result means this application endpoint accepted the request after Volanea accepted the message for sending. It should not be interpreted as proof that a recipient has opened the email or that a mailbox provider has placed it in the inbox. Delivery and engagement are later stages in the email lifecycle.
Test invalid input deliberately
Good integration tests include failure paths. Try a missing recipient, malformed JSON, an invalid address, and a request without the JSON content type.
curl -i -X POST http://localhost:3000/api/send-email \
-H "Content-Type: application/json" \
-d '{}'
curl -i -X POST http://localhost:3000/api/send-email \
-H "Content-Type: text/plain" \
-d 'to=you@example.com'
curl -i http://localhost:3000/api/send-email
You should receive controlled 400, 415, and 405 responses, respectively. Returning explicit errors at this boundary makes frontend debugging easier and prevents malformed traffic from reaching your email provider.
Call the function from your application
Your application can call the Vercel Function with fetch. The following browser-side example is acceptable because it calls your endpoint and does not contain the Volanea key.
async function sendWelcomeEmail(to: string) {
const response = await fetch('/api/send-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ to }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message ?? 'Could not send email.');
}
return result;
}
Do not make this endpoint a general-purpose public mail relay. The sample accepts a destination address only to keep the integration understandable, but a production endpoint should normally tie the recipient and message to authenticated application state.
For example, a logged-in user may request a verification email only for the email address currently attached to their account. An order receipt should use the email stored on the completed order, not a client-provided address. A password-reset endpoint needs rate limits, anti-enumeration behavior, and a short-lived reset token before it sends anything.
Use server-side business data for the message
A safer production pattern is to send a stable event identifier to the function and load trusted data on the server. This prevents callers from changing the subject, HTML body, sender identity, or recipient arbitrarily.
// Prefer a request shape like this for authenticated application flows.
body: JSON.stringify({ orderId: 'ord_123' })
The function can verify the user session, retrieve ord_123 from the database, confirm ownership, build the receipt from trusted values, and send it. This gives you a reliable audit trail and sharply reduces the risk of someone using your endpoint to send unwanted email.
Customize the transactional message
The sample keeps the message in code:
{
from,
to: [to],
subject: 'Welcome to Acme',
html: '<h1>Welcome to Acme</h1>...',
}
That is appropriate for proving the integration works. Once you move beyond a single message, separate business events from email presentation.
Keep HTML safe and predictable
Build HTML from trusted values whenever possible. If user-generated content must appear in an email, escape it for HTML before interpolation. Email clients are inconsistent and often have restrictive rendering behavior, so prefer simple semantic markup, inline-friendly styles, readable text, and visible links.
Include a text alternative when your sending workflow and message design require one, and avoid treating emails like web pages. JavaScript, external forms, and complex browser features are not dependable in inboxes. A clear subject, short opening paragraph, obvious call to action, and a recognizable sender generally serve transactional messages better than elaborate layouts.
Keep send logic near the business event
A welcome email belongs after account creation succeeds. A receipt belongs after payment and order persistence are committed. A password-reset email belongs after a secure token has been generated and stored. If you send before the underlying state is durable, recipients can receive messages that refer to accounts, orders, or links that do not exist.
Where your architecture uses queues or background jobs, store an outbox event in the same durable write as the business record, then process it asynchronously. The idempotency key should be derived from that outbox event. This approach avoids losing an email when a database update succeeds but the request to the provider fails immediately afterward.
Deploy to Vercel
Commit the function file but never commit .env.local or any secret key. Push the project to the Git provider connected to Vercel or deploy with your existing Vercel workflow.
Before relying on the endpoint in production, confirm all of the following:
VOLANEA_API_KEYexists in the deployed environment.EMAIL_FROMis present and uses the intended sender identity.- The sender domain is configured for sending.
- Your deployed route is
/api/send-email. - The client sends a
POSTrequest with JSON. - The endpoint has authentication, authorization, or rate limiting appropriate to the event.
- Function logs do not contain secrets or full email bodies.
Vercel Functions scale per incoming request, but email delivery should still be designed as a controlled side effect. A traffic spike, a broken client retry loop, or a malicious caller can generate a large number of requests quickly. Rate limits and business-level authorization are as important as the code that calls the email API.
Preview deployments need their own sending policy
Preview deployments are useful for testing, but they can accidentally send to real customers if they share production configuration. Consider using a Volanea test key or a non-production sender identity for previews. You can also restrict recipient addresses in preview environments to an internal allowlist.
A simple safeguard is to reject non-company recipient addresses when VERCEL_ENV is not production. Keep this as a deliberate operational decision rather than silently changing recipients, since hidden message rewrites can make debugging confusing.
Common errors
The errors below are especially common when you send email with Vercel Functions. Start with the function logs and the HTTP status returned by your endpoint; do not log the API key to diagnose a problem.
Authentication failures: missing, invalid, or revoked API key
Symptoms often include a provider rejection after the function reaches Volanea, or a local server_misconfigured response from the sample function.
Check these items:
- Confirm the environment variable is named exactly
VOLANEA_API_KEY. - Confirm the key exists in the active Vercel environment, not only in
.env.local. - Redeploy after changing the environment variable in Vercel.
- Verify that the key is a secret key intended for API use.
- Remove accidental quotes, spaces, or copied newline characters from the value.
- Confirm that your code sends
Authorization: Bearer ${apiKey}.
Do not test by printing the key in a log. If you need to check configuration without disclosing the secret, log only whether it exists:
console.info('VOLANEA_API_KEY configured:', Boolean(process.env.VOLANEA_API_KEY));
Remove diagnostic logging when the issue is resolved.
Wrong Content-Type on the request to your function
If your caller sends form data, plain text, or an omitted header, request.json() will either fail or the function will return the sample’s 415 unsupported_media_type response.
Use both the JSON header and JSON.stringify:
await fetch('/api/send-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'you@example.com' }),
});
Do not send to=you@example.com as a plain request body unless you also change the function to parse form data. The sample is intentionally JSON-only to make the interface explicit.
Forgetting await when sending the provider request
A serverless function can finish its response lifecycle before un-awaited work completes. If you write fetch(VOLANEA_SEND_URL, options); without await, your endpoint may return success even though the provider request has not finished or has failed.
The sample correctly awaits the outbound send:
const volaneaResponse = await fetch(VOLANEA_SEND_URL, options);
Use waitUntil only for non-critical post-response work, such as telemetry or a best-effort log. The actual email send is critical work, so it must be awaited before returning success.
Calling the wrong route or HTTP method
A function located at api/send-email.ts is available at /api/send-email. Calling /send-email, using GET, or configuring a frontend form to submit to another path will not reach the handler as intended.
The sample returns 405 for non-POST methods. Make sure your request uses POST and that your deployment includes the api/send-email.ts file in the expected project root.
Sender-domain or sender-address rejection
A valid API key does not authorize every possible from address. If Volanea rejects the message after authentication, check whether EMAIL_FROM belongs to a sender domain you have configured and whether the exact address format is appropriate for your account.
Do not set the from field from browser input. Keep it in a server environment variable or other trusted server-side configuration so callers cannot impersonate arbitrary senders.
Malformed JSON or an unexpected request body
await request.json() throws when the body is not valid JSON. It can also produce an object whose to field is missing, an array, a number, or another unsupported type.
The sample catches JSON parsing errors and validates that to is a string. Keep this boundary validation even if your frontend already validates forms. Clients can be bypassed, requests can be replayed, and integrations change over time.
Duplicate emails after retries
If a client times out, it may retry even when the first attempt reached your function. If your function generates a new random idempotency key each time, the provider sees those as independent sends.
For production event flows, use a deterministic idempotency key based on an event that should produce exactly one email, such as an order ID plus the message purpose. Save the event status in your database so you can determine whether a retry is appropriate.
Treating acceptance as inbox placement
A successful send response means the sending provider accepted the message. It does not guarantee the recipient saw it, opened it, or received it in the primary inbox. Mailbox-provider filtering, bounces, suppression status, domain authentication, and recipient behavior happen after the initial API request.
Handle delivery outcomes separately through event processing rather than showing an end user an overconfident “delivered” status immediately after the send call.
Production hardening checklist
The copy-paste function is intentionally complete, but production email flows need application-specific protections. Review this checklist before using a public endpoint at scale.
- Authenticate the caller. Require a user session, a signed request, or trusted service-to-service authentication where appropriate.
- Authorize the action. Confirm the caller is allowed to trigger this specific email for this specific account, order, or recipient.
- Rate-limit the route. Password resets, verification sends, and invitations are frequent abuse targets.
- Use trusted server-side data. Build the recipient, subject, and body from your database or trusted service logic, not raw browser input.
- Use stable idempotency keys. Tie a key to the business event that should create one message.
- Log safely. Record correlation IDs, message purpose, and recipient domain when needed; avoid secrets, reset links, or full message bodies.
- Separate environments. Use distinct keys, domains, or recipient restrictions for development, preview, staging, and production.
- Observe outcomes. Alert on provider failures, unexpected send volume, bounces, and complaint-related issues.
The most important design choice is to treat email as a side effect of an authorized, durable business event. A function that accepts a free-form recipient and arbitrary HTML is easy to demo but dangerous to expose. A function that receives an authenticated event ID and renders a trusted transactional message is easier to secure, test, and operate.
Next steps
Once your first transactional message works, the next priorities are reliability and maintainability.
Process webhooks for delivery events
Webhooks let your application receive event notifications after the initial send request. Use them to reconcile message state, record bounces, react to complaints, update suppression-related business logic, and investigate delivery failures.
Webhook handlers should verify signatures, parse the raw request body according to the provider’s verification requirements, acknowledge valid events quickly, and process duplicate events safely. Store an event identifier and make handling idempotent because webhook delivery can be retried.
Move repeated markup into templates
Templates help keep consistent branding and reduce duplicated HTML across functions. They are especially helpful when multiple application events use the same layout but different data, such as receipts, invitations, and account notifications.
Keep template data trusted and validated. Decide which values are required, define fallback behavior for missing data, and test representative messages in common email clients. A template system improves maintainability, but it does not remove the need for sender authentication, input validation, or event-level idempotency.
Add recipient-quality checks where appropriate
Basic regex validation prevents obvious malformed input but cannot establish that a mailbox exists or can receive mail. For user-entered addresses, use the address verification tool as an additional check before an important workflow, while still treating mailbox-provider outcomes and bounce events as the final operational signal.
FAQ
Do I need a Volanea SDK to send email with Vercel Functions?
No. This guide uses the standard fetch API to call Volanea’s REST endpoint directly. The only installed package in the sample is @vercel/functions, used for Vercel’s waitUntil helper; the actual email send uses standard HTTP.
Can I call the Volanea API directly from a React component?
No. A Volanea secret key must remain on the server. Call your own Vercel Function from the React component, and let the function authenticate with Volanea using a server-side environment variable.
Why does the function return HTTP 202 instead of 200?
The sample uses 202 Accepted to communicate that the message was accepted for sending after the provider accepted the request. It does not claim final delivery or inbox placement.
Should I use waitUntil to send the email after returning a response?
No. Await the Volanea send request before responding. Use waitUntil only for non-critical work that can safely happen after the response, such as telemetry or best-effort logging.
How do I prevent duplicate transactional emails?
Use an idempotency key derived from a durable business event, such as an order ID and message type. Reuse that key when retrying the same intended send, and record your application’s event state so retries are controlled.