DEV Community

Mike Rispoli
Mike Rispoli

Posted on

Building an Email Referral Rewards Program API in Rails

One of the most effective means of growing an audience is through email referrals. The rise of newsletters like Morning Brew and companies like Harry’s showcase that offering a tiered rewards structure for referring friends can grow a list to astronomical numbers. That is, provided your newsletter or product is actually something people want. The following describes how you can build a simple version of this program yourself using Ruby on Rails API.

Program Requirements

  1. After a user is subscribed through mailchimp they are able to refer their friends to the program.
  2. Subscribers can track their confirmed referrals and see their progress toward the referral count goal.
  3. We will start with a single tier of five referrals to receive a prize.
  4. Referral emails need to be validated and not existing subscribers.
  5. Subscribers will not be required to create any sort of account with a username and password to participate since this can be a barrier to entry.

Technical Architecture and User Flow

At a high level, subscribers will be added to the database when a subscriber is successfully subscribed to our mailchimp list. This allows us to check these against our existing subscribers as well as control sends through our unsubscribe list as well.

Now the call to mailchimp could be done inside our Rails API controller or a model hook, or it could live on our web server and we can make the call to our referral program API in a promise when the mailchimp subscription succeeds. I opted for the latter approach for two reasons:

  1. Keeping external dependencies to mailchimp out of the API itself would allow us to more easily change email service providers should that happen in the future.
  2. A failure in our referral program API server would not cause users to stop being able to subscribe to the mailing list. While this is a slim case it does give us an added layer of redundancy by keeping these decoupled and is easy to recover from should this ever happen.

Upon signing up, subscribers are issued a user_key and share_key. The user_key is a unique key where a subscriber can check their progress toward a reward and find their unique share link that will allow them to receive attribution for referrals. The share_key is a unique key that gets appended to a subscribers’ referral url that they will share with friends.

When the share_key of another user is seen in a new subscription that will queue up a validation email. When the link in that validation email is clicked (ensuring it is a real email address) that referral will get attributed to the referee.

When a subscribers referral count reaches five, they are sent an email letting them know they have won a reward and to add their address to have it sent to them. When that subscriber adds their address this triggers an alert to the team to fulfill their reward.

When the reward is fulfilled that user is notified via email that their prize has shipped.

Now for those of you thinking, but what if they have two email addresses?You could just make five unique email addresses to get the prize! This is where business sense has to step in and say, if you are trying to give away a Ferrari, this is not the program for you. If you want a coffee mug or hoodie so bad that you will go through the process of creating five email addresses and referring them, then that is a risk we were willing to accept and reward. In general it’s hard enough to get people to open your newsletters, no less go through all the hoops of gaming the system.

Getting Started

Without further ado let’s get going…

First spin up a rails app in API only mode.

rails new referral_program_api --api

To learn more about why I like using rails for this type of thing check out their documentation: Using Rails for API-only Applications — Ruby on Rails Guides.

And that’s it we now have our API ready to go.

Defining The Model

For this we only need to models, one for Subscribers the other for Referrals.

The Rules

  1. Each referral can belong to only one subscriber.
  2. Every email address can only subscribe once.
  3. Every email address can only be a referral once.
  4. Every subscriber and referral must have an email address.

Let’s start by creating the subscribers. We can do so with our rails generator commands.

rails g model subscriber

You can also add all of the attributes to this command as well, however I find it a bit easier to add them to the migration file afterwards, but that’s just a personal preference. So head on over to your /db/migrate/<timestamp>_create_subscribers.rb file and create the following attributes.

class CreateSubscribers < ActiveRecord::Migration[5.2]
  def change
    create_table :subscribers do |t|
      t.string :email
      t.string :first_name
      t.string :last_name
      t.string :share_key
      t.string :user_key
      t.string :street_address_one
      t.string :street_address_two
      t.string :city
      t.string :state 
      t.string :zip
      t.boolean :prize_sent
      t.boolean :sent_to_fulfillment
        t.references :referrer, index: true
      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we can hop over to our /app/models/subscriber.rb file where we can setup some rules and callbacks to generate our unique keys for subscribers.

class Subscriber < ApplicationRecord
  before_save { email.downcase! }
  has_many :referrals, class_name: "Subscriber", 
                          foreign_key: "referrer_id"
  validates :first_name, length: { maximum: 50 }
  validates :last_name, length: { maximum: 50 }
  validates :email, presence: true, length: { maximum: 50 }, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }, uniqueness: {case_sensitive: false}

  # Assign a share key
  before_create do |subscriber|
    subscriber.share_key = generate_unique_key('share_key')
    subscriber.user_key = generate_unique_key('user_key')
    subscriber.prize_sent = false
    subscriber.sent_to_airtable = false
  end

  private

  def generate_unique_key(field_name)
    loop do
      key = SecureRandom.urlsafe_base64(9).gsub(/-|_/,('a'..'z').to_a[rand(26)])
      break key unless Subscriber.exists?("#{field_name}": key)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The first thing we are doing here is converting all email addresses to lowercase and then setting up our association to the referrals model with the line has_many :referrals.... This is called a self join and will allow us to associate our referrals to a subscriber all in one table.

We then create a few simple validations for first_name, last_name, and email_address .

We then use the before_create callback to assign a unique share_key and user_key to the subscriber. These are generated using a private function that makes sure that each of these keys are unique independently. The likelihood that two keys are the same using the SecureRandom.urlsafe_base64(9) function is highly unlikely, but as a safeguard we do this in a loop and in the case that the key is already used we generate another.

Now that we have a solid subscriber model we can go ahead and create our controller.

Creating Subscriber Controller

Now that we have the proper model in place we can setup our endpoints and controller.

Run the command:

rails g controller subscriber

Now head over to /app/controllers/subscriber_controller.rb and we can create actions for show, create, confirm, and update.

| Action | Description |
|————|——————|
| Show | For fetching the subscriber information along with associated referrals. |
| Create | For creating a new subscriber, can take in a referrer_key parameter, which triggers a confirmation email to the subscriber to confirm the email is truly valid. |
| Confirm | For confirming an email address and giving credit to the referrer via referrer_id field. |
| Update | For updating the subscriber, used for when prize is won for entering address, triggering notifications to the fulfillment team. |

We are not going to be using a delete action here. In general we flag users as unsubscribed in email but we like to never forget them. We don’t have to create any unsubscribe flags in this program since this is still going to be managed by mailchimp and all subscribes and referrals will be passing through their management system before ever hitting our API.

class SubscriberController < ApplicationController

  def show

  end

  def create    

  end

  def confirm

  end

  def update

  end

  private

  def subscriber_params
    params
      .require(:subscriber)
      .permit(
        :first_name,
        :last_name,
        :email,
        :street_address_one,
        :street_address_two,
        :city,
        :state,
        :zip,
        :referrer_key
      )
  end
end
Enter fullscreen mode Exit fullscreen mode

We want to be sure to only allow the parameters we have set forth at this stage. You’ll notice that we are not allowing parameters that are not going to be updatable by the user and we have included one additional parameter of referrer_key, which will have the share_key of the referring subscriber if this is a referral.

It’s important to remember that all of our subscribers, whether they are referred to us or not need to pass through the referral program to be properly assigned a user_key and share_key.

Our Private Functions

Here we’re going to be adding a few more private function to our controller in order to set the subscriber and referrer prior to our actions. I’m using ... for brevity here, this builds upon the boilerplate laid out above.

class SubscriberController < ApplicationController
    before_action :set_subscriber, only: [:show, :update, :confirm]
  before_action :set_referrer, only: [:create, :confirm]

  ...

  private

    def set_subscriber
    @subscriber = Subscriber.find_by_user_key(params[:id])
  end

  def set_referrer
    @referrer = Subscriber.find_by_share_key(params[:referrer_key])
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

These functions set our subscriber and referrer. We are also adding some before_action hooks here to set these instance variables in the functions we need them in.

Defining Routes

Now that we have our base actions setup we can head over to /config/routes.rb and define our routes. I like to setup the actions as follows:

Rails.application.routes.draw do
  get '/subscriber/:id' => 'subscriber#show'
  post '/confirm-subscriber/:id' => 'subscriber#confirm'
  post '/subscriber' => 'subscriber#create'
  put '/subscriber/:id' => 'subscriber#update'
end
Enter fullscreen mode Exit fullscreen mode

The :id field in this case is going to be our user_key so as users can only be looked up by their unique key that will only be sent to their personal emails. Again, we are going to take privacy seriously here and even if a user key was somehow compromised the only thing that we are going to return is the users’ email address, user key, and share key. Sensitive information like address and name are going to be used only or our internal administrators and do not need to be accessible at any endpoint.

Completing Our Actions

Now let’s fill in our actions.

class SubscriberController < ApplicationController

...

def show
    render json: @subscriber, :only => ['email', 'user_key', 'share_key', 'sent_to_airtable', 'prize_sent'], :include => { :referrals => { :only => :email } }
  end

  def create    
    @subscriber = Subscriber.new(subscriber_params)

    if @subscriber.save
      if @referrer
        # Send email here to confirm the referral email
      end

      render json: @subscriber, status: :created, location: @subscriber, :only => ['email', 'user_key', 'share_key']
    else
      render json: @subscriber.errors, status: :unprocessable_entity
    end
  end

  def confirm
    if @subscriber && @referrer 
      @subscriber.update(
        referrer_id: @referrer.id
      )

      if get_referral_count(@referrer.id) == 5
        # send reward notification
      end

      render json: @subscriber, status: :created, location: @subscriber, :only => ['email', 'user_key', 'share_key']
    else
      render json: { message: 'Bad user key or share key.' }, status: :unprocessable_entity
    end
  end

  def update
    if !@subscriber
      create
    elsif @subscriber.update(subscriber_params)
      if get_referral_count(@subscriber.id) >= 5 && has_complete_address?
        # Sent to fulfillment service
      end

      render json: @subscriber, status: :created, location: @subscriber, :only => ['email', 'user_key', 'share_key']
    else
      render json: @subscriber.errors, status: :unprocessable_entity
    end
  end

    private

    ...

    def get_referral_count(id)
    Subscriber.where(referrer_id: id).count
  end

  def has_complete_address?
    @subscriber.street_address_one != nil && @subscriber.city != nil && @subscriber.state != nil && @subscriber.zip != nil
  end

    ...

end
Enter fullscreen mode Exit fullscreen mode

Here, we’ve filled in our controllers except for three key notification points.

  1. Sending the confirmation email to validate the email address is real.
  2. Sending the notification that a subscriber has achieved five referrals.
  3. Sending a notification to the fulfillment team that a subscriber has achieved a reward and submitted their address.

To complete these we will need to setup some mailers in a later section.

We also add two more private functions here to get_referral_count of a referee, and has_complete_address? to see if a subscriber has entered a complete address.

At this point we have to setup our connections with mailchimp in two places.

  1. To send a subscribers’ user_key and share_key back to mailchimp as merge_vars.
  2. To send notifications emails via mandrill using ActionMailer.

If you do not want to use mailchimp or gibbon you may skip the next section and integrate whichever email service provider and transactional emails service you are comfortable with.

Integrating Mailchimp and Mandrill

Gibbon Setup

I find Gibbon to be the best gem for interacting with mailchimp via a rails application. We’ll also be using the figaro gem to allow for environment variables so we don’t commit our mailchimp api keys to source code.

Add the following to your Gemfile.

# use for environment variables
gem "figaro"
# mailchimp
gem "gibbon", '~>2.2.4'
Enter fullscreen mode Exit fullscreen mode

Then run bundle install.

Create an application.yml file in your /config folder.

touch ./config/application.yml

Then head over to Mailchimp and get your API key and list id. Be advised these are in two different locations inside of Mailchimp I also like to setup a variable used to signal that we are in the development or staging environment for testing purposes.

MAILCHIMP_API_KEY: '<your_mailchimp_api_key>'
MAILCHIMP_LIST_ID: '<your_mailchimp_list_id>'
MAILCHIMP_ENV: 'development'
Enter fullscreen mode Exit fullscreen mode

Initialize Gibbon

Create a gibbon.rb file inside ./config/initializers.

touch ./config/initializers/gibbon.rb

Inside this file you will setup the connection to Mailchimp via the Gibbon gem when the rails server starts. Hence, being in the initializers folder.

Gibbon::Request.api_key = ENV["MAILCHIMP_API_KEY"]
Gibbon::Request.timeout = 15
Gibbon::Request.throws_exceptions = false unless ENV["MAILCHIMP_ENV"] == "development"

if ENV["CONTENTFUL_ENV"] == "development"
    puts "MailChimp API key: #{Gibbon::Request.api_key}"
end
Enter fullscreen mode Exit fullscreen mode

At this point you can fire up your rails server and you should see the above line printed to the console letting you know our connection to Mailchimp is up and running.

Adding to the Model

We can now add Gibbon to our subscriber model callbacks in order to send the share_key and user_key back to mailchimp. But first, we have to allow for these in our subscriber list.

If you go to your mailchimp audiences tab and select the list we used for the list id above you can click settings and select Audience fields and |MERGE| tags. Here you will be able to add two new fields with the labels Share Key and User Key and the tag names SHAREKEY and USERKEY to be used inside templates.

We can now use gibbon to send over the proper values in the after_save callback.

...

after_save do |subscriber|
    unless Rails.env.test?
      begin
        gibbon = Gibbon::Request.new

    gibbon.lists(ENV['MAILCHIMP_LIST_ID']).members(Digest::MD5.hexdigest(subscriber.email))
          .update(body: {
            merge_fields: {
              SHAREKEY: subscriber.share_key, 
              USERKEY: subscriber.user_key
            }
          })
      rescue Gibbon::MailChimpError => e  
            # This is a good place to add your error alerting but is beyond the scope of this
        puts "Houston, we have a problem: #{e.raw_body}"
      end
    end
  end

...

Enter fullscreen mode Exit fullscreen mode

The key thing here is that if our connection to mailchimp fails for whatever reason, we are still subscribing the user to be corrected later. Inside of the rescue statement a pro tip would be to put your preferred error alerting system to alert you of an issue sending the keys over to mailchimp without affecting the user experience.

Mandrill Setup

Next we can setup mandrill, mailchimp’s transactional email service. I’m not going to go into how to set this up on the mailchimp side in this tutorial but mailchimp has great help docs and it takes about five minutes to connect mandrill to you mailchimp account.

Inside you rails app you are going to add the mandrill-api package to your Gemfile.

#mandrill
gem mandrill-api
Enter fullscreen mode Exit fullscreen mode

Run bundle install.

Pull up your /config/application.yml file again and add the following environment variables.

SMTP_ADDRESS: 'smtp.mandrillapp.com'
SMTP_DOMAIN: 'localhost'
SMTP_PASSWORD: '<your_mandrill_smtp_password>'
SMTP_USERNAME: '<your_mandrill_username>'
Enter fullscreen mode Exit fullscreen mode

The password and username can be obtained inside of mandrill for use in this file.

Next we will create our base_mandrill_mailer that will setup all of our default values for our notification emails to inherit.

Create our base_mandrill_mailer.rb file:

touch ./app/mailers/base_mandrill_mailer.rb

And add the following:

require "mandrill"

class BaseMandrillMailer < ActionMailer::Base
  default(
    from: "<your_from_email_address>",
    reply_to: "<your_reply_to_email_address>"
  )

  private

    def send_mail(email, subject, body)
      mail(to: email, subject: subject, body: body, content_type: "text/html")
    end

    def mandrill_template(template_name, attributes)
      mandrill = Mandrill::API.new(ENV["SMTP_PASSWORD"])

      merge_vars = attributes.map do |key, value|
        { name: key, content: value }
      end

      mandrill.templates.render(template_name, [], merge_vars)["html"]
    end
end
Enter fullscreen mode Exit fullscreen mode

Be sure to fill in your from and reply to email values above.

Now we can create two mailers, one for confirmation emails the other for rewards notifications.

touch ./mailers/confirmation_mailer.rb ./mailers/reward_mailer.rb
Enter fullscreen mode Exit fullscreen mode
class ConfirmationMailer < BaseMandrillMailer
  def confirm_email(subscriber, referrer_key)
    subject = "Confirm Your Email"
    merge_vars = {
      "USERKEY" => subscriber.user_key,
      "REFERRERKEY" => referrer_key,
      "EMAIL" => subscriber.email
    }
    body = mandrill_template("Confirm Email", merge_vars)

    send_mail(subscriber.email, subject, body)
  end
end
Enter fullscreen mode Exit fullscreen mode
class RewardMailer < BaseMandrillMailer
  def reward_achieved(subscriber)
    subject = "Your Gift Awaits!"
    merge_vars = {
      "USERKEY" => subscriber.user_key,
      "EMAIL" => subscriber.email
    }
    body = mandrill_template("Reward Achieved", merge_vars)

    send_mail(subscriber.email, subject, body)
  end

  def reward_sent(subscriber)
    subject = "Your Gift Is On The Way"
    merge_vars = {
      "EMAIL" => subscriber.email
    }
    body = mandrill_template("Reward Sent", merge_vars)

    send_mail(subscriber.email, subject, body)
  end

  def notify_fulfillment_team(subscriber)
    subject = "Someone just earned themselves a free tote!"
    merge_vars = {
      "EMAIL" => subscriber.email
    }
    body = mandrill_template("Notify Fulfillment Team", merge_vars)

    send_mail('<the_email_of_whoever_fulfills_rewards>', subject, body)
  end
end
Enter fullscreen mode Exit fullscreen mode

You’ll notice that these mailers reference some templates that are not in mandrill yet. You are correct. What we have to do next is go over to mailchimp and create four templates inside their editor named the following:

  1. Confirm Email
  2. Reward Achieved
  3. Reward Sent
  4. Notify Fulfillment Team

I like to keep them simple and inside each of these templates you need to use your merge_vars to create links to your front end that will pass the proper keys to your application.

We’ll just be focusing on the API layer since use of merge vars and how this interacts with the front end of the site can vary based upon configurations.

At last we can add our email notifications to our controller actions.

Adding Mail Notifications

Now we can head back into our subscriber controller and add the following notifications.

class SubscriberController < ApplicationController

...

  def create   

     ...

      if @referrer
        # Send email here to confirm the referral email
            ConfirmationMailer.confirm_email(@subscriber, @referrer.share_key).deliver_now
      end

     ...

  end

  def confirm

     ...

      if get_referral_count(@referrer.id) == 5
        # send reward notification
            RewardMailer.reward_achieved(@referrer).deliver_now
      end

     ...

  end

  def update

    ...

      if get_referral_count(@subscriber.id) >= 5 && has_complete_address?
        # Sent to fulfillment service
        @subscriber.update(sent_to_fulfillment: true)
        RewardMailer.notify_fulfillment_team(self).deliver_now

      end

    ...

  end

    ...

end
Enter fullscreen mode Exit fullscreen mode

We now have all that we need to fire up the program. There’s just one mailer that we created that we did not use. That’s the one notifying the user that their order has shipped. For brevity I decided to leave out how you want to manage that since that can vary by what you want to do.

What I decided to do is send the orders over to Airtable and have a field that marks them as pending. I then run a cron job that checks the table each day and if fulfillments are marked as fulfilled since that last run we send an email letting the subscriber know their reward has shipped.

Authentication

This is the final part of our api. While we don’t want users to have to log in to view their progress and to get their share links, we don’t just want these endpoints accessible to the public.

What we are going to do here is just integrate basic token based authentication with our API, using a key we generate and store in our application.yml file. On our server this is going to be a part of our configuration file that should not be shared with anyone. When we implemented the figaro gem before this application.yml file should be a part of our .gitignore and never checked into source control.

First let’s jump over to our /app/controllers/application_controller.rb file and add the following.

class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate

  protected

  def authenticate
    authenticate_token || render_unauthorized
  end

  def authenticate_token
    authenticate_with_http_token do |token, options|
      token == ENV['SECRET_KEY_BASE']
    end
  end

  def render_unauthorized(realm = "Application")
    self.headers["WWW-Authenticate"] = %(Token realm="#{realm}")
    render json: 'Bad credentials', status: :unauthorized
  end
end

Enter fullscreen mode Exit fullscreen mode

Here we are using Action Controller’s built in http token authentication method. We setup a before_action hook that ensures that any requested resource in any controller passes through our authenticate method.

We then create a set of protected methods, which are similar to private methods except these are accessible by any subclass of our main ApplicationController class.

In our authenticate_token method we just do a simple comparison to check if the token is equal to the token in our environment variables.

Generating the Token

To generate our token I like to just use a variant of our generate_token function in our controller in the rails console.

Open a new terminal, navigate to your rails project, and run the command rails c

Enter the following in the rails console:

SecureRandom.urlsafe_base64(32).gsub(/-|_/,('a'..'z').to_a[rand(26)])
Enter fullscreen mode Exit fullscreen mode

Anytime you run this you will end up with a string that looks something like this:

c3OcKh1J8a8ich9bQC0TNsdP7LdCqcWL57cJKoch4a0

Take the unique key you have generated and add it to your application.yml file as SECRET_KEY_BASE like so:

SECRET_KEY_BASE: 'c3OcKh1J8a8ich9bQC0TNsdP7LdCqcWL57cJKoch4a0'
Enter fullscreen mode Exit fullscreen mode

Now if you try to access a resource you should get an unauthorized response.

In order to get a valid response again you need to pass the header Authorization: “Token token= c3OcKh1J8a8ich9bQC0TNsdP7LdCqcWL57cJKoch4a0”

Make sure that you are always doing this over https so that this is encrypted. When you build out your front end you must ensure that this key is NOT exposed on the client nor checked into version control like github. If you are using a node based front end, make sure to use a package like DOTENV which gives you similar functionality to figaro for ruby. You can then create a route in your node server to handle these responses and keeps this key hidden from your front end code.

Conclusion

And that’s all folks. Our API is complete and you can deploy this to heroku or wherever you like to deploy your rails apps to. This is just an API and you can design the front end however you like. As always appreciate any feedback on the approach. Happy coding.

Top comments (0)