Skip to main content

Overview

In this tutorial, you will set up a common booking workflow:
  • Send a booking confirmation email after a user completes a booking.
  • Send a recovery email if a user starts a booking but does not complete it in time.
By the end, you will have:
  • Two Custom Events (booking_started, booking_complete)
  • One Journey that branches on completion vs abandonment
  • A booking Data Feed for confirmation details
  • An optional coupon Data Feed for recovery incentives
This guide focuses on configuring OneSignal. Your booking system and backend can be implemented in any language or framework.

Setup flow

  1. Your app tracks a booking_started custom event.
  2. This enters the user into a Journey.
  3. The Journey waits for a booking_complete event and if not received in time, it sends follow-up reminders.
  4. If the booking completes, OneSignal calls a booking Data Feed at send time and sends a confirmation email with the latest booking details.
  5. If the booking does not complete within the wait window, the Journey follows the expiration path and sends a recovery email.

Setup

Prerequisites

Before you begin, make sure you have:
  • A OneSignal app with the Email channel enabled
  • A backend endpoint that can return booking and/or coupon data as JSON
  • A stable user identifier shared between your app, backend, and OneSignals External ID
  • Access to Custom Events

1. Track booking events

Track the following Custom Events. These can be from your app (using our SDK) or from your backend (using our REST API). Event names:
  • booking_started — when the user begins the booking flow
  • booking_complete — when the booking is successfully completed
Use the trackEvent() method on our Mobile SDK and/or Web SDK to send custom events directly from your app/website.
Example
OneSignal.User.trackEvent("booking_started");
OneSignal.User.trackEvent("booking_complete");
Use the same user identity when tracking events and when returning data from your backend. Mismatched IDs are the most common cause of missing personalization.

2. Create Data Feed aliases

In OneSignal, go to Settings > Data Feeds and create the following aliases. Booking Data Feed: Use this feed to pull the latest booking details at send time.
  • Alias: booking_data
  • Method: GET
  • URL:
Example endpoint
https://your-domain.com/datafeed/booking?user_id={{subscription.external_id}}
Example response:
JSON
{
  "first_name": "Sam",
  "last_booking": {
    "service_type": "Consultation",
    "booking_date": "January 22, 2026",
    "booking_time": "2:00 PM",
    "price": 45
  }
}
Coupon Data Feed (optional): Use this optional feed if you want to include a coupon code in your recovery email.
  • Alias: coupon
  • Method: GET
  • URL:
Example endpoint
https://your-domain.com/datafeed/coupon?user_id={{subscription.external_id}}
Example response:
JSON
{
  "first_name": "Sam",
  "code": "PROMO8F3K2",
  "discount_text": "10%",
  "expires_in_hours": 2,
  "deep_link": "https://your-domain.com/checkout?coupon=PROMO8F3K2"
}
Secure your Data Feed endpoints. In production, send an API key in request headers (for example x-api-key) and configure that header in Settings > Data Feeds instead of embedding secrets in the URL.

3. Create email templates

Booking confirmation email:

Subject:
Your booking details
Body:
Hi {{ data_feed.booking_data.first_name | default: "there" }},

Thanks for your booking! Here are your appointment details:

Service: {{ data_feed.booking_data.last_booking.service_type }}
Date: {{ data_feed.booking_data.last_booking.booking_date }}
Time: {{ data_feed.booking_data.last_booking.booking_time }}
Price: ${{ data_feed.booking_data.last_booking.price }}

We look forward to seeing you!

Booking recovery email

Subject:
Complete your booking and save
Body:
Hi {{ data_feed.coupon.first_name | default: "there" }},

Finish your booking in the next {{ data_feed.coupon.expires_in_hours }} hours and save
{{ data_feed.coupon.discount_text }} with this code:

{{ data_feed.coupon.code }}

Use it here:
{{ data_feed.coupon.deep_link }}
Always include default filters in Liquid to prevent blank content if a Data Feed field is missing.

4. Build the Journey

  1. In OneSignal, go to Messages > Journeys > Create Journey
  2. Set the Entry Trigger to:
    • Custom Event: booking_started
  3. Add a Wait Until step:
    • Condition: Custom Event occurs
    • Event name: booking_complete
    • Maximum wait time: 10 minutes
    • Enable the expiration path
  4. Configure the branches:
    • Completed: Send booking confirmation email
      • Data Feed: booking_data
    • Expired: Send recovery email
      • Data Feed: coupon
The expiration branch allows you to handle abandonment without additional logic in your app. See:

5. Test and verify

Verify events

Trigger the custom events from your app or backend and confirm. In OneSignal, go to Analytics > Custom Events and confirm you see:
  • booking_started events appear for your External ID
  • booking_complete events appear for your External ID

Verify Data Feeds

Manually call your Data Feed endpoints using a known user ID and confirm:
  • A 200 response is returned
  • All expected fields are present

Verify emails

Send test messages from the Journey editor and confirm:
  • Booking emails contain real booking details
  • Recovery emails contain a valid coupon
  • No Liquid variables render empty
If personalization is missing, confirm that the user ID in the Data Feed request matches the user who triggered the Journey.

Example: Data Feed implementation

This example shows a minimal Express implementation for booking confirmation and recovery Data Feeds. Your backend language, framework, and data source can differ as long as the JSON response shape matches your email templates.

Booking Data Feed example

import express from "express";

const app = express();

function dataFeedAuth(req, res, next) {
  if (req.headers["x-api-key"] !== process.env.DATAFEED_API_KEY) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  next();
}

app.get("/datafeed/booking", dataFeedAuth, async (req, res) => {
  const { user_id } = req.query;

  if (!user_id) {
    return res.status(400).json({ error: "Missing user_id" });
  }

  const booking = await getLatestBookingForUser(user_id);

  if (!booking) {
    return res.status(404).json({ error: "No booking found" });
  }

  res.json({
    first_name: booking.first_name,
    last_booking: {
      service_type: booking.service_type,
      booking_date: booking.booking_date,
      booking_time: booking.booking_time,
      price: booking.price
    }
  });
});

Coupon Data Feed example

app.get("/datafeed/coupon", dataFeedAuth, async (req, res) => {
  const { user_id } = req.query;

  if (!user_id) {
    return res.status(400).json({ error: "Missing user_id" });
  }

  const coupon = await generateCouponForUser(user_id);

  res.json({
    first_name: coupon.first_name,
    code: coupon.code,
    discount_text: coupon.discount_text,
    expires_in_hours: coupon.expires_in_hours,
    deep_link: coupon.deep_link
  });
});

Implementation guidelines

  • Keep responses fast (Data Feeds are called at send time)
  • Always return a predictable JSON structure
  • Use 404 when no data exists
  • Secure endpoints with an API key sent via request headers

Common issues

Email shows empty values

  • Data Feed returned 404
  • Field names changed in the JSON response
  • User identity mismatch

Journey does not branch

  • booking_complete event not tracked
  • Event name mismatch (case-sensitive)
  • Event occurs outside the wait window

Data Feed returns 401 or 403

  • Missing or invalid API key
  • Header not configured in the Data Feed settings

Next steps

  • Add event properties (service type, price) for more advanced Journey conditions
  • Add additional recovery steps such as push or SMS reminders
  • Use Journey exit rules to prevent repeated recovery messages