---
title: "Booking recovery for travel brands | Snowplow Signals"
description: "Snowplow Signals tells your booking flow and CRM where a traveler stopped and which trip it was, so they bring it back while the traveler is still deciding."
source: https://signals.snowplow.io/solutions/travel/booking
---

[Travel](https://signals.snowplow.io/solutions/travel) / Booking recovery 

# Bring the traveler back before they book elsewhere. 

Signals tells your booking flow and CRM where a traveler stopped, how long ago, and which route and dates they had picked, so the hold, the restored trip or the reminder reaches them while the trip is still open.

[Start free](https://signals.snowplow.io/start) [Talk to us](https://signals.snowplow.io/start?intent=talk) 

More completed bookings

## Complete more bookings by reaching the traveler before they book elsewhere. 

Completed bookings move when the follow-up reaches a traveler while the trip is still open in their mind. Signals gives your booking flow and CRM the step this traveler stopped at, how long they have been there, how many times they went back a step, and the route and dates of the fare they picked. Your booking flow and CRM decide what to hold, what to restore and when to send.

Four ways travel brands use it

01 The traveler idle at payment Four minutes on the payment step, fare still picked. Hold it and say so. 02 The traveler going back to results Back from passengers to results twice, same fare each time. Show the one they had. 03 The traveler back an hour later Left at payment an hour ago, now on the homepage. Put the trip back. 04 The traveler who left yesterday Left at payment yesterday, no visit since. Send the route and dates. 

[Read the docs →](https://docs.snowplow.io/docs/signals/introduction/) 

### Hold the fare while the traveler is still on the payment step

A traveler picked a fare from Boston to Lisbon, filled in two passengers and has been on the payment step for four minutes. They may be in another tab pricing the same route, or looking for a card. The fare expiry runs on its own clock, and the message about it usually reaches the traveler after the fare has gone, when they come back to the tab. A line above the payment form saying the fare is held for a few more minutes, for as long as your fare rules allow, answers the question before they leave the tab to settle it.

What Signals knows

`current_step` 

The step of the booking flow they are on now

`seconds_on_step` 

Seconds since they reached it

`selected_route_session` 

The route of the fare they picked this visit

What your booking flow and CRM do

Holds the fare and says so above the payment form once a traveler has been on the payment step for several minutes without finishing, for the time your fare rules allow, and does nothing for the traveler who has just arrived on the step.

Define it · PythonUse it · TypeScript

booking.tsx · 12 lines Copy Copied 

```
from snowplow_signals import (
    Attribute, Event, EventProperty, StreamAttributeGroup, domain_sessionid,
)

booking_step = Event(name="booking_step", vendor="com.example", version="1-0-0")
fare_selected = Event(name="fare_selected", vendor="com.example", version="1-0-0")

current_step = Attribute(
    name="current_step",
    type="string",
    events=[booking_step],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="booking_step", major_version=1, path="step"),
)

seconds_on_step = Attribute(
    name="seconds_on_step",
    type="double",
    events=[booking_step],
    aggregation="time_since_last",
    time_unit="s",
)

selected_route_session = Attribute(
    name="selected_route_session",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="route"),
)

# Keyed on the visit, so the clock starts again when the traveler comes back another day.
trip_booking_idle = StreamAttributeGroup(
    name="trip_booking_idle",
    version=1,
    attribute_key=domain_sessionid,
    owner="you@example.com",
    attributes=[current_step, seconds_on_step, selected_route_session],
)
```

```
// In the code that renders the payment step
const traveler = await signals.getServiceAttributes({
  name: "trip_booking_idle",
  attribute_key: "domain_sessionid",
  identifier: sessionId,
});
// { current_step: "payment", seconds_on_step: 243.0, selected_route_session: "BOS-LIS" }

if (traveler.current_step === "payment" && traveler.seconds_on_step >= 240) {
  const hold = booking.holdFare({ route: traveler.selected_route_session, minutes: fareRules.holdMinutes });
  booking.notice(`This fare is held for you for another ${hold.minutes} minutes.`);
}
```

### Keep the fare they picked in view when they go back to compare

A traveler picked a fare, reached the passengers step, went back to the results, picked the same fare and went back again. They are checking whether a later departure is cheaper, or whether the price they saw is still the price. The results page renders as if this were the first look, and the fare they had is somewhere in the list, sometimes at a new price. The second return is the moment to pin their fare at the top, with its price beside the alternatives, so comparing does not mean starting over.

What Signals knows

`step_regressions_session` 

Times they went back a step this visit

`selected_fare_session` 

The fare they picked last this visit

`selected_route_session` 

The route of the fare they picked this visit

What your booking flow and CRM do

Pins the fare the traveler picked at the top of the results, with its price beside the alternatives, once they have gone back from passengers to results twice this visit, and keeps the usual ranking for a traveler on their first pass.

Define it · PythonUse it · TypeScript

booking.tsx · 15 lines Copy Copied 

```
from snowplow_signals import (
    Attribute, Criteria, Criterion, Event, EventProperty, StreamAttributeGroup, domain_sessionid,
)

booking_step = Event(name="booking_step", vendor="com.example", version="1-0-0")
fare_selected = Event(name="fare_selected", vendor="com.example", version="1-0-0")

step_regressions_session = Attribute(
    name="step_regressions_session",
    type="int32",
    events=[booking_step],
    aggregation="counter",
    criteria=Criteria(all=[
        Criterion.eq(EventProperty(vendor="com.example", name="booking_step", major_version=1, path="direction"), "back"),
    ]),
    default_value=0,
)

selected_fare_session = Attribute(
    name="selected_fare_session",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="fare_id"),
)

selected_route_session = Attribute(
    name="selected_route_session",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="route"),
)

trip_booking_compare = StreamAttributeGroup(
    name="trip_booking_compare",
    version=1,
    attribute_key=domain_sessionid,
    owner="you@example.com",
    attributes=[step_regressions_session, selected_fare_session, selected_route_session],
)
```

```
// In the code that renders the results page
const traveler = await signals.getServiceAttributes({
  name: "trip_booking_compare",
  attribute_key: "domain_sessionid",
  identifier: sessionId,
});
// { step_regressions_session: 2, selected_fare_session: "fare-81c2", selected_route_session: "BOS-LIS" }

if (traveler.step_regressions_session >= 2 && traveler.selected_fare_session) {
  results.pin({
    fare: traveler.selected_fare_session,
    label: "The fare you picked",
    showPriceBeside: true,
  });
}
```

### Restore the itinerary when the traveler comes back

A traveler left at the payment step an hour ago, and now they are on the homepage on the same browser. They were comparing, or something interrupted them. The homepage shows them the search box and this week's deals, the same as a first visit, and the trip they built is in a cart they have to find. The reminder email, if one goes out, lands after this visit has ended. The homepage is where the trip belongs, with the route and dates filled in and the fare repriced, so the booking picks up at the step they left.

What Signals knows

`hours_since_abandon` 

Hours since they left a booking unfinished

`abandoned_step` 

The step they left at

`last_route_7d` 

The route of the last fare they picked in the last seven days

`last_depart_7d` 

The departure date of that fare

What your booking flow and CRM do

Puts the trip back at the top of the homepage, with the route and dates filled in and the fare repriced, when a traveler who left at payment in the last two days comes back on the same browser, and shows the usual homepage to everyone else.

Define it · PythonUse it · TypeScript

home.tsx · 15 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, Event, EventProperty, StreamAttributeGroup, domain_userid,
)

booking_abandoned = Event(name="booking_abandoned", vendor="com.example", version="1-0-0")
fare_selected = Event(name="fare_selected", vendor="com.example", version="1-0-0")

hours_since_abandon = Attribute(
    name="hours_since_abandon",
    type="double",
    events=[booking_abandoned],
    aggregation="time_since_last",
    time_unit="h",
)

abandoned_step = Attribute(
    name="abandoned_step",
    type="string",
    events=[booking_abandoned],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="booking_abandoned", major_version=1, path="step"),
)

last_route_7d = Attribute(
    name="last_route_7d",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="route"),
    period=timedelta(days=7),
)

last_depart_7d = Attribute(
    name="last_depart_7d",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="depart_date"),
    period=timedelta(days=7),
)

# Keyed on the browser, so the trip follows the traveler into the next visit without a login.
trip_booking_returning = StreamAttributeGroup(
    name="trip_booking_returning",
    version=1,
    attribute_key=domain_userid,
    owner="you@example.com",
    attributes=[hours_since_abandon, abandoned_step, last_route_7d, last_depart_7d],
)
```

```
// In the code that renders the homepage
const traveler = await signals.getServiceAttributes({
  name: "trip_booking_returning",
  attribute_key: "domain_userid",
  identifier: domainUserId,
});
// { hours_since_abandon: 1.2, abandoned_step: "payment", last_route_7d: "BOS-LIS", last_depart_7d: "2026-11-12" }

if (traveler.hours_since_abandon <= 48 && traveler.abandoned_step === "payment") {
  homepage.restoreTrip({
    route: traveler.last_route_7d,
    depart: traveler.last_depart_7d,
    resumeAt: "payment",
  });
}
```

### Send the reminder with the trip in it, while the trip is still open

A traveler got as far as the passengers step yesterday, typed an email and left at payment. They have not been back. The reminder usually goes out when the abandonment job next runs, and it arrives once the traveler has booked elsewhere or stopped thinking about the trip. A note the next day that names the route and the dates, sent to the traveler who has not come back on their own, is the one they open. The email stays in your CRM under your consent rules, and Signals supplies how long ago they left and what the trip was, keyed on the browser.

What Signals knows

`hours_since_abandon` 

Hours since they left a booking unfinished

`hours_since_visit` 

Hours since their last page view

`last_route_7d` 

The route of the last fare they picked in the last seven days

`last_depart_7d` 

The departure date of that fare

What your booking flow and CRM do

Sends a reminder with the route and dates a day after a traveler left a booking, only when they have not been back since and only to a traveler whose email your CRM holds with consent, and holds off for the one who came back on their own.

Define it · PythonUse it · TypeScript

crm-sync.ts · 19 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, Event, EventProperty, StreamAttributeGroup, domain_userid,
)

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")
booking_abandoned = Event(name="booking_abandoned", vendor="com.example", version="1-0-0")
fare_selected = Event(name="fare_selected", vendor="com.example", version="1-0-0")

hours_since_abandon = Attribute(
    name="hours_since_abandon",
    type="double",
    events=[booking_abandoned],
    aggregation="time_since_last",
    time_unit="h",
)

hours_since_visit = Attribute(
    name="hours_since_visit",
    type="double",
    events=[page_view],
    aggregation="time_since_last",
    time_unit="h",
)

last_route_7d = Attribute(
    name="last_route_7d",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="route"),
    period=timedelta(days=7),
)

last_depart_7d = Attribute(
    name="last_depart_7d",
    type="string",
    events=[fare_selected],
    aggregation="last",
    property=EventProperty(vendor="com.example", name="fare_selected", major_version=1, path="depart_date"),
    period=timedelta(days=7),
)

# The email never reaches Signals. Your CRM links it to the browser id when the traveler types it in the flow.
trip_booking_reminder = StreamAttributeGroup(
    name="trip_booking_reminder",
    version=1,
    attribute_key=domain_userid,
    owner="you@example.com",
    attributes=[hours_since_abandon, hours_since_visit, last_route_7d, last_depart_7d],
)
```

```
// In the job that syncs traveler state to your CRM
const traveler = await signals.getServiceAttributes({
  name: "trip_booking_reminder",
  attribute_key: "domain_userid",
  identifier: domainUserId,
});
// { hours_since_abandon: 26.4, hours_since_visit: 26.3, last_route_7d: "BOS-LIS", last_depart_7d: "2026-11-12" }

// The email they typed in the flow, kept by your CRM under your consent rules.
const contact = crm.contactFor(domainUserId);

if (contact?.email && traveler.hours_since_abandon >= 24 && traveler.hours_since_visit >= 24) {
  crm.send({
    to: contact,
    campaign: "finish_your_booking",
    route: traveler.last_route_7d,
    depart: traveler.last_depart_7d,
  });
}
```

## Try it on your own site. 

Add the SDK, define one thing you want to know about a visitor, and watch it update while you click around. The free tier runs on the real engine: no card, no sales call.

[Start free](https://signals.snowplow.io/start) [Talk to an engineer](https://signals.snowplow.io/start?intent=engineer)
