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 Action
s 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, Action
s 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 Action
s happened. Therefore, we decided to introduce a new concept: Behavior
s. 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 Behavior
s, which are affected by incoming Action
s. Those Behavior
s 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 Action
s 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 flexibleCohort
s 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 DynamicCohort
s. 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 StaticCohort
s by hand, which was far from ideal.
After a couple of different designs, we decided to leverage the Action Processor
mechanism to process incoming Action
s and, based on CohortRule
s, determine whether the account should be added to a DynamicCohort
, removed from it or simply ignore the Action
for cohort attribution purposes.
A 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).
CohortRule
s are implementations of the following CohortRule
protocol:
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 │ └────────┘
- A
System
(likely external) performs some operations that emits anEvent
(e.g. Transaction Created , User Created). - It is converted into a Rewards
Action
by a conversion layer, and then sent toRewards
. - The
Rewards
System receives theAction
and starts processing it. - The first processing done is through
CohortRule
. Each currently activeCohortRule
is loaded and processes theAction
. - Based on the
Action
and the previous states,CohortRule
can add or remove users fromDynamicCohort
, or ignore the action. Based on the results,Cohort
is updated with a new set of users and stored in the database. - Once
CohortRule
is processed, theAction
goes through the second step of the Action Processor. - The Action Processor uses the
customer_account_id
on theAction
payload to identify whichCohort
contains the specific account, and whichCampaign
targets theCohort
. - It loads the
SelectedTrigger
of the filteredCampaign
, and use theirBehaviorTrigger
implementation to process theAction
. - The
SelectedTrigger
can ignore the Action, accept it and update the internal state of theSelectedTriggerInstance
associated with the account/user, or trigger and create aReward
for the user. - If a
Reward
is created and is aPointsReward
, the points accrued will be added to the account balance by creating onePointsBalanceEntry
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 ofRedemptionOffer
s 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 RedemptionClass
es 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!