Send Booking Confirmation Emails with Express.js + MailAnvil — Travel & Event Bookings
Send Booking Confirmation Emails with Express.js + MailAnvil
Booking confirmation is the one email your users actually wait for. A travel platform that takes a payment and doesn't send an instant confirmation looks broken — support tickets pile up, chargebacks follow. Whether you run a hotel marketplace, a bus-ticket app, or an event platform, this flow is worth building properly.
In this tutorial: an Express.js endpoint that accepts a booking, sends a confirmation email with an e-ticket PDF attached, and survives retries without double-sending. ~60 lines of code.
Prerequisites
- Node 18+
- A MailAnvil account — free tier: 3,000 emails/month at mailanvil.com
- An API key from the MailAnvil dashboard
- A verified sending domain (e.g.
[email protected])
The flow
POST /bookings (your app) → payment confirmed → POST /v1/send (MailAnvil) → SES → inbox
Two rules that matter for booking emails:
- Send the confirmation as part of the payment-success path, not a nightly batch. Speed is the feature.
- Never send twice. Payment webhooks retry. Use MailAnvil's
Idempotency-Keyheader keyed on the booking ID.
Setup
mkdir booking-emails && cd booking-emails
npm init -y && npm install express
echo "MAILANVIL_API_KEY=your_key_here" > .env
The confirmation route
// server.js
import express from 'express';
const app = express();
app.use(express.json());
const API = 'https://api.mailanvil.com/v1/send';
const KEY = process.env.MAILANVIL_API_KEY;
function bookingEmailHtml(b) {
return `
<div style="font-family:sans-serif;max-width:520px;margin:auto;color:#111">
<h2 style="color:#ff801f">Booking confirmed ✅</h2>
<p>Hi ${b.customerName}, your booking is confirmed.</p>
<table style="border-collapse:collapse;width:100%">
<tr><td style="padding:6px 0">Booking ID</td><td><b>${b.bookingId}</b></td></tr>
<tr><td>Item</td><td>${b.itemName}</td></tr>
<tr><td>Date</td><td>${b.startDate}</td></tr>
<tr><td>Total</td><td><b>${b.total}</b></td></tr>
</table>
<p>Your e-ticket is attached as a PDF. Show it at check-in.</p>
</div>`;
}
async function sendConfirmation(booking) {
const res = await fetch(API, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `booking-${booking.bookingId}`,
},
body: JSON.stringify({
from: '[email protected]',
to: [booking.email],
subject: `Booking confirmed — ${booking.itemName} (${booking.bookingId})`,
html: bookingEmailHtml(booking),
text: `Booking ${booking.bookingId} confirmed: ${booking.itemName}, ${booking.startDate}. Total ${booking.total}.`,
attachments: [{
path: booking.eticketUrl, // your R2/S3-hosted e-ticket PDF
filename: `eticket-${booking.bookingId}.pdf`,
}],
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`MailAnvil ${res.status}: ${err?.error?.message ?? res.statusText}`);
}
return res.json();
}
app.post('/bookings/:id/confirm', async (req, res) => {
const booking = await getBooking(req.params.id); // your DB
if (!booking) return res.status(404).json({ error: 'booking not found' });
if (booking.confirmationSentAt) return res.json({ ok: true, replay: true });
try {
const out = await sendConfirmation(booking);
await markConfirmationSent(booking.id);
res.json({ ok: true, messageId: out.id });
} catch (e) {
res.status(502).json({ error: e.message }); // let your job retry
}
});
app.listen(3000);
Why Idempotency-Key matters here
Payment providers (Xendit, Midtrans, Stripe) retry webhooks aggressively. Without an idempotency key, each retry can become a second "booking confirmed" email — the fastest way to train users to ignore your mail. The key above means MailAnvil returns the original result for repeated sends of the same booking.
The confirmationSentAt check is your second layer, so a replayed request doesn't even call the API.
Attachments: path vs content
path— a public URL MailAnvil fetches for you (SSRF-guarded). Best for e-tickets already generated and stored in R2/S3.content— base64-inline the PDF if you generate it in-memory at send time.
Limits: 10 attachments max, 25 MB combined. An e-ticket PDF is a few hundred KB — no problem.
Deliverability for booking emails
Booking confirmations are the most-opened transactional email you'll send — protect that:
- Send from a subdomain (
[email protected]vs marketing mail), with DKIM/SPF verified. MailAnvil walks you through DNS records on domain add. - Watch your bounce rate. A hotel booking collected by phone with a typo'd email burns your sender reputation. Monitor the Logs tab; suppressed addresses are listed there.
- Validate recipient emails at checkout — MailAnvil blocks disposable domains at signup on its side, but validating format plus MX on your side catches typos before they bounce.
Where this pattern shows up in Indonesia
- Travel & ticketing — bus, train, and flight aggregators: confirmation + e-ticket PDF, exactly the flow above.
- Food delivery invoices — same endpoint shape, invoice PDF instead of e-ticket; daily order summaries go out at 6 AM from the same API.
- Fintech OTP — a separate 6-digit-code flow, shorter TTL, no attachments. Same API key, different template; keep them on separate
fromaddresses.
Next steps
- Add an order-status webhook handler so bounced confirmations trigger an SMS/WhatsApp fallback.
- Add templates via
POST /v1/templatesso your team edits email copy without redeploying.
Try MailAnvil free at mailanvil.com — 3,000 emails/month, IDR pricing, QRIS/GoPay, no credit card needed.