Building a rewards platform from scratch

This post was originally written by Pedro Franceschi on Medium. Reposted with permission.


Brex is launching rewards today. This post aims to go over the design decisions that we made to build our rewards platform from the ground up.

Rewards goal

The primary goal of our rewards program is to drive spend and, more generically, any sort of in-product behavior. In order to provide this flexibility from day one, our rewards platform ended up with a relatively complex design despite the simple scope of our initial rewards offering (we’re launching with a basic points system). However, we knew our growth team would rapidly come up with creative ideas around rewards, so we decided to be future-proof and design the platform correctly from the beginning. 🙂

Brex was built as a distributed system from day one, and different subsystems are responsible for different sets of features. Therefore, in order to reward any type of user action, we needed an easy way for any system on Brex to trigger Brex-defined actions that might generate rewards back to users, while being extra careful about idempotency and consistency (which we value tremendously as a financial company, even for a non-critical feature like rewards).

The overall idea of the system is: given a user behavior and an action, any sort of reward can be triggered — from actual dollars (cashback) to points, bottles of champagne, etc. Points, however, offer the most versatility as they decouple accrual from redemption (unlike “gift” rewards, for example like a fixed amount gift card).

Therefore, we decided to split our rewards platform in two major components: accruals, responsible for processing system-wide actions, matching them to campaigns and issuing rewards accordingly (primarily points); and redemptions, where we control which offers that can be redeemed using points previously accumulated.

By controlling all the parameters of accrual and redemption rates for each action/offer, we can give our growth team granularity to drive very specific user behaviors. Here’s how:

(Sections from this blog post were extracted from the internal design doc for our rewards system. Thanks, Thomas Césaré-Herriau!)

Actions

An Action is a rewards-specific event that, when received by the rewards platform, starts the process of accruing points. Examples of actions are:

  • Purchase at Instacart
  • New ERP integration
  • New receipt uploaded
  • User signed up through referral link

Initially, we started by exposing a simple Action API to other Brex systems, which enabled registering ad-hoc Actions on the rewards platform:

Although simple, this approach would require every single system on Brex to manually dispatch every possibleAction to the rewards system. In order to increase flexibility, we also wanted our design to support consuming events from event streams (i.e. a Kafka streaming from a database replication slot, for instance) instead of triggering all events manually, so we added support for a conversion layer which takes generic system events (e.g. transaction cleared, user created) and transforms them in a meaningful Action(purchase, user referral).

To ensure idempotency, the conversion layer computes a unique token for the Action, based on the system event (e.g. the ID of the data entity affected in the originating system). The combination of the type of the Action and this token is a unique index in a ActionInstance table. When the Reward system receive an Action, it looks up the database to see whether this particular instance has already been processed or not. As the ActionInstance is stored in the database within the same transaction that processed it, this lookup ensures the Action will only get processed once.

Behaviors

In our initial design, Actions were directly associated with rewards. However, we realized that this was not flexible enough as some rewards should only be redeemed after a specific sequence of Actions happened. Therefore, we decided to introduce a new concept: Behaviors. Examples of behaviors are:

  • 10 purchase at Lyft
  • 5 times syncing your ERP integration
  • $10K spent in the first month
  • 2 users invited who spent $1K each in their first week

Rewards are obtained based on Behaviors, which are affected by incoming Actions. Those Behaviors are encoded using an implementation of a protocol, and a campaign-specific configuration that can customize how the incoming actions affect a behavior being triggered (named BehaviorTrigger). The Behavior then processes anAction and stores an internal state in each of their instances (per customer account/user), as a BehaviorTriggerInstance.

(Note that not all Actions trigger theBehavior right away: for instance, given a behavior of spending $10k in the first month, every Purchase Action has to be processed and the “total amount spent in the first month” has to be updated, thus the importance of having a BehaviorTriggerInstance to store internal state.)

The configuration of a BehaviorTrigger covers both how it is triggered based on the Action processed, as well as how the Reward is calculated based on its final state (it can be a rate based on the transaction amount, or a fixed points rewards configured in a campaign).

An Action Processor in Rewards processes all the incoming ActionInstance, and loads the SelectedTrigger that can process them, filtering out the Campaigns that don’t target the account/user.

Cohorts and Campaigns

Since the beginning, we also wanted to have the flexibility to target specific campaigns to cohorts of accounts on Brex. It turned out, however, that designing flexibleCohorts was a lot more complicated than we initially thought.

We started with two basic types of cohorts: a GlobalCohort that targets all accounts, and a StaticCohort, which contains a static list of customer account ids.

Soon we realized that we’d also need support for DynamicCohorts. For instance, imagine a campaign that gave 2x points for all new accounts in the first month on Brex. Accounts would have to be manually added and removed from StaticCohorts by hand, which was far from ideal.

After a couple of different designs, we decided to leverage the Action Processor mechanism to process incoming Actions and, based on CohortRules, determine whether the account should be added to a DynamicCohort, removed from it or simply ignore the Action for cohort attribution purposes.

CohortRule has an internal state similar to a BehaviorTrigger, which enables support for complex assignment rules (i.e. adding an account to a cohort after they spent $1000 on the first two months).

CohortRules are implementations of the following CohortRuleprotocol:

Accrual flow

With actions, behaviors, cohorts and campaigns, we have the overall flow for points accrual:

    ┌─────────────────────────────┐
    │                             │
    │       External System       │
    │  (e.g authorization system) │
    │                             │
    └─────────────────────────────┘
                   │
                   │ 1.
                   ▼
         ┌───────────────────┐
         │ Conversion Layer  │
         └─────────┬─────────┘
                   │
                   │ 2.
                   ▼
            ┌─────────────┐
            │   Action    │
            └──────┬──────┘
                   │
                   │ 3.
                   ▼
         ┌───────────────────┐
         │  Action Processor │
         └───────────────────┘
                   │ 4.
      ┌────────────┼────────────┐
      ▼            ▼            ▼
 ┌────────┐   ┌────────┐   ┌────────┐
 │ Cohort │   │ Cohort │   │ Cohort │
 │  Rule  │   │  Rule  │   │  Rule  │
 └────────┘   └────────┘   └────────┘
      │            │            │ 5.
      ▼            ▼            ▼
 ┌────────┐   ┌────────┐   ┌────────┐
 │   add  │   │ remove │   │ ignore │
 └────────┘   └────────┘   └────────┘
                 Then:
            ┌─────────────┐
            │   Action    │
            └──────┬──────┘
                   │
                   │ 6.
                   ▼
         ┌───────────────────┐
         │  Action Processor │
         └───────────────────┘
                   │ 7.
      ┌────────────┼────────────┐
      ▼            ▼            ▼
 ┌────────┐   ┌────────┐   ┌────────┐
 │Campaign│   │Campaign│   │Campaign│
 └────────┘   └────────┘   └────────┘
      │            │            │ 8.
      ▼            ▼            ▼
 ┌────────┐   ┌────────┐   ┌────────┐
 │Behavior│   │Behavior│   │Behavior│
 │Trigger │   │Trigger │   │Trigger │
 └────────┘   └────────┘   └────────┘
      │            │            │ 9.
      ▼            ▼            ▼
 ┌────────┐   ┌────────┐   ┌────────┐
 │ update │   │ Reward │   │ ignore │
 └────────┘   └────────┘   └────────┘
                   │
                   │ 10.
                   ▼
              ┌────────┐
              │ Points │
              │Balance │
              │ Entry  │
              └────────┘
  1. System (likely external) performs some operations that emits an Event(e.g. Transaction Created , User Created).
  2. It is converted into a Rewards Action by a conversion layer, and then sent to Rewards.
  3. The Rewards System receives the Action and starts processing it.
  4. The first processing done is through CohortRule. Each currently active CohortRule is loaded and processes the Action.
  5. Based on the Action and the previous states, CohortRule can add or remove users from DynamicCohort, or ignore the action. Based on the results, Cohort is updated with a new set of users and stored in the database.
  6. Once CohortRule is processed, the Action goes through the second step of the Action Processor.
  7. The Action Processor uses the customer_account_id on the Actionpayload to identify which Cohort contains the specific account, and which Campaign targets the Cohort.
  8. It loads the SelectedTrigger of the filtered Campaign, and use their BehaviorTrigger implementation to process the Action.
  9. The SelectedTriggercan ignore the Action, accept it and update the internal state of the SelectedTriggerInstance associated with the account/user, or trigger and create a Reward for the user.
  10. If a Reward is created and is a PointsReward, the points accrued will be added to the account balance by creating one PointsBalanceEntry linked to the account and the user.

Redemption

Once accrued, points can be used to redeem offers, a generic term describing anything of value to the user. Our initial design was simply having a table ofRedemptionOffers with a cost column storing the amounts of points required to redeem the offer. This design worked fine to redeem Brex points in promo codes or physical items, but not for offers with dynamic pricing like booking a flight or providing a statement credit (cashback) for a transaction.

As different types of offers have completely different implementations for calculating their cost and redeeming them, we needed a design that supported a runtime protocol (instead of simply a model) to aggregate offers of the same class under a same implementation. Thus, we introduced theRedemptionClass entity, which defines a specific class of offers that can be redeemed using points. For instance:

  • Provide a cashback for a transaction on a statement.
  • Convert Brex points to AWS credits on the user’s account.
  • Get a bottle of Champagne
  • Book a flight

This mechanism is defined by the implementation of a protocol that performs the redemption action, and a configuration specific to the Redemption program.RedemptionOffer implements this protocol to provide rewards:

This way, we can define RedemptionClasses for each type of redemption offer available on Brex. This abstraction also provides flexibility to dynamically price our inventory of static deals (i.e. promo codes) depending on previous levels of spend, account history, etc., which is a nice addition 🙂


The final design for our rewards platform ended up much more complex than what we initially expected. However, anticipating the future needs of our business, it would be unwise to cut corners and deliver a MVP that, in order to support more advanced functionality, would need to be completely redesigned in the near future.

At Brex, we pride ourselves on building systems correctly from day one. We believe that spending the time to start with the right abstractions is the best investment to exponentially reduce technical debt in the long-run. When building financial infrastructure from scratch, the only certainty is that future engineers will build on top of the existing abstractions, so the time spent building a proper technical foundation is paramount.

Brex is building core financial infrastructure from scratch — come work with us!

Software Weekly

Software Weekly

Subscribe to Software Weekly, a curated weekly newsletter featuring the best and newest from the software engineering community.