Rolling Your Own Starbucks Loyalty Program

4/2023 Aman Azad

How to Roll Your Own Loyalty Program

Lots of businesses benefit from implementing their own loyalty programs. Airlines, restaurants, coffee shops all have some form of loyalty programs to entice recurring visits from the same customers.

Implementing a loyalty program for your specific business however, is a weird, nebulous problem that never seems to quite fit into an existing software offering.

We ran into this while building Legaci. We felt a lot of artists lacked any meaningful way to incentivize bigger commitments from their fans beyond streaming them on DPSs.

We wanted to integrate a loyalty program where fans get rewarded for listening and watching an artist’s content, then use those points to redeem merch, pre-sale tickets, video shout-outs, etc.

Legaci BI Rewards Page

Finding the right solution

Legaci offers fan clubs for artists.

We felt adding a livestream experience tackles two birds with one stone: offer engaging, long form content to users, and lay the foundation for the core “gameplay” of the loyalty program.

For loyalty programs to work, users need to take actions they’re already accustomed to doing. Starbucks customers purchase coffee, airline customers purchase tickets. For artists, their fans are used to listening to music, and watching music videos and other video content featuring their favorite artists.

Not wanting to build out an entire twitch.tv ecosystem alongside our loyalty program, we decided the best way to deliver something was to take advantage of our artists' catalog of music videos and content on YouTube.

So, we created a "livestream" where fans watch a playlist of YouTube videos curated by the artist. The videos remain in sync across all users so the fans are always watching the same content. For every minute a fan watched, they earned 1 point which they could later redeem for prizes.

Legaci BI TV Page (Check out the product over at bi.legaci.com/tv - you need to be a fan to watch!)

Data modeling

Because we’d be transacting potentially hundreds of thousands of requests a minute, we wanted this system to have as few moving parts as we could afford.

The pieces we needed were as follows:

  1. SQL table acting as a ledger for the individual records of points awarded
  2. SQL roll up table storing the summarized points for a user
  3. Redis queue to manage recording transactions, and preventing double spend and bad actors

Few reasons why these were the choices we went with.

The SQL tables are very simple, we track the user ID, artist ID, and amount of points transacted. The rollup table was necessary to cut back on unnecessary, and repeated calling of the main ledger to summarize the latest point values for a particular user.

We needed still to maintain a ledger of all the point additions and subtractions to make sure we have a high level of confidence in the system. This is extremely important as these points would be used by fans to unlock access to extremely valuable products. Maintaining a ledger let us confidently report back to our users, and artists that the points being represented were indeed correct.

Leveraging Redis’s single-threaded server, and a basic system of locks cuts back entirely on bad actors attempting to take advantage of the points system. Logging in on different tabs, different devices, and the like are all attack vectors to gain the system. Again on the issue of increasing confidence, these were all necessary requirements we had to account for.

Code

We can cleanly split this up into two sections: the frontend and the backend code.

React

The frontend is a very simple layer, that calls a GraphQL increment endpoint once every minute, granted a few conditions are met (the user is logged in, is a member of the fan club, has not gone past the daily allotment of points).

// Mutations - Standard GraphQL mutation
const [incrementPoints] = useIncrementListeningPointsMutation();

// Use Effect - Track if the TV is playing, and increment points every minute
React.useEffect(() => {
  if (isPlaying) {
    const countdown = setTimeout(() => {
      incrementPoints({
        variables: {
          brandId: brand?.id || "",
          type: "tv-watch",
        },
      });
      setIncrement(increment + 1);
    }, POINT_INCREMENT_TIME_MS /* Set to 1000ms */);
    setMutationTimer(countdown);
  } else {
    if (mutationTimer) {
      clearTimeout(mutationTimer);
    }
    stopPolling();
  }
}, [isPlaying, increment]);

NodeJS

The backend becomes very simple once we make the right infrastructure choices. Broadly, the increment endpoint enqueues a job indicating this particular user for this artist needs a point added or subtracted. We also track for what particular instance this user is transacting points, whether that's for redeeming a prize, or watching TV, or something else.

All the verifications, checks and processing is done in a serverless function that verifies whether or not this user can have this transaction recorded.

Enqueue a job to record a transaction:

export const incrementListeningPoints = async ({
  userId,
  artistId,
  type,
}: {
  userId: string,
  artistId: string,
  type?: ArtistPointsTransactionModel["type"],
}) => {
  WorkerServices.enqueueJob({
    jobName: "pointsRecordTransaction",
    userId,
    artistId,
    pointsAmount: 1,
    linkedEntityId: artistId,
    linkedEntityType: "artist",
    type: type || "track-listen",
  });
};

Processing function that verifies whether or not this user can have this transaction recorded:

export const recordPoints = async ({
  userId,
  artistId,
  pointsAmount,
  linkedEntityType,
  linkedEntityId,
  type,
  createdAt,
}: RecordPointsAndGoldInput) => {
  const canRecordPoints = await getCanRecordPoints({
    userId,
    artistId,
    type,
    linkedEntityType,
    linkedEntityId,
  })

  if (!canRecordPoints) {
    return
  }

  ...
}

Create a new entry for this transaction, or update an existing one if it already exists:

const createPointsTransactionData = {
  userId,
  artistId,
  points: pointsAmount,
  ...(linkedEntityId ? { linkedEntityId } : {}),
  ...(linkedEntityType ? { linkedEntityType } : {}),
  ...(createdAt ? { createdAt } : { createdAt: new Date().toUTCString() }),
  ...(type ? { type } : {}),
};

const existingTransactionRecord = await getExistingLeaderboardEntry({
  userId,
  artistId,
  pointsAmount,
  linkedEntityType,
  linkedEntityId,
  type,
  createdAt,
});

await addPointsTranscationRecord({
  existingLeaderboard,
  createLeaderboardData,
  type,
});

Finally, we update the rollup table to reflect the latest point values for this user, and trigger a Mixpanel event for general observability of usage in the app.

rollupUserCurrency({ userId, artistId });
triggerMixpanelEvent({ type, artistId });

Wrapping up

The hardest challenge in rolling your own loyalty program is figuring out that core gameplay your users/customers/fans will engage in to get points. This needs to be something that can be done solo, is existing and familiar behavior, and is engaging.

Once this is hashed out, implementing the core system isn’t too esoteric of a software engineering problem. The main issue is understanding where the vectors of abuse are in your particular game, and clamping down on those with good architecture, and checks along the way.