View as Markdown

How to convert non-spenders with an escalating offer chain

This guide walks you through how to convert non-spenders into first-time buyers with an offer that improves the more they buy. You’ll build an escalating chain: a starter offer with a personal countdown, and a discounted version when they buy the starter offer.

Before you start #

You’ll need:

  • A Satori instance and access to the Satori console
  • Nakama and Hiro installed

Read about Journeys first if you aren’t familiar with the feature.

What you’ll build #

An escalating offer chain, assembled from five parts:

  • a journey that drives the chain and decides when to escalate
  • live events that carry each offer and its countdown
  • Hiro economy definitions for the offers themselves
  • a custom Nakama server runtime in Go that publishes the purchase signal advancing the chain
  • client code that renders the timer. The examples here are Unity and C#. You can use any other Satori client library.

Satori and Hiro talk to each other through Satori Personalizer, registered on your Nakama server. It reads one feature flag per Hiro system, named Hiro-[SystemName], so Hiro-Economy is where an economy override lands. Make sure your Nakama server is connected to Satori and the personalizer is registered, following Set up personalizers.

Register it with economy event publishing turned on. The chain’s entry condition filters on purchaseCompletedCount, and that computed property only exists if Hiro forwards economy events to Satori:

1
2
3
systems.AddPersonalizer(hiro.NewSatoriPersonalizer(ctx,
    hiro.SatoriPersonalizerPublishEconomyEvents(),
))

Without SatoriPersonalizerPublishEconomyEvents(), purchaseCompletedCount stays at zero for everyone and the chain admits players who have already spent. See Publishers for the full list of event options.

Here’s the flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Entry Point
 └─ Add to Live Event: starter_offer          [48h participation]
     └─ Send Message: "Starter pack available"
         └─ Bought, or window closed?
             ├─ Bought  → Revert starter_offer → Exit
             └─ Expired → Revert starter_offer
                           └─ Add to Live Event: discount_offer   [72h participation]
                               └─ Send Message: "Last chance, now 40% off"
                                   └─ Bought, or window closed?
                                       ├─ Bought  → Revert discount_offer → Exit
                                       └─ Expired → Revert discount_offer → Exit

Build the offer chain #

Step 1

Define the offers in Hiro #

Both offers live in your economy config as ordinary store items, disabled by default. The live events enable them one at a time.

Add them to base-economy-dev1.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
  "initialize_user": {
    "currencies": {
      "gems": 0
    }
  },
  "store_items": {
    "starter_offer": {
      "name": "Starter Pack",
      "description": "A one-time boost to get you going.",
      "cost": {
        "sku": "com.yourgame.starter_pack"
      },
      "reward": {
        "guaranteed": {
          "currencies": {
            "gems": {
              "min": 500,
              "max": 500
            }
          }
        }
      },
      "disabled": true
    },
    "discount_offer": {
      "name": "Starter Pack (40% off)",
      "description": "Last chance, now 40% off.",
      "cost": {
        "sku": "com.yourgame.starter_pack_discount"
      },
      "reward": {
        "guaranteed": {
          "currencies": {
            "gems": {
              "min": 500,
              "max": 500
            }
          }
        }
      },
      "disabled": true
    }
  },
  "placements": {},
  "donations": {}
}

Both items are disabled, so nobody sees them until a live event turns one on.

Step 2

Publish a purchase signal per offer #

To know when a player buys one of your offers, you need the server to publish that event. Hiro already publishes purchaseCompleted to Satori, but its computed property counts every purchase together, so purchaseCompletedCount can’t tell you which offer converted. Send a distinct event per offer from the store reward hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
systems.GetEconomySystem().SetOnStoreItemReward(func(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, userID, sourceID string, source *hiro.EconomyConfigStoreItem, rewardConfig *hiro.EconomyConfigReward, reward *hiro.Reward) (*hiro.Reward, error) {
	var eventName string

	switch sourceID {
	case "starter_offer":
		eventName = "starterOfferPurchased"
	case "discount_offer":
		eventName = "discountOfferPurchased"
	default:
		return reward, nil
	}

	if err := nk.GetSatori().EventsPublish(ctx, userID, []*runtime.Event{{
		Name:      eventName,
		Timestamp: time.Now().Unix(),
	}}); err != nil {
		logger.Error("error publishing %s to Satori: %s", eventName, err.Error())
	}

	return reward, nil
})

Sending from the server keeps the signal authoritative. A client can’t fake having bought the offer. It also keeps the whole change server side, without needing a client update.

Register the two events on the Satori Console so Satori accepts them:

  1. Go to Settings > Events.
  2. Select Create New Event.
  3. For Event Name enter starterOfferPurchased.
  4. Repeat for discountOfferPurchased.

Satori derives a computed property from each event name automatically, giving you starterOfferPurchasedCount and discountOfferPurchasedCount computed properties.

Step 3

Create the journey #

  1. Go to Journeys and select Create Journey.
  2. On the Details stage, enter starter_offer_chain for Name and describe what the chain does.
  3. Select Create.
  4. Leave Rejoin cooldown period as Never rejoin, so each player sees the chain only once.

Completing the wizard drops you into the flow editor with Entry Point already placed.

The Scheduling stage of the Create Journey wizard
Scheduling decides when the journey admits players and whether they ever return
Step 4

Define who enters #

  1. Select the Entry Point step with the label Immediate to open its configuration.
  2. Select Choose Conditions.
  3. Add the conditions limiting entry to engaged non-spenders:
    • purchaseCompletedCount less than 1
    • sessionStartCount more or equal than 3

Players who’ve never spent and have opened the game at least three times now qualify. Leaving the condition empty would admit everyone.

Journey's dashboard showing the entry conditions for the journey setup.
Journey's dashboard showing the entry conditions for the journey setup.
Step 5

Present the first offer #

Create the live event from inside the journey step rather than from the Live Events page. A journey supplies membership itself, so an event created this way doesn’t need an audience.

  1. Drag Add to Live Event from the palette onto the canvas.
  2. Connect Entry Point to it.
  3. Select the Create new Live Event icon (+ sign) beside the live event picker.
  4. Name the event starter_offer and leave Audience(s) empty.
  5. Leave Explicit Join off, so journey enrollment takes effect without the client opting in.
  6. Leave Sticky Membership off. It keeps a player in the event after eligibility ends, so it would leave the offer visible after the revert.
  7. Set Participation Duration (seconds, optional) to 172800, which is 48 hours.
  8. Under Feature Flags to override, select Hiro-Economy and give it this value:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "store_items": {
    "starter_offer": {
      "name": "Starter Pack",
      "description": "A one-time boost to get you going.",
      "cost": { "sku": "com.yourgame.starter_pack" },
      "reward": {
        "guaranteed": {
          "currencies": { "gems": { "min": 500, "max": 500 } }
        }
      },
      "disabled": false
    }
  }
}
  1. Set the live event’s start and end times so that the run window fully contains the 48-hour participation window.
  2. Save the event. It’s selected on the step automatically.

SatoriPersonalizer reads one flag per Hiro system, named Hiro-[SystemName]. Economy config comes from Hiro-Economy, and a live event override outranks the flag’s own variants, so the live event is all you need.

Create journey live events from the journey
Opening the Live Events page and creating an event there makes Audience(s) a required field, because audiences are normally what admit players. An event created from a journey step gets its membership from the step instead, so the field becomes optional. If you’ve already made an event with a placeholder audience, remove that audience before pointing a journey at it, or those players enter the chain’s offer without entering the chain journey.
Repeat the whole store item, not just the field you're changing
The override value is decoded onto the config Hiro already loaded. Store items you don’t mention are left alone, but any item you mention is replaced in its entirety. An override containing only {"store_items": {"starter_offer": {"disabled": false}}} enables the item but wipes its name, cost, and rewards. Always repeat the full definition of every item an override touches.

Optionally, add a message to the tier. This step needs a message template to already exist. If you don’t have one, create it from the Messages section of the console first. See Create message templates.

  1. Drag Send Message onto the canvas and connect the live event step to it.
  2. Select your offer Template.
  3. Turn on Persist in Identity Inbox to keep the message after external delivery succeeds.

Players reaching these steps join starter_offer. Their participation clock starts, SatoriPersonalizer picks up the economy override, and the offer appears in the store.

Step 6

Watch for the purchase or the expiry #

The player waits here until one of two things happens: they buy the offer, or the wait you set runs out. Satori re-evaluates the condition every time that player sends an event, so a purchase advances them the moment it lands.

  1. Drag Conditional Branch onto the canvas and connect the Send Message step to it. If you skipped the optional message, connect the live event step instead.
  2. Add a rule and label its branch Bought:
    • starterOfferPurchasedCount greater than 0
  3. Turn on Wait for a condition to be true and set it to 2 days (how long the offer will be available to the player).

A purchase sends the player down Bought as soon as the event is emitted, even if it arrives on the same pass that their window closes. Everyone else waits at this step until the timeout you set elapses, then leaves through No Match.

A conditional branch matching on a purchase or on the live event participation ending
Two rules, evaluated in order, with the wait toggle holding the player until one matches
Step 7

Escalate when the window closes #

  1. Drag Revert Step onto the canvas and connect it to the Bought.
  2. Select the starter_offer live event step as the step to undo.
  3. Add an Add to Live Event step connected to the revert, and create discount_offer from it the same way. Enable the discount_offer store item.
  4. (optional) Add a Send Message step announcing the discount.
  5. Add a second Conditional Branch with the same two-rule shape, matching discountOfferPurchasedCount and the Wait for a condition to be true timeout.
Step 8

Clear the offer on the way out #

Live event membership outlives the journey. Leaving the journey doesn’t remove it, so the player keeps seeing the offer. You have to take it away yourself. Add a Revert Step on every remaining path before the player reaches Exit:

  1. Drag Revert Step onto the canvas.
  2. Select the live event step it should undo.
  3. Connect it to Exit.
  4. Repeat for each remaining path out of the chain.
How the complete journey should look
Complete Journey of a chain offer.
Step 9

Render the offer and its countdown #

This step shows the offer in your store and counts down to the moment it expires. The examples use the Hiro and Satori C# client libraries. The offer itself comes from Hiro’s economy config. The countdown comes from the live event in Satori.

Start with the store. When the journey adds a player to the live event, SatoriPersonalizer folds that override into the economy config Hiro serves to that player. Call RefreshStoreAsync to fetch that updated config. The client’s cached store predates the override, so the offer stays invisible until you refetch:

1
2
3
4
5
private async void OnEnable()
{
    // Picks up whichever offer the journey has enabled for this player.
    await _economySystem.RefreshStoreAsync();
}

The countdown comes from the live event. Each returned event carries ActiveParticipationEndTimeSec, the moment this specific player’s participation ends:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var events = await client.GetLiveEventsAsync(session, names: new[] { "starter_offer", "discount_offer" });

foreach (var liveEvent in events.LiveEvents)
{
    // 0 means the player has no participation limit on this event.
    if (liveEvent.ActiveParticipationEndTimeSec == 0)
    {
        continue;
    }

    var expiresAt = DateTimeOffset.FromUnixTimeSeconds(liveEvent.ActiveParticipationEndTimeSec);
    var remaining = expiresAt - DateTimeOffset.UtcNow;

    offerTimerText.text = remaining > TimeSpan.Zero
        ? $"{(int)remaining.TotalHours:00}:{remaining.Minutes:00}:{remaining.Seconds:00}"
        : "Expired";

    break;
}
Don't poll the server for the countdown
Fetch ActiveParticipationEndTimeSec once, then tick the remaining time down locally. Calling GetLiveEventsAsync every frame puts a request per frame per player on your Satori instance. Refetch from the server only when the player opens the store or returns to the foreground.

Both offers are priced with a sku, so they’re real-money purchases. Buy them through UnityPurchasingSystem, which triggers the platform’s native purchase sheet, submits the receipt to Nakama for validation, and resolves only once the reward has been granted. See Purchase a store item:

1
2
IPurchaseResult result = await _purchasingSystem.BuyProductByIdAsync(itemId);
await _economySystem.RefreshAsync();

For a complete store UI, see One Time Store Offers.

Step 10

Validate and enable #

The editor validates the graph as you build. Fix anything it flags before saving:

  • Entry Point connects to at least one step
  • Every step except Entry Point has exactly one incoming connection
  • Only condition steps have more than one outgoing path
  • Every path eventually reaches Exit

Save the journey, then set its status to Enabled. If the start time is still ahead, it reports as Scheduled until the window opens, then flips to Running on its own.

Confirm it’s working #

Open Journey Analytics for per-step counts. The two condition steps are the interesting ones: compare how many players leave through Bought against how many leave through No Match to see each tier’s conversion. Total completions and average duration tell you how long the chain takes end to end.

To debug a specific player, open their identity page and check their journey history. It lists every step they’ve entered and when, which is the quickest way to answer why someone did or didn’t get an offer.

If an offer isn’t appearing, check the resolution chain before suspecting the journey. GetFlagOverridesAsync returns every source affecting a player’s flags, so you can see whether the live event override is being applied and whether anything is taking precedence over it.

Adapt the chain #

Add a third tier: Repeat the revert-then-escalate pattern from the second Expired branch. Watch the total wait, because a chain that takes a week to resolve delays the player’s next journey too.

Run it on a cadence: Set a Rejoin cooldown period instead of Never rejoin. Players become eligible again once it elapses, which turns the chain into a recurring campaign. The minimum cooldown is 60 seconds.

Carry display data with the offer: Give the live event a Value, a one-off JSON object the client reads alongside the countdown. Useful for banner art or copy that doesn’t belong in your economy config.

Things to watch out for #

Override completeness: An override replaces every store item it names. Mentioning an item without repeating its full definition wipes the fields you left out. Keep override values complete.

Run window coverage: The run window has to contain the participation window. Participation expiry is only set when the player’s join time plus the duration falls before the run ends. Otherwise participation lasts the full run and the client gets 0, meaning no limit, so the countdown disappears.

Dormant players: Expiry doesn’t wake a dormant player. A participation window closing isn’t an event, so a player who stops playing escalates on their next session rather than the moment their window ends. The wait duration on the condition step is what eventually catches players who never return.

Lifetime event counts: starterOfferPurchasedCount never resets, so a player running the chain a second time after a rejoin cooldown looks like they already converted. For recurring chains, publish a season-specific event name from the reward hook.

Fixed action types: A step’s action type is fixed once saved. To turn a Send Message step into an Add to Live Event step, delete it and add a new one.

See next #