---
title: "Loyalty timing for travel brands | Snowplow Signals"
description: "Snowplow Signals tells your site when a traveler is booking a second trip this year and which member benefits sit unused, so the prompt shows then, not before."
source: https://signals.snowplow.io/solutions/travel/loyalty
---

[Travel](https://signals.snowplow.io/solutions/travel) / Loyalty timing 

# A join prompt that waits for the second trip. 

Signals tells your site how many trips a traveler has booked this year, how many times they have closed the join prompt and whether a member has used a benefit, so the prompt shows when there is a reason to say yes.

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

More loyalty sign-ups

## Get more loyalty sign-ups by asking when the traveler has a reason to join. 

Loyalty sign-ups move when the prompt reaches a traveler who has a reason to join, and stays out of the way of one who does not have it yet. Signals gives your site how many trips this traveler has booked in the last year, how many visits they have made this month, how many times they have closed the join prompt and whether a member has used a benefit on a booking. Your site decides when the prompt shows and what it says.

Four ways travel brands use it

01 The traveler booking a second trip this year Flew in March, booking again in October, on the confirmation step. Ask now, with the points. 02 The first-visit traveler In from search, comparing two fares, nothing booked. No prompt. 03 The traveler who closed the prompt twice Closed the join prompt twice in three months, booking again. Stop asking on the page. 04 The member with a benefit left unused Three trips as a member, free bag never claimed. Remind them at the extras step. 

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

### Show the join prompt on the booking that earns the points

A traveler booked a flight in March and is back in October, two passengers picked, on the confirmation step of a second trip. A join prompt that shows on arrival reached this traveler on their first visit, when they had no idea whether they would fly with you again, and the no they gave then cost them nothing. The reason to join arrives now, as the points this booking would earn, and the prompt belongs on this step with that number in it.

What Signals knows

`bookings_1y` 

Bookings completed on this browser in the last year

`visits_30d` 

Separate visits in the last 30 days

What your site does

Shows the join prompt at the confirmation step once a traveler has a completed booking in the last year, and fills in the points this booking would earn from your program's own rules. The step stays clear of the prompt for a traveler booking their first trip.

Define it · PythonUse it · TypeScript

confirmation.tsx · 15 lines Copy Copied 

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

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

bookings_1y = Attribute(
    name="bookings_1y",
    type="int32",
    events=[booking_completed],
    aggregation="counter",
    period=timedelta(days=365),
    default_value=0,
)

visits_30d = Attribute(
    name="visits_30d",
    type="int32",
    events=[page_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=30),
)

# Travelers book without an account, so this group keys on the browser.
trip_loyalty_second_trip = StreamAttributeGroup(
    name="trip_loyalty_second_trip",
    version=1,
    attribute_key=domain_userid,
    owner="you@example.com",
    attributes=[bookings_1y, visits_30d],
)
```

```
// In the code that renders the confirmation step
const traveler = await signals.getServiceAttributes({
  name: "trip_loyalty_second_trip",
  attribute_key: "domain_userid",
  identifier: domainUserId,
});
// { bookings_1y: 1, visits_30d: 3 }

if (traveler.bookings_1y >= 1) {
  confirmation.show({
    prompt: "join",
    points: loyalty.pointsFor(booking),
    headline: "Join now and this trip earns points",
  });
}
```

### Keep the join prompt off a first visit

A traveler arrived from a search result a few minutes ago and is comparing two fares to Denver. It is their first visit, and nothing has been booked. A join prompt that shows on arrival asks this traveler before they have any reason to say yes, and a no on the first visit makes the ask on the second visit harder. The right move this visit is no prompt. If they book, the points line on their first confirmation is all the program needs to say.

What Signals knows

`visits_30d` 

Separate visits in the last 30 days

`bookings_1y` 

Bookings completed on this browser in the last year

What your site does

Leaves the join prompt off every page for a traveler on their first visit with no booking in the last year, and lets the booking make the case. The first mention of the program is the points line on their first confirmation.

Define it · PythonUse it · TypeScript

layout.tsx · 11 lines Copy Copied 

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

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

visits_30d = Attribute(
    name="visits_30d",
    type="int32",
    events=[page_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=30),
)

bookings_1y = Attribute(
    name="bookings_1y",
    type="int32",
    events=[booking_completed],
    aggregation="counter",
    period=timedelta(days=365),
    default_value=0,
)

trip_loyalty_first_visit = StreamAttributeGroup(
    name="trip_loyalty_first_visit",
    version=1,
    attribute_key=domain_userid,
    owner="you@example.com",
    attributes=[visits_30d, bookings_1y],
)
```

```
// In the layout that decides whether the join banner renders
const traveler = await signals.getServiceAttributes({
  name: "trip_loyalty_first_visit",
  attribute_key: "domain_userid",
  identifier: domainUserId,
});
// { visits_30d: 1, bookings_1y: 0 }

if (traveler.visits_30d <= 1 && traveler.bookings_1y === 0) {
  joinPrompt.hide();
}
```

### Move the ask to the email after the second no

A traveler has closed the join prompt twice in three months and is back, booking another trip. The prompt runs on one rule for everyone, so the third ask is set to show at the same step, and the count of closes usually reaches that rule after the page has shown it again. Two closes is an answer. The ask leaves the page, and the confirmation email carries one line with the points this booking earned and a join link, for the traveler to read once the trip is settled.

What Signals knows

`prompt_dismissals_90d` 

Times they closed the join prompt in the last 90 days

`bookings_1y` 

Bookings completed on this browser in the last year

What your site does

Keeps the join prompt off the confirmation step once a traveler has closed it twice in ninety days, and adds a one-line join with the points this booking earned to the confirmation email instead of repeating the ask on the page.

Define it · PythonUse it · TypeScript

confirmation.tsx · 15 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, StreamAttributeGroup, domain_userid

booking_completed = Event(name="booking_completed", vendor="com.example", version="1-0-0")
loyalty_prompt_dismissed = Event(name="loyalty_prompt_dismissed", vendor="com.example", version="1-0-0")

prompt_dismissals_90d = Attribute(
    name="prompt_dismissals_90d",
    type="int32",
    events=[loyalty_prompt_dismissed],
    aggregation="counter",
    period=timedelta(days=90),
    default_value=0,
)

bookings_1y = Attribute(
    name="bookings_1y",
    type="int32",
    events=[booking_completed],
    aggregation="counter",
    period=timedelta(days=365),
    default_value=0,
)

trip_loyalty_dismissed = StreamAttributeGroup(
    name="trip_loyalty_dismissed",
    version=1,
    attribute_key=domain_userid,
    owner="you@example.com",
    attributes=[prompt_dismissals_90d, bookings_1y],
)
```

```
// In the code that renders the confirmation step
const traveler = await signals.getServiceAttributes({
  name: "trip_loyalty_dismissed",
  attribute_key: "domain_userid",
  identifier: domainUserId,
});
// { prompt_dismissals_90d: 2, bookings_1y: 2 }

if (traveler.prompt_dismissals_90d >= 2) {
  confirmation.hide({ prompt: "join" });
  confirmationEmail.add({
    block: "join_inline",
    points: loyalty.pointsFor(booking),
  });
}
```

### Remind a member of the benefit they have not claimed

A member has booked three trips this year and has never used the checked bag their tier includes. The extras step prices the bag the same for everyone, and the reminder that it is free for this member usually comes in the annual statement, after the trips are flown. The extras step is where the bag is being priced. That is where the reminder belongs, while this member is still choosing.

What Signals knows

`bookings_1y` 

Bookings this member completed in the last year

`benefits_used_1y` 

Member benefits claimed on a booking in the last year

What your site does

Shows the bag or seat this member's tier includes at the extras step, at no charge, when a member who has booked this year has not used a benefit, and tells your loyalty platform the benefit was claimed. Which benefits the tier includes comes from your program. Signals supplies that none have been used.

Define it · PythonUse it · TypeScript

extras.tsx · 14 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, StreamAttributeGroup, user_id

booking_completed = Event(name="booking_completed", vendor="com.example", version="1-0-0")
benefit_used = Event(name="benefit_used", vendor="com.example", version="1-0-0")

bookings_1y = Attribute(
    name="bookings_1y",
    type="int32",
    events=[booking_completed],
    aggregation="counter",
    period=timedelta(days=365),
    default_value=0,
)

benefits_used_1y = Attribute(
    name="benefits_used_1y",
    type="int32",
    events=[benefit_used],
    aggregation="counter",
    period=timedelta(days=365),
    default_value=0,
)

# Members are logged in, so this group keys on the user id.
trip_loyalty_benefits = StreamAttributeGroup(
    name="trip_loyalty_benefits",
    version=1,
    attribute_key=user_id,
    owner="you@example.com",
    attributes=[bookings_1y, benefits_used_1y],
)
```

```
// In the code that renders the extras step
const member = await signals.getServiceAttributes({
  name: "trip_loyalty_benefits",
  attribute_key: "user_id",
  identifier: userId,
});
// { bookings_1y: 3, benefits_used_1y: 0 }

if (member.bookings_1y >= 1 && member.benefits_used_1y === 0) {
  extras.show({
    reminder: "benefit_unused",
    benefits: loyalty.benefitsFor(userId),
  });
}
```

## 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)
