Dev Life

Building transactional email workflows for order confirmations with Mailgun’s API

Order confirmation emails don’t have to be complicated. In this step-by-step guide, we’ll show you how to build a reliable transactional workflow using Mailgun’s API.
Image for Building transactional email workflows for order confirmations with Mailgun’s API
May 15, 2025

Unlike marketing emails, transactional emails (such as order confirmations, shipping notifications, and password resets) are triggered by specific user actions and provide real-time updates on their interactions with your platform. They help build trust, cut down on support questions, and make for a smooth shopping experience.

Order confirmations are particularly important, as they let your customers know their purchase was successful and provide the user with a record of the transaction details.

In this tutorial, you’ll learn how to build a transactional email workflow for order confirmations using Mailgun’s API.

Implementing transactional email workflows for order confirmations

Before you begin, make sure you have a Mailgun account and Node.js installed on your machine.

Once your account is registered and activated, click Get started and then Create an API Key to provide a short description of the key. Once it’s generated, copy and save the API key somewhere safe.

API Key

This key will be used to authenticate your requests to Mailgun’s API.

To keep things simple, clone the following UI repository and set up the environment variables by running the following npm commands:

                            

                                $ git clone https://github.com/Ikeh-Akinyemi/draftdev-mailgunnerrn$ cd draftdev-mailgunner; npm install rn$ echo -e u0022MAILGUN_API_KEY=your_api_key_here
MAILGUN_DOMAIN=your_domain_here
PORT=8080u0022 u003eu003e .env
                            
                        

This command adds the mailgun.js, form-data, cors, dotenv, and express libraries for use in the backend server and creates some placeholder values for the Mailgun API key and domain in a .env file that you’ll update during the tutorial.

The Mailgun domain gives you the option of setting up your own custom domain or using a sandbox for testing purposes. You’ll use a sandbox domain in this tutorial. You can find Mailgun’s sandbox domain by navigating to Send > Sending > Domain settings on your dashboard and clicking the Select button in the API integration option:

Mailgun API Sandbox

Your sandbox domain is included in the boilerplates provided for Mailgun setup. Remember to update the value of MAILGUN_DOMAIN (currently your_domain_here) inside the .env file.

Domain Update

If you want to set up a custom domain, follow the instructions detailed in this YouTube tutorial.

The application interface

In this scenario, you’ll be working with a simple shopping cart application built with HTML, CSS, and JavaScript. The application allows users to add items to their cart, adjust quantities, and proceed to checkout. When the user confirms their purchase, the application sends a request to the backend to process the order and send a confirmation email. To preview the UI, open the ui/index.html file in the browser or run the Python command python3 -m http.server -d=./ui:

Shopping Cart Example

This UI is designed to be intuitive, with a focus on providing a seamless checkout experience. The backend handles the heavy lifting, including processing orders and sending confirmation emails.

Setting up the Mailgun connection

To integrate Mailgun into your backend, you need to initialize the Mailgun client using your API key. Here’s how to set up the connection in your Node.js backend:

                            

                                const formData = require('form-data');rnconst Mailgun = require('mailgun.js');rnrnconst mailgun = new Mailgun(formData);rnconst mg = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY });rnconst MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN;
                            
                        

This code initializes the Mailgun client, which will be used to send transactional emails. Make sure to update the value of MAILGUN_API_KEY (currently your_api_key_here) inside your .env file before continuing.

Test the connection by sending a sample test email:

                            

                                mg.messages.create('u003cyour-domain.comu003e', {rn    from: u0022Excited User u003cmailgun@your-domain.comu003eu0022,rn    to: [u0022test@example.comu0022],rn    subject: u0022Hellou0022,rn    text: u0022Testing some Mailgun awesomeness!u0022rn})rn.then(msg =u003e console.log(msg)) // logs response datarn.catch(err =u003e console.error(err)); // logs any error
                            
                        

You can use the sandbox domain provided by Mailgun for testing, but you need to add at least one verified email for testing (up to five). To verify your test email address, go to Send > Sending > Domain settings in your Mailgun dashboard. Then, enter your email address in the designated input field and click Add. Mailgun will send a verification email to this address.

Check your inbox and click the I Agree verification link to complete the process and register as an authorized test recipient:

Verification Link

Designing the email template

Transactional emails often require dynamic content, like the user’s name, order details, and shipping information, which is why you need to create an HTML template with placeholders for dynamic data. Here’s an example of a simple order confirmation template:

                            

                                u003c!DOCTYPE htmlu003ernu003chtmlu003ernu003cheadu003ern    u003ctitleu003eOrder Confirmationu003c/titleu003ernu003c/headu003ernu003cbodyu003ern    u003ch1u003eThank you for your order, {{name}}!u003c/h1u003ern    u003cpu003eYour order number is {{orderNumber}}.u003c/pu003ern    u003cpu003eWe will ship your items to {{shippingAddress}}.u003c/pu003ernu003c/bodyu003ernu003c/htmlu003e
                            
                        

You can use a templating engine like Handlebars or EJS to replace the placeholders with actual data before sending the email. You can also use Mailgun’s intuitive visual builder to create beautiful, responsive email templates without any coding knowledge.

In this article, you’ll use the simplest form of templating: string interpolation using JavaScript template literals. This approach allows you to set up an HTML email template populated with the necessary dynamic data:

                            

                                function emailTemplate(order) {rn  // Format items for emailrn  const itemsList = order.items.map(item =u003e {rn    return `rn      u003ctru003ern        u003ctd style=u0022padding: 10px 0; border-bottom: 1px solid #eee;u0022u003ern          ${item.name}rn        u003c/tdu003ern        u003ctd style=u0022padding: 10px 0; border-bottom: 1px solid #eee; text-align: center;u0022u003ern          ${item.quantity}rn        u003c/tdu003ern        u003ctd style=u0022padding: 10px 0; border-bottom: 1px solid #eee; text-align: right;u0022u003ern          $${item.price.toFixed(2)}rn        u003c/tdu003ern        u003ctd style=u0022padding: 10px 0; border-bottom: 1px solid #eee; text-align: right;u0022u003ern          $${(item.price * item.quantity).toFixed(2)}rn        u003c/tdu003ern      u003c/tru003ern    `;rn  }).join('');rn};
                            
                        

This code iterates over an order’s items to generate a table-like receipt of the customer’s order. The returned value, itemsList, is a string literal that will be used to build the rest of the email template like this:

                            

                                function emailTemplate(order) {rn    ...rnrn  // Create email HTML templatern  const emailHtml = `rn    u003c!DOCTYPE htmlu003ern    u003chtmlu003ern    u003cheadu003ern      u003cstyleu003ern        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }rn        .container { max-width: 600px; margin: 0 auto; }rn        .header { background-color: #f8f9fa; padding: 20px; text-align: center; }rn        .content { padding: 20px; }rn        .order-details { margin-top: 20px; }rn        .order-table { width: 100%; border-collapse: collapse; }rn        .order-table th { text-align: left; padding: 10px 0; border-bottom: 2px solid #ddd; }rn        .footer { margin-top: 30px; text-align: center; font-size: 14px; color: #777; }rn        .total-row { font-weight: bold; }rn      u003c/styleu003ern    u003c/headu003ern    u003cbodyu003ern      u003cdiv class=u0022containeru0022u003ern        u003cdiv class=u0022headeru0022u003ern          u003ch2u003eOrder Confirmationu003c/h2u003ern          u003cpu003eThank you for your purchase!u003c/pu003ern        u003c/divu003ernrn        u003cdiv class=u0022contentu0022u003ern          u003cpu003eHello ${order.customer.name},u003c/pu003ernrn          u003cpu003eYour order has been confirmed. Here are your order details:u003c/pu003ernrn          u003cdiv class=u0022order-detailsu0022u003ern            u003cpu003eu003cstrongu003eOrder ID:u003c/strongu003e ${order.id}u003c/pu003ern            u003cpu003eu003cstrongu003eOrder Date:u003c/strongu003e ${new Date(order.createdAt).toLocaleString()}u003c/pu003ernrn            u003ctable class=u0022order-tableu0022u003ern              u003ctheadu003ern                u003ctru003ern                  u003cthu003eItemu003c/thu003ern                  u003cth style=u0022text-align: center;u0022u003eQtyu003c/thu003ern                  u003cth style=u0022text-align: right;u0022u003ePriceu003c/thu003ern                  u003cth style=u0022text-align: right;u0022u003eTotalu003c/thu003ern                u003c/tru003ern              u003c/theadu003ern              u003ctbodyu003ern                ${itemsList}rn                u003ctru003ern                  u003ctd colspan=u00223u0022 style=u0022text-align: right; padding-top: 20px;u0022u003eu003cstrongu003eSubtotal:u003c/strongu003eu003c/tdu003ern                  u003ctd style=u0022text-align: right; padding-top: 20px;u0022u003eu003cstrongu003e$${order.subtotal.toFixed(2)}u003c/strongu003eu003c/tdu003ern                u003c/tru003ern                u003ctru003ern                  u003ctd colspan=u00223u0022 style=u0022text-align: right;u0022u003eu003cstrongu003eTax:u003c/strongu003eu003c/tdu003ern                  u003ctd style=u0022text-align: right;u0022u003eu003cstrongu003e$${order.tax.toFixed(2)}u003c/strongu003eu003c/tdu003ern                u003c/tru003ern                u003ctr class=u0022total-rowu0022u003ern                  u003ctd colspan=u00223u0022 style=u0022text-align: right; padding-top: 10px;u0022u003eu003cstrongu003eTotal:u003c/strongu003eu003c/tdu003ern                  u003ctd style=u0022text-align: right; padding-top: 10px;u0022u003eu003cstrongu003e$${order.total.toFixed(2)}u003c/strongu003eu003c/tdu003ern                u003c/tru003ern              u003c/tbodyu003ern            u003c/tableu003ern          u003c/divu003ernrn          u003cdiv style=u0022margin-top: 30px;u0022u003ern            u003cpu003eu003cstrongu003eShipping Address:u003c/strongu003eu003c/pu003ern            u003cpu003ern              ${order.shipping.address}u003cbru003ern              ${order.shipping.city}, ${order.shipping.state} ${order.shipping.zipCode}rn            u003c/pu003ern          u003c/divu003ernrn          u003cdiv style=u0022margin-top: 30px;u0022u003ern            u003cpu003eu003cstrongu003ePayment Method:u003c/strongu003e ${order.payment.method} (ending in ${order.payment.last4})u003c/pu003ern          u003c/divu003ernrn          u003cdiv style=u0022margin-top: 30px;u0022u003ern            u003cpu003eIf you have any questions about your order, please contact our customer support.u003c/pu003ern            u003cpu003eThank you for shopping with us!u003c/pu003ern          u003c/divu003ern        u003c/divu003ernrn        u003cdiv class=u0022footeru0022u003ern          u003cpu003eThis is an automated email, please do not reply to this message.u003c/pu003ern          u003cpu003e© 2025 Your Company. All rights reserved.u003c/pu003ern        u003c/divu003ern      u003c/divu003ern    u003c/bodyu003ern    u003c/htmlu003ern  `;rnrn  return emailHtml;rn}
                            
                        

Here, you combine the receipt with the rest of an HTML template string containing customer details. The HTML contains basic styling for the email template.

Implementing the email sending functionality

Now that your template is ready, it’s time to write the function that sends the email using Mailgun’s API. Here’s how you can implement this functionality in your backend:

                            

                                async function sendOrderConfirmationEmail(order) {rn  try {rnrn    let emailHtml = emailTemplate(order)rnrn    // Send email via Mailgunrn    const response = await mg.messages.create(MAILGUN_DOMAIN, {rn      from: `Your Store u003corders@${MAILGUN_DOMAIN}u003e`,rn      to: order.customer.email,rn      subject: `Order Confirmation #${order.id}`,rn      html: emailHtml,rn      'o:tag': ['order-confirmation'],rn      'o:tracking': truern    });rnrn    console.log('Email sent successfully:', response);rn    return response;rnrn  } catch (error) {rn    console.error('Error sending confirmation email:', error);rn    throw error;rn  }rn}
                            
                        

This function takes an order object, constructs the email content, and sends it using Mailgun’s API. The o:tag and o:tracking options allow you to track the email’s delivery and engagement.

Now, let’s test the implementation with a simple order object to see if it works:

                            

                                const testOrder = {rn  id: u0022TEST123u0022,rn  customer: {rn    name: u0022Test Useru0022,rn    email: u0022your-verified-email@example.comu0022 // Use one of your verified emails for testingrn  },rn  items: [rn    { name: u0022Test Productu0022, quantity: 1, price: 29.99 }rn  ],rn  subtotal: 29.99,rn  tax: 2.99,rn  total: 32.98,rn  shipping: {rn    address: u0022123 Test Streetu0022,rn    city: u0022Test Cityu0022,rn    state: u0022TSu0022,rn    zipCode: u002212345u0022rn  },rn  payment: {rn    method: u0022Credit Cardu0022,rn    last4: u00224242u0022rn  },rn  createdAt: new Date()rn};rnrnsendOrderConfirmationEmail(testOrder)rn  .then(response =u003e {rn    console.log('Test email sent successfully!');rn    console.log('Message ID:', response.id);rn    console.log('Message status:', response.status);rn  })rn  .catch(error =u003e {rn    console.error('Failed to send test email:', error.message);rn  });
                            
                        

Run this code with node ./src/server.js and check your inbox (remember to use a verified email for the testOrder.customer.email value if you’re using Mailgun’s sandbox domain). You should receive a basic order confirmation email with the test data:

Order Confirmation Image

In this setup, any error that occurs is bubbled up to the caller, and the .catch method logs it to the standard output. This approach allows you to quickly identify and troubleshoot any issues that might occur during the email sending process.

In the next section, you’ll connect this to your frontend so you can send real order data from customer checkouts.

Integrating with application events

To ensure that the email workflow is triggered automatically when an order is confirmed, you need to set up an event listener or route in your application.

Here, you’ll set up an Express.js application and define a route that handles order confirmations while calling the sendOrderConfirmationEmail function:

                            

                                const express = require('express');rnconst cors = require('cors');rn...rnrn// Load environment variablesrnrequire('dotenv').config();rnrn// Initialize Expressrnconst app = express();rnconst PORT = process.env.PORT || 3000;rnrn// Middlewarernapp.use(cors());rnapp.use(express.json());rnrnconst orders = [];rnrnapp.post('/api/orders', async (req, res) =u003e {rn  try {rn    // Validate required fieldsrn    if (!req.body.items || !req.body.customer || !req.body.customer.email) {rn      return res.status(400).json({ rn        success: false, rn        error: 'Missing required order information' rn      });rn    }rnrn    // Generate order IDrn    const orderId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5).toUpperCase();rnrn    // Create order objectrn    const order = {rn      id: orderId,rn      ...req.body,rn      status: 'confirmed',rn      createdAt: new Date()rn    };rnrn    // Save order (to database in a real app)rn    orders.push(order);rnrn    // Send confirmation emailrn    await sendOrderConfirmationEmail(order);rnrn    // Return success responsern    res.status(201).json({rn      success: true,rn      order: {rn        id: order.id,rn        status: order.status,rn        createdAt: order.createdAtrn      },rn      message: 'Order created successfully and confirmation email sent.'rn    });rnrn  } catch (error) {rn    console.error('Error processing order:', error);rn    res.status(500).json({rn      success: false,rn      error: 'Failed to process order'rn    });rn  }rn});
                            
                        

The /api/orders route handles incoming order requests, creates an order object, and sends a confirmation email using the sendOrderConfirmationEmail function. The rest of the route includes a simple error reporting logic, but in production, you might want to implement a retry mechanism or a more robust error handler.

Testing the workflow

To test the setup so far, you need to modify the /ui/index.html file. First, find the line of code where the orderData object is defined and update the customer’s email property (orderData.customer.email) to use one of your verified emails. You also need to update the api constant to point to the URL of your server.

Then, open the /ui/index.html file in a browser and trigger the checkout process by selecting items for checkout and clicking the Checkout Selected Items button. This should open a confirmation dialog where you can click Confirm Purchase to complete the checkout process:

Confirm Order Image

Check your email inbox and verify that the email was sent and correctly rendered with the dynamic content. If you used the sandbox domain, you may need to check your spam folder:

Order Confirmation Email Image

Remember to test how your application handles errors, such as invalid email addresses or API downtimes, by intentionally using incorrect values and verifying that your error handling works as expected.

All the code used in this tutorial is available on GitHub.

Monitoring and managing emails

Mailgun provides detailed metrics and tracking features that allow you to monitor the performance of your transactional emails. You can track delivery rates and open rates in addition to handling bounces or failures directly from the Mailgun dashboard.

To access the logs of each email sent, navigate to the Send > Reporting > Logs section in your Mailgun account. Here, you can see the timestamp and status of each email sent, including whether it was delivered, opened, or rejected:

Email logs image

You can click any log entry to view its full details, like geolocation for opened emails, delivery-status for delivered emails, and others:

Email Log Details Image

You can also go to Send > Reporting > Metrics to view a graphical breakdown of key email metrics, including the sent, delivered, failed, and opened count:

Key Email Metrics Image

Wrapping up

You’ve just built a working order confirmation workflow with Mailgun’s API.

Transactional emails like these keep customers informed right after checkout, cutting down on support tickets and improving trust without extra overhead. Order confirmations aren’t the only workflows that matter to consumers. Check out our tutorial on password resets to keep optimizing your transactional emails.