JustEmails
PricingSign inStart free trialStart free
Tutorials··13 min read

Send Transactional Email from Bun + Hono with JustEmails API

Bun + Hono email API: native fetch, no deps.

By JustEmails Platform Team
Contents
  1. What You'll Build: Transactional Email in Bun + Hono
  2. Prerequisites
  3. Step 1: Scaffold the Bun + Hono Project
  4. Step 2: Add Environment Variables
  5. Step 3: Create the Email Sending Function
  6. Step 4: Wire Up the Hono Route
  7. Step 5: Test the Endpoint
  8. Step 6: Add Middleware for API Keys (Optional)
  9. Step 7: Error Handling for Production
  10. Common Errors and How to Fix Them
  11. Real World Example: Verification Email
  12. Next Steps
  13. Frequently Asked Questions
  14. Why use Bun's native fetch instead of an HTTP library for JustEmails?
  15. Does Hono work the same on Bun and Cloudflare Workers for email sending?
  16. How many transactional emails can I send from Bun with JustEmails?
  17. What happens if the JustEmails API is unreachable from my Bun server?
  18. Try JustEmails

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.

What You'll Build: Transactional Email in Bun + Hono

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.

Prerequisites

Before starting:

  • Bun 1.0+ installed locally (bun --version to check)
  • A JustEmails account with at least one verified domain, on a paid plan (transactional API access is on the paid plan)
  • Your JustEmails API key (Dashboard → API → Keys)
  • Basic TypeScript familiarity
  • 15 minutes

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.

Step 1: Scaffold the Bun + Hono Project

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.

Step 2: Add Environment Variables

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.

Step 3: Create the Email Sending Function

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.

Step 4: Wire Up the Hono Route

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.

Step 5: Test the Endpoint

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.

Step 6: Add Middleware for API Keys (Optional)

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.)

Step 7: Error Handling for Production

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.

Common Errors and How to Fix Them

"401 Unauthorized" from JustEmails

Your API key is wrong. Check:

  • Did you copy the full key including the je_live_ prefix?
  • Is the key actually in your .env file?
  • Did you restart the Bun server after adding the key?

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.

Real World Example: Verification Email

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.

Next Steps

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.)

Frequently Asked Questions

Why use Bun's native fetch instead of an HTTP library for JustEmails?

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.

Does Hono work the same on Bun and Cloudflare Workers for email sending?

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.

How many transactional emails can I send from Bun with JustEmails?

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.

What happens if the JustEmails API is unreachable from my Bun server?

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%.


Try JustEmails

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.

Start your 7-day free trial → · How it compares

transactional-emailbun-runtimehono-frameworkemail-apitypescriptbuildinpublicsaasstudioaiworkforcebuildwithclaude

Related posts

Tutorials
Amazon WorkMail Alternative: How to Migrate to JustEmails Without Losing Mail
12 min read
Comparisons
Rackspace Email Alternative After the Price Rise
11 min read
Tutorials
Migrate from Postmark to JustEmails API: 2026 Cutover Guide
14 min read