Send email with Bun by calling the Volanea REST API from your server-side TypeScript or JavaScript code. This guide uses Bun’s built-in fetch, so you do not need to install or rely on an unverified third-party SDK.
Volanea’s send endpoint is POST https://api.volanea.com/v1/send. It accepts a secret API key, a sender from a verified domain, one or more recipients, a subject, and email content. The endpoint supports sending to up to 50 recipients in a single message request; for a single transactional message, use a one-item to array. (volanea.com)
What you will build
You will create a small Bun script that:
- Reads a Volanea API key from
VOLANEA_API_KEY. - Reads sender and recipient addresses from environment variables.
- Sends one HTML-and-text transactional email with
fetch. - Uses
awaitso your process waits for the API result. - Fails with a useful error message when Volanea returns a non-success HTTP status.
- Includes an idempotency key so a retry can be made safely.
The example is deliberately low-level. It maps directly to the REST request rather than wrapping the API in convenience methods that can hide headers, status codes, or response bodies. That is useful in Bun server routes, cron jobs, workers, command-line jobs, and backend services where you want a small dependency surface.
Prerequisites
Before you send production email, make sure you have the following:
- Bun installed. Bun is a JavaScript and TypeScript runtime with a package manager and a built-in implementation of the standard
fetchAPI. (bun.sh) - A Volanea secret API key. Volanea API documentation identifies secret keys with
sk_…orsk_test_…prefixes. Keep them server-side only. (volanea.com) - A verified sending domain. The
fromaddress must belong to a verified domain outside test-mode use. (volanea.com) - A recipient email address you are authorized to contact.
Do not put a secret Volanea key in browser code, a client-side React component, a public repository, or a static site bundle. Any client that can read the key can send through your account. Keep the call in a trusted Bun process: an API route, a server handler, a background worker, or a local script used for development.
Install Bun and create the project
If Bun is not already installed on macOS or Linux, install it with the official command:
curl -fsSL https://bun.com/install | bash
Create a directory for the example and initialize a Bun project:
mkdir volanea-bun-email
cd volanea-bun-email
bun init -y
This integration has no external SDK dependency to install. Bun provides fetch globally, and fetch is the appropriate built-in primitive for making HTTPS requests from Bun. (bun.com)
If you are adding this code to an existing Bun project, run the project’s normal dependency installation command first:
bun install
That command installs the dependencies already declared in package.json; the email sender below itself adds none. Avoid installing a package merely to send one JSON HTTP request. A direct REST integration is smaller, portable, and makes the request format easy to inspect during troubleshooting.
Configure Volanea environment variables
Create a .env file in the project root:
VOLANEA_API_KEY=sk_test_replace_with_your_key
VOLANEA_FROM=Acme Support <support@your-verified-domain.com>
VOLANEA_TO=you@example.com
Replace all placeholder values before running the script. In particular, VOLANEA_FROM must use an address at a domain you have verified for sending. Do not copy an example sender address into production unless it is a sender you own and have configured.
Bun automatically reads .env files and makes values available through process.env. It also supports environment-specific files such as .env.development and .env.local; later files have higher precedence. (bun.com)
Add the local secrets file to .gitignore immediately:
.env
.env.local
.env.*.local
For deployed applications, configure VOLANEA_API_KEY, VOLANEA_FROM, and any other secrets in the environment-variable system offered by your hosting provider or secret manager. A .env file is convenient locally, but it is not a substitute for deployment secret management.
Choosing test and live keys
Use a test key while you are proving the integration. Volanea documents sk_test_… keys alongside live secret keys. This lets you exercise request construction without accidentally treating a development run as a production delivery. (volanea.com)
When you move to live sending, change only the environment variable in the production environment. Do not hard-code a live key in send-email.ts, and do not maintain separate copies of the script with different keys. The code should be identical across environments; configuration should differ.
Complete Bun email example
Create send-email.ts with the following complete script:
const apiKey = process.env.VOLANEA_API_KEY;
const from = process.env.VOLANEA_FROM;
const to = process.env.VOLANEA_TO;
if (!apiKey) {
throw new Error("Missing VOLANEA_API_KEY environment variable.");
}
if (!from) {
throw new Error("Missing VOLANEA_FROM environment variable.");
}
if (!to) {
throw new Error("Missing VOLANEA_TO environment variable.");
}
const payload = {
from,
to: [to],
subject: "Welcome to Acme",
text: "Thanks for creating an Acme account. Your workspace is ready.",
html: `
<!doctype html>
<html lang="en">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h1>Welcome to Acme</h1>
<p>Thanks for creating an account. Your workspace is ready.</p>
<p>If you did not create this account, you can safely ignore this email.</p>
</body>
</html>
`,
};
const response = await fetch("https://api.volanea.com/v1/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
`Volanea send failed (${response.status} ${response.statusText}): ${responseText}`,
);
}
console.log("Volanea accepted the email request:");
console.log(responseText);
Run it from the project directory:
bun run send-email.ts
If the request succeeds, the script prints the response returned by Volanea. A successful API response means the service accepted and processed the request; it is not the same thing as proof that the recipient has read the message. Mailbox delivery can later be affected by receiving-server policy, a mailbox rejection, a bounce, or recipient-side filtering. Treat message lifecycle events as the source of truth for operational delivery monitoring.
Why this code is copy-pasteable
The script does not rely on application framework globals, a package-specific client, a callback API, or an implicit transpilation step. Bun can execute TypeScript directly, fetch is available globally, and the script validates all required configuration before it attempts an HTTP request. Bun’s documentation recommends fetch for networking and shows that it supports a POST method, custom headers, and reading response bodies. (bun.com)
The body is passed through JSON.stringify(payload). This matters: fetch does not automatically serialize ordinary JavaScript objects as JSON. Without serialization, your server may send an invalid body such as [object Object], which is not valid JSON and will not match the API’s expected request format.
The response is read as text rather than immediately calling response.json(). That makes the error path more robust because a proxy, a gateway, or an unexpected server response may return non-JSON text. Once you know the response shape your integration expects, you can parse successful JSON responses separately, but preserving the raw error body during initial integration is extremely useful.
Understand the request fields
The example sends the basic fields used in a transactional email request:
from: The visible sender. Use a recognizable display name plus an address at a verified domain, such asAcme Support <support@example.com>.to: An array containing the recipient address. The send endpoint can accept one recipient or up to 50 recipients for one message request. (volanea.com)subject: The email subject line.text: A plain-text alternative for recipients and clients that do not render HTML.html: The HTML version of the email.Idempotency-Key: A request header that identifies this logical send attempt for safe retries. Volanea documents this header for avoiding duplicate sends. (volanea.com)
Include both HTML and text content
Keep a text version even when your product email is designed in HTML. Plain text provides a usable fallback for mail clients that block HTML or users who prefer text-only messages. It also forces you to make sure the actual message remains understandable without layout, images, colors, or buttons.
Your text version does not have to reproduce every visual detail. It should include the core message, important destination URLs in readable form when applicable, and any instructions required for the user to take action. For a password reset, that means including the reset link. For an invoice, that means including the amount, date, and a way to access the invoice.
Use a legitimate and stable sender
The sender is not cosmetic. It affects recipient recognition, reply handling, domain alignment, and support workflows. Choose a sender that maps to the message type: security@ for security notices, receipts@ for payment receipts, or support@ for product communications.
Do not rotate sender identities just to make messages appear novel, and do not use a from address that recipients cannot reply to unless the product experience clearly explains how users get help. If replies should go to a different inbox, consult the API reference for the supported reply-to field and configure it explicitly rather than trying to encode reply behavior in the display name. Volanea’s API reference includes sender-name and reply-to fields in its sending model. (volanea.com)
Send email with Bun from an HTTP endpoint
A standalone script is useful for testing, but production applications commonly send email in response to an authenticated server request. The following example uses Bun.serve to expose a local endpoint. It validates only the structure of the request for demonstration purposes; your real application must also authenticate the caller and apply authorization rules.
const apiKey = process.env.VOLANEA_API_KEY;
const from = process.env.VOLANEA_FROM;
if (!apiKey || !from) {
throw new Error("Set VOLANEA_API_KEY and VOLANEA_FROM before starting the server.");
}
const server = Bun.serve({
port: 3000,
async fetch(request) {
const url = new URL(request.url);
if (request.method !== "POST" || url.pathname !== "/send-welcome") {
return new Response("Not found", { status: 404 });
}
const body = await request.json().catch(() => null);
const email = body?.email;
if (typeof email !== "string" || email.length === 0) {
return Response.json(
{ error: "Provide an email address in the JSON request body." },
{ status: 400 },
);
}
const volaneaResponse = await fetch("https://api.volanea.com/v1/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `welcome:${crypto.randomUUID()}`,
},
body: JSON.stringify({
from,
to: [email],
subject: "Welcome to Acme",
text: "Welcome to Acme. Your account is ready.",
html: "<h1>Welcome to Acme</h1><p>Your account is ready.</p>",
}),
});
const result = await volaneaResponse.text();
if (!volaneaResponse.ok) {
console.error("Volanea error:", result);
return Response.json(
{ error: "Email could not be queued." },
{ status: 502 },
);
}
return new Response(result, {
status: 201,
headers: { "Content-Type": "application/json" },
});
},
});
console.log(`Listening on http://localhost:${server.port}`);
Start the server:
bun run server.ts
Then call it locally:
curl -X POST http://localhost:3000/send-welcome \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com"}'
Do not expose an endpoint like /send-welcome publicly without authentication, rate limiting, abuse controls, and application-level eligibility checks. Otherwise, an attacker can use it to trigger unwanted email or enumerate valid accounts based on behavior differences. A safe production flow derives the recipient from the authenticated server-side user record rather than trusting an arbitrary client-provided destination.
Use idempotency keys correctly
Network failures are ambiguous. Your Bun process may time out after Volanea has accepted the request, or a deployment may interrupt your worker after the request reaches the API. Retrying blindly can result in duplicate receipts, duplicate magic links, or multiple password reset messages.
Volanea supports the Idempotency-Key request header for safe retries. (volanea.com) The key must identify the business event, not merely the individual HTTP attempt.
For example, a random UUID is good for a one-off manual send, as shown in the initial script. For a durable workflow, use a stable value based on a record you already control:
const idempotencyKey = `password-reset:${user.id}:${resetRequest.id}`;
If your job retries the same reset request, send the same idempotency key. If the user later initiates a new reset request, create a new reset-request record and therefore a new key. Do not reuse a constant such as password-reset across all users, and do not generate a new random key every time a retry occurs. Both mistakes defeat the purpose of idempotency.
Decide what to retry
Retrying every failure is not safe or useful. Divide failures into categories:
- Configuration and request errors such as missing fields, malformed JSON, an unverified sender, or unauthorized credentials usually need a code or configuration fix. Do not automatically retry them.
- Temporary service or network failures may be appropriate to retry with exponential backoff, bounded attempts, and a stable idempotency key.
- Recipient-level delivery outcomes such as bounces should update your application state and should not be solved by repeatedly resending the same content.
Record the logical event ID, Volanea response status, response body, retry count, and idempotency key in your logs. This lets an operator answer the most important incident question: whether the application attempted the same message once, twice, or not at all.
Common errors when using Bun and Volanea
401 or 403 authentication failures
A 401 Unauthorized or 403 Forbidden response usually means the API key is absent, malformed, revoked, from the wrong environment, or not being sent in the expected authorization header.
Check these items in order:
- Confirm
VOLANEA_API_KEYis available to the Bun process, not merely present in a file somewhere else. - Restart a long-running Bun server after changing local environment configuration.
- Ensure the request sends
Authorization: Bearer ${apiKey}. - Ensure you did not include literal placeholder text such as
sk_test_replace_with_your_key. - Ensure a test key is being used with the intended test workflow and a live key with the intended live workflow.
- Never log the full key while diagnosing the issue.
For a local check that does not print the secret, temporarily log only whether it exists:
console.log("VOLANEA_API_KEY configured:", Boolean(process.env.VOLANEA_API_KEY));
Bun loads .env files automatically under its standard invocation, but Bun’s documentation notes that automatic loading is disabled when Bun is invoked as Node-compatible node in certain modes. If your deployment wrapper changes how Bun is started, explicitly verify which environment values it passes to the process. (bun.com)
400 or 422 because the JSON body is wrong
A validation response usually points to the payload rather than the transport. Common causes include a missing from, an empty recipient list, an invalid email address, a missing subject, or a body that is not valid JSON.
The two most common Bun mistakes are forgetting JSON.stringify(payload) and forgetting the JSON content-type header. The request must include both:
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
Do not use application/x-www-form-urlencoded, multipart/form-data, or a raw JavaScript object for this JSON API request. If you receive a validation error, print the response body in development exactly as the example does. It often identifies the invalid field directly.
Wrong Content-Type
The content type describes the format of the bytes in the HTTP request body. When you call JSON.stringify, the body is JSON text, so the correct value is:
Content-Type: application/json
Do not confuse the request content type with the email content type. The HTTP request is JSON even though one JSON field contains HTML for the email body. Your html field should contain HTML markup as a string; it does not mean the entire API request uses Content-Type: text/html.
The process exits before the email request finishes
fetch returns a promise. If you do not use await, your script may finish before you have handled the API result, and errors can become unhandled promise rejections.
Incorrect:
fetch("https://api.volanea.com/v1/send", options);
console.log("Done");
Correct:
const response = await fetch("https://api.volanea.com/v1/send", options);
console.log(response.status);
At top level, Bun supports the await used in the example script. In a function, mark the function async and await the send call inside it. Also await response.text() or response.json() before returning from a server handler if you need the result for logging or for the HTTP response.
A sender-domain or from-address rejection
A sender address must be associated with a verified domain for normal delivery. (volanea.com) A common mistake is to configure VOLANEA_FROM with a personal mailbox or a domain that has not completed verification.
Make the environment variable explicit and deployment-specific. For example, use a test sender in development and the real support or receipt address in production. Avoid setting the sender dynamically from user input; that can create spoofing risk, break domain alignment, and make abuse controls harder.
fetch throws before a response exists
An HTTP error status such as 400 or 500 normally still gives you a Response object, which is why the sample checks response.ok. But DNS problems, TLS failures, a blocked outbound network policy, or an aborted request can cause fetch itself to throw.
Wrap the request in try/catch when your application needs to distinguish network exceptions from Volanea API responses:
try {
const response = await fetch("https://api.volanea.com/v1/send", options);
// Handle response.ok here.
} catch (error) {
console.error("Network error while calling Volanea:", error);
// Queue a retry only if your workflow can safely retry.
}
Do not label every exception as an email-delivery failure. A DNS error means your service could not reach the API; it says nothing about the recipient mailbox. Preserve this distinction in logs and alerts.
Duplicate emails after retrying a job
Duplicate transactional messages are usually an application retry design issue rather than a Bun issue. Use the same idempotency key for all attempts to perform the same logical send. Volanea documents support for the Idempotency-Key header specifically for safe retries. (volanea.com)
A practical rule is: create an application event record first, store its identifier, and derive the idempotency key from that record. Send from a job using the record. If the worker crashes and restarts, it can retry the same record with the same key.
“The API returned success, but I do not see the email”
First, distinguish acceptance from inbox placement. Check the response body and message record in your Volanea account, then inspect recipient mailbox folders such as spam or promotions where appropriate. Verify that you used the expected destination address and that the sender domain is authenticated.
Do not repeatedly send to the same recipient in an attempt to force inbox placement. Repeated unsuccessful sends can create a poor recipient experience and damage operational confidence. Instead, use delivery-event data to determine whether the message was accepted, deferred, bounced, complained about, or delivered.
Production practices for transactional email
A send call should be a small, reliable part of a larger application workflow. The following practices make the integration easier to operate as message volume grows.
Keep email out of the critical request path when possible
For some flows, such as a password reset, immediate sending is expected. For others, including receipts, notifications, account changes, and lifecycle messages, it can be better to write a durable job to a queue or database outbox and let a worker send the email.
This separates a user-facing database transaction from an external HTTP call. Your application can acknowledge the completed action after it has stored the event, while a worker handles retries and error reporting. The tradeoff is eventual delivery rather than immediate inline delivery, so choose based on the user experience and the operational importance of the message.
Log identifiers, not secrets or sensitive content
Logs should help debug a message without becoming another copy of customer data. Record:
- Your internal event or job ID.
- A masked recipient or a recipient identifier where practical.
- The sender identity.
- The idempotency key.
- HTTP status and a sanitized API error body.
- The message identifier returned by the API, if present.
Avoid logging API keys, password-reset URLs, full HTML bodies containing personal data, and raw user-provided values unless your privacy and retention policies explicitly allow it.
Make templates predictable
Transactional email should be easy to recognize and easy to scan. Use a stable structure, a clear subject, a visible reason for the message, and one primary action. Keep essential instructions in text, not solely in an image or button.
When a template includes a user-specific URL, generate it on the server, set an expiration where appropriate, and make the action idempotent or safely repeatable. A reset URL should not be reusable forever, and an email confirmation should not create a second account if the user follows it twice.
Verify addresses before high-value flows
Address quality affects deliverability, bounce rates, and user experience. For signup forms, imports, and workflows where an invalid address would be expensive, validate the format in your application and consider checking deliverability before committing the address to an important workflow. You can use the email address verification tool as an additional pre-send check.
Validation does not replace consent, authentication, or bounce handling. An address can be syntactically valid and still be mistyped, unavailable, suppressed, or inappropriate for the message. Treat verification as one signal in an overall data-quality process.
Next steps: webhooks and templates
Once your first Bun send works, build the two pieces that make transactional email operational rather than merely functional.
Add webhooks for message events
Webhooks let Volanea notify your application about events after the initial API request, such as delivery-related outcomes, bounces, complaints, or other message lifecycle activity supported by your configuration. Use a Bun HTTP handler to receive events, verify the webhook signature according to the API reference, persist the event ID, and return a fast successful response after durable storage.
Your webhook endpoint should be idempotent too. Providers may retry a webhook when your endpoint does not acknowledge it or when a network interruption makes the acknowledgement uncertain. Store a unique event identifier and ignore repeats after the first successful processing. Keep the webhook handler short: write the event to a queue or database, then process heavier work asynchronously.
Move reusable content into templates
Volanea supports reusable templates addressed by templateId, allowing a send request to reference stored content instead of carrying markup every time. (volanea.com) Templates are useful when several services send the same receipt, notification, or onboarding message and you want design changes to be coordinated.
Keep template data minimal and explicit. Pass only the variables a template needs, escape user-controlled data properly, and establish a versioning process for high-risk messages such as invoices, account security notices, and legal communications. For endpoint details, authentication guidance, and current request schemas, use the Volanea API reference and setup guides.
FAQ
Do I need a Volanea Bun SDK to send email with Bun?
No. This guide uses Bun’s built-in fetch to make an authenticated JSON request to Volanea’s REST API. That avoids inventing or depending on an unverified Bun-specific SDK. Bun documents fetch as its recommended networking interface. (bun.com)
What is the install command for this Bun integration?
Install Bun with curl -fsSL https://bun.com/install | bash, then initialize the example with bun init -y. There is no external email package to install for the REST-based code shown here.
Why should I use await fetch() when sending an email?
fetch() is asynchronous. Awaiting it ensures your code receives the HTTP response, can handle errors, and does not complete a one-off script before the send attempt has finished.
Can I retry a failed Volanea send request?
Retry transient network or service failures only when your workflow can do so safely. Reuse the same Idempotency-Key for retries of the same logical message to reduce duplicate-send risk. (volanea.com)
Does a successful send response guarantee inbox delivery?
No. It means the API accepted the request. Use delivery-related events and message records to understand what happens after acceptance, including deferrals, bounces, and delivery outcomes.