Send Transactional Email from Bun + Hono with JustEmails API
Bun + Hono email API: native fetch, no deps.
Bun + Hono email API: native fetch, no deps.
Bun has native fetch. The JustEmails API takes JSON and returns JSON. The entire email send is one POST request, so there's no need to pull in a package to call a REST endpoint.
That's what we're building here.
JustEmails is built by Velocity Digital Labs, the studio behind ClickzProtect for ad fraud protection and JustAnalytics for privacy-first analytics. Bun deserves its own walkthrough. It isn't just "Node but faster," and the runtime has its own opinions about how things should work.
A working Bun + Hono API endpoint that sends transactional email via the JustEmails API. One route handler. Native fetch. Environment variables for secrets. Proper error handling that tells you why sends fail. Your emails authenticate with SPF/DKIM because you're sending from a verified domain — no spam folder roulette.
Before starting:
bun --version to check)API access requires a paid plan. This tutorial runs on the transactional API, and API access is not included in the 7-day trial — a trial account cannot issue the key Step 2 asks for, so there is no way to complete this walkthrough without subscribing first.
Quick context on sending limits: the base $49/year plan includes 1,000 transactional API emails per month, with extra capacity at $25/year per 10,000/month tier. The ceiling to design around is the daily one — sends are capped per account per day, and a newly verified domain ramps up: no more than 50 a day in the first week, 200 in the second, and up to 500 from day 15, with mailbox sends and API sends counted against the same number, so a tier raises the monthly allowance rather than that daily cap. For verification emails, password resets and receipts, that covers most early-stage SaaS apps. If you're weighing SMTP vs API for transactional email, this tutorial is squarely on the API side.
Start fresh or add this to an existing project. The steps below assume fresh.
mkdir bun-email-api && cd bun-email-api
bun init -y
bun add hono
That's it. No bundler config, no tsconfig drama. Bun handles TypeScript natively.
Create your entry point:
// index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Email API running'))
export default app
Run it:
bun run index.ts
Hit http://localhost:3000 and you should see "Email API running." Nothing fancy. But it's working.
Never hardcode API keys. Bun supports .env files natively — no dotenv package required.
Create a .env file:
JUSTEMAILS_API_KEY=je_live_xxxxxxxxxxxxxxxxxxxx
FROM_EMAIL=noreply@yourdomain.com
FROM_NAME=Your App
Replace the placeholder with your actual API key from the JustEmails dashboard (Dashboard → API → Keys). That screen is paid-plan only, as flagged in the prerequisites — a trial account cannot create this key. The key looks like je_live_xxx... for production sends.
Add .env to your .gitignore immediately:
echo ".env" >> .gitignore
Access these in your code with Bun.env.VARIABLE_NAME or process.env.VARIABLE_NAME — both work.
Here's the core logic. One function, native fetch, no dependencies.
// email.ts
interface EmailPayload {
to: string
subject: string
html: string
text?: string
}
interface EmailResult {
success: boolean
id?: string
error?: string
code?: string
}
export async function sendEmail(payload: EmailPayload): Promise<EmailResult> {
const apiKey = Bun.env.JUSTEMAILS_API_KEY
const fromEmail = Bun.env.FROM_EMAIL
const fromName = Bun.env.FROM_NAME
if (!apiKey) {
return { success: false, error: 'Missing JUSTEMAILS_API_KEY' }
}
try {
const response = await fetch('https://justemails.app/api/v1/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
to: payload.to,
from: fromEmail,
fromName: fromName,
subject: payload.subject,
html: payload.html,
text: payload.text || '',
}),
})
const data = await response.json()
if (!response.ok) {
return {
success: false,
error: data.error?.message || `HTTP ${response.status}`,
code: data.error?.code,
}
}
return { success: true, id: data.id }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'Unknown error'
}
}
}
This handles the happy path and the common failure modes: missing env var, API error responses, network failures. The code reads the message ID from data.id and, on failure, error.message and error.code; check the API docs for the exact response shape. The code surfaces both, which is what lets you branch on a failure like an unverified sender domain instead of string-matching a status.
Why the text field? Some email clients (mostly corporate Outlook setups) prefer plain text, and including it improves deliverability marginally. It's optional.
Now connect the email function to an API endpoint.
// index.ts
import { Hono } from 'hono'
import { sendEmail } from './email'
const app = new Hono()
app.get('/', (c) => c.text('Email API running'))
app.post('/send-email', async (c) => {
const body = await c.req.json()
// Basic validation
if (!body.to || !body.subject || !body.html) {
return c.json({
success: false,
error: 'Missing required fields: to, subject, html'
}, 400)
}
// Email format check (simple, not exhaustive)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.to)) {
return c.json({
success: false,
error: 'Invalid email format'
}, 400)
}
const result = await sendEmail({
to: body.to,
subject: body.subject,
html: body.html,
text: body.text,
})
if (!result.success) {
return c.json(result, 500)
}
return c.json(result)
})
export default app
You've got a POST endpoint at /send-email that accepts JSON with to, subject, and html fields. Returns the JustEmails message id on success, or an error on failure.
The email regex is intentionally simple. Probably too simple, honestly — it'll accept garbage like a@b.c. Production apps should use a proper validation library or let the API reject invalid addresses and surface its error.message. Less code to maintain that way.
Restart the server and hit it with curl:
bun run index.ts
In another terminal:
curl -X POST http://localhost:3000/send-email \
-H "Content-Type: application/json" \
-d '{
"to": "your-real-email@example.com",
"subject": "Test from Bun + Hono",
"html": "<h1>It works!</h1><p>Sent via JustEmails API.</p>"
}'
A successful response looks like:
{
"success": true,
"id": "msg_xxxxxxxxxxxx"
}
Check your inbox. If it's there (might take 30 seconds), you're done with the basic setup.
If it landed in spam, your domain probably doesn't have proper email authentication configured. JustEmails auto-configures SPF/DKIM/DMARC when you verify a domain, but if you added records manually, something might be off. Our DMARC enforcement guide covers the DNS setup if you need to debug.
If you're exposing this endpoint publicly, you need authentication. Don't let random internet requests send email through your API.
Here's a simple API key middleware:
// middleware.ts
import { Context, Next } from 'hono'
export async function apiKeyAuth(c: Context, next: Next) {
const apiKey = c.req.header('X-API-Key')
const validKey = Bun.env.CLIENT_API_KEY
if (!validKey) {
console.warn('CLIENT_API_KEY not set — API key auth disabled')
return next()
}
if (apiKey !== validKey) {
return c.json({ error: 'Invalid or missing API key' }, 401)
}
return next()
}
Add to your .env:
CLIENT_API_KEY=your-secret-api-key-for-clients
Wire it up:
// index.ts
import { Hono } from 'hono'
import { sendEmail } from './email'
import { apiKeyAuth } from './middleware'
const app = new Hono()
app.get('/', (c) => c.text('Email API running'))
// Apply auth to the email endpoint
app.post('/send-email', apiKeyAuth, async (c) => {
// ... rest of handler
})
export default app
Now clients need to include X-API-Key: your-secret-key in their requests. Basic, but it works. For production, consider JWT or OAuth depending on your architecture.
(Look, I know the security people will say this isn't enough. They're right. But it's infinitely better than no auth, and you can upgrade later.)
The basic version returns generic 500 errors. In production, you want better visibility.
Here's a more production-ready error handler:
app.post('/send-email', apiKeyAuth, async (c) => {
let body: Record<string, unknown>
try {
body = await c.req.json()
} catch {
return c.json({
success: false,
error: 'Invalid JSON body'
}, 400)
}
if (!body.to || !body.subject || !body.html) {
return c.json({
success: false,
error: 'Missing required fields: to, subject, html'
}, 400)
}
const result = await sendEmail({
to: body.to as string,
subject: body.subject as string,
html: body.html as string,
text: body.text as string | undefined,
})
if (!result.success) {
// Log the upstream code and message for debugging, but don't expose them
console.error('Email send failed:', result.code, result.error)
// Return a sanitized error to the client
const clientError = result.error?.includes('HTTP 401')
? 'Email service authentication failed'
: 'Failed to send email'
return c.json({ success: false, error: clientError }, 500)
}
return c.json({
success: true,
id: result.id
})
})
A 401 means your JustEmails API key is wrong. A from-domain that isn't verified in your JustEmails account is the other common gotcha: the API refuses the send and reports it in error.code and error.message. The handler above logs both server-side and returns the generic "Failed to send email" to clients, so read your logs first when a send fails.
"401 Unauthorized" from JustEmails
Your API key is wrong. Check:
je_live_ prefix?.env file?Bun doesn't hot-reload env variables, so restart the server after changing the key.
Send rejected because the from address isn't verified
Check error.code and error.message in your server log. You're probably trying to send from a domain that isn't set up in JustEmails. Add the domain in Dashboard → Domains, add the DNS records, wait for verification. JustEmails won't send from unverified domains — that's intentional security, not a bug.
Email sends but lands in spam
Check your domain's email authentication. Run your domain through mail-tester.com — send a test to their address and see the score. Usually it's missing SPF or DKIM. Our custom domain email setup guide walks through the DNS configuration.
"fetch failed" or network errors
Either your machine can't reach the internet, or there's a firewall blocking outbound HTTPS. Try:
curl -I https://justemails.app
If that fails, it's a network issue, not a code issue.
Bun.env returns undefined
Your .env file isn't in the project root, or the variable name has a typo. Bun reads .env from the current working directory. If you're running from a subdirectory, it won't find the file.
Here's a complete verification email endpoint you can actually use:
app.post('/send-verification', apiKeyAuth, async (c) => {
const { email, verificationUrl, userName } = await c.req.json()
if (!email || !verificationUrl) {
return c.json({
success: false,
error: 'Missing email or verificationUrl'
}, 400)
}
const html = `
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Verify your email</h2>
<p>Hi${userName ? ` ${userName}` : ''},</p>
<p>Click the button below to verify your email address:</p>
<a href="${verificationUrl}"
style="display: inline-block; background: #0070f3; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 16px 0;">
Verify Email
</a>
<p style="color: #666; font-size: 14px;">
If you didn't create an account, ignore this email.
</p>
</div>
`
const result = await sendEmail({
to: email,
subject: 'Verify your email address',
html,
text: `Verify your email by visiting: ${verificationUrl}`,
})
return c.json(result, result.success ? 200 : 500)
})
This handles the token generation on your auth system's side — you just pass the URL. The email includes both HTML and plain text fallbacks.
You've got a working email endpoint. Here's what to build next:
Add rate limiting. Without it, someone could hit your endpoint a few thousand times and burn through your monthly quota. Use a community rate-limiter middleware for Hono, or a simple in-memory counter for development. For production, Redis-backed limiting is more reliable.
Track your usage. JustEmails Dashboard → API → Usage shows your transactional email counts. The base plan includes 1,000/month. If you're approaching that consistently, add a 10,000/month tier for $25/year — bearing in mind that a tier lifts the monthly allowance, not the daily ceiling.
Set up webhooks. JustEmails can send webhook delivery notifications to your endpoint. Useful for keeping your user records in sync (marking emails as undeliverable, for example). Dashboard → Webhooks to configure.
If you're running analytics alongside email, JustAnalytics integrates cleanly with the same Bun stack — same fetch-based approach, same env var pattern. Privacy-first analytics without the Google dependency. And if you're running paid campaigns to drive signups, ClickzProtect catches fraudulent clicks before they inflate your acquisition costs.
The Bun + Hono combo is underrated for API-first SaaS. Fast cold starts, native TypeScript, minimal dependencies. The email piece we just built is typical — most things that would need three npm packages in Node are already there. Not everything is smooth — the ecosystem still has gaps that'll frustrate you at weird moments — but for straightforward API work? It's hard to go back.
(Honestly, it's a mystery more teams aren't shipping production Bun. Opinion, not benchmark.)
Bun ships with a native fetch implementation that's faster than node-fetch or axios and doesn't require any dependencies. The JustEmails API is a standard REST endpoint — you just POST JSON with an Authorization header. Native fetch handles this perfectly. Adding a library introduces bundle size and potential compatibility issues for zero benefit.
The Hono route handler code is nearly identical. The main difference is environment variable access — Bun uses Bun.env or process.env, while Workers use c.env passed through the context. The fetch call to JustEmails is the same on both runtimes. Check the runtime's own docs for how bindings are exposed if you're deploying there.
The base $49/year plan includes 1,000 transactional API emails per month. If your Bun API sends more, add 10,000 emails/month for $25/year — stackable, though a tier raises the monthly quota only and the daily sending cap still applies to API sends. Most SaaS apps doing verification and password reset emails stay well under 1,000/month unless you're at serious scale.
The fetch call will throw or return a non-2xx status. Wrap the call in try/catch and return a 500 or 503 to your client with a clear error message. For production, consider a simple retry with exponential backoff — network blips happen. The JustEmails API is generally available, but no external service is 100%.
Unlimited custom domain email hosting for $49/year flat — unlimited domains, unlimited mailboxes, 10 GB storage, full IMAP/SMTP. Built for agencies, freelancers, and anyone managing email across more than one domain.