Building a rewards platform from scratch
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.
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!)
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 possible
Action 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.
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
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
Behavior then processes an
Action and stores an internal state in each of their instances (per customer account/user), as a
(Note that not all
Actions trigger the
Behavior 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).
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 flexible
Cohorts 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
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 │ └────────┘ └────────┘ └────────┘
┌─────────────┐ │ Action │ └──────┬──────┘ │ │ 6. ▼ ┌───────────────────┐ │ Action Processor │ └───────────────────┘ │ 7. ┌────────────┼────────────┐ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │Campaign│ │Campaign│ │Campaign│ └────────┘ └────────┘ └────────┘ │ │ │ 8. ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │Behavior│ │Behavior│ │Behavior│ │Trigger │ │Trigger │ │Trigger │ └────────┘ └────────┘ └────────┘ │ │ │ 9. ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ │ update │ │ Reward │ │ ignore │ └────────┘ └────────┘ └────────┘ │ │ 10. ▼ ┌────────┐ │ Points │ │Balance │ │ Entry │ └────────┘
System(likely external) performs some operations that emits an
Event(e.g. Transaction Created , User Created).
- It is converted into a Rewards
Actionby a conversion layer, and then sent to
RewardsSystem receives the
Actionand starts processing it.
- The first processing done is through
CohortRule. Each currently active
CohortRuleis loaded and processes the
- Based on the
Actionand the previous states,
CohortRulecan add or remove users from
DynamicCohort, or ignore the action. Based on the results,
Cohortis updated with a new set of users and stored in the database.
CohortRuleis processed, the
Actiongoes through the second step of the Action Processor.
- The Action Processor uses the
Actionpayload to identify which
Cohortcontains the specific account, and which
- It loads the
SelectedTriggerof the filtered
Campaign, and use their
BehaviorTriggerimplementation to process the
SelectedTriggercan ignore the Action, accept it and update the internal state of the
SelectedTriggerInstanceassociated with the account/user, or trigger and create a
Rewardfor the user.
- If a
Rewardis created and is a
PointsReward, the points accrued will be added to the account balance by creating one
PointsBalanceEntrylinked to the account and the user.
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 of
RedemptionOffers 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 the
RedemptionClass 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!