← Back to all posts

Send Booking Confirmation Emails with Express.js + MailAnvil — Travel & Event Bookings

2026-10-20 · MailAnvil Team

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

The flow

POST /bookings (your app) → payment confirmed → POST /v1/send (MailAnvil) → SES → inbox

Two rules that matter for booking emails:

  1. Send the confirmation as part of the payment-success path, not a nightly batch. Speed is the feature.
  2. Never send twice. Payment webhooks retry. Use MailAnvil's Idempotency-Key header 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

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:

Where this pattern shows up in Indonesia

Next steps

Try MailAnvil free at mailanvil.com — 3,000 emails/month, IDR pricing, QRIS/GoPay, no credit card needed.