We recently finished working on a point of sale system that incorporates Apple Pay and Passbook. The front-end is an iOS app written in Swift, and the back-end leverages Spree Commerce, an open source e-commerce Ruby on Rails framework. We’ll take a look at how we went about integrating Passbook into the system, from front to back.

Setup

This blog expects that you are already familiar with Passbook. If not there are some good sources to get you started.

Install Spree, following the installation guide on the Spree page. Then, add the Passbook gem to your Spree instance’s Gemfile. 

gem 'passbook'

Next we install the gem

bundle install

After the gem is installed we generate the passbook initializer

rails g passbook:config

Next we add our passbook certs to config/initializers/passbook.rb

require 'passbook'

Passbook.configure do |passbook|

  # Path to your wwdc cert file
  passbook.wwdc_cert = Rails.root.join('lib/assets/wwdrca.cer')

  # Path to your cert.p12 file
  passbook.p12_certificate = Rails.root.join('lib/assets/storecard.p12')

  # Password for your certificate
  passbook.p12_password = ''
end

Models

For our needs, we generated our models with the following attributes and relationships

I chose to only persist necessary pass identifiers to the PaymentPassbook model, the other attributes were stored in a JSON file i’ll show later. The relationship between the PaymentPassbook and SpreeProduct models exists for scenarios where a pass is linked to a product, like an event ticket.

API Controllers

Spree allows you to extend it’s internal classes and add your own business logic. We took advantage of this by extending the Spree API resource and adding REST endpoints for managing passbooks.

First we have to modify our routes (in config/initializers/passbook.rb)

Spree::Core::Engine.routes.append do
  get 'users/:user_id/passbooks' => 'passbooks#index'
  get 'users/:user_id/passbooks/:passbook_id/download' => 'passbooks#download'
  resources :passbooks do
    get 'download'
  end
end

After creating the routes, we add the API controller (in app/controllers/spree/api/passbooks_controller.rb)

module Spree
  module Api
    class PassbooksController < Spree::Api::BaseController
      def index
        if params[:user_id]
          @passbooks = PaymentPassbook.where(user_id: params[:user_id])
        else
          @passbooks = PaymentPassbook.ransack(params[:q]).result.page(params[:page]).per(params[:per_page])
        end
        respond_with(@passbooks)
      end

      def show
        @passbook = PaymentPassbook.find(params[:id])
        respond_with(@passbook)
      end

      def create
        @passbook = PaymentPassbook.new(passbook_params)
        @passbook.serial_number = SecureRandom.urlsafe_base64(9)
        @passbook.barcode_message = SecureRandom.urlsafe_base64(9)
        @passbook.authentication_token = Digest::SHA256.hexdigest "#{params[:passbook][:user_id]}"
        @passbook.save!

        respond_with(@passbook, :status => 201, :default_template => :show)
      end

      def update
        @passbook = PaymentPassbook.find(params[:id])
        if @passbook.update_attributes(passbook_params)
          render :show
        else
          invalid_resource!(@passbook)
        end
      end

      def download
        passbook_id = params[:id] || params[:passbook_id]
        passbook = PaymentPassbook.find(passbook_id)
        pass = passbook.build
        pkpass = pass.file
        send_file pkpass.path, type: 'application/vnd.apple.pkpass', disposition: 'attachment', filename: "pass.pkpass"
      end

      private

      def passbook_params
        params.require(:passbook).permit([:pass_type_identifier, :product_id, :user_id, :redeemed])
      end
    end
  end
end

It is important to note that when a create is called, the passbook’s serial number and barcode message are set to a randomly generated unique identifier. Also, the authentication token is a hash of the user id, as this value needs to be unique per user.

On download, we build the pass, using the passbook gem and a JSON file as a template

(in app/models/payment_passbook.rb)

def build
	signer = Passbook::Signer.new
	if self.pass_type_identifier == "pass.com.makeandbuild.event"
		signer = Passbook::Signer.new certificate:  Rails.root.join('lib/assets/eventTicket.p12')
	end
	pass = Passbook::PKPass.new self.as_json, signer
	pass.addFiles [Rails.root.join('lib/assets/logo.png'), Rails.root.join('lib/assets/logo@2x.png'), Rails.root.join('lib/assets/icon.png'), Rails.root.join('lib/assets/icon@2x.png')]
	pass
end

(in lib/assets/eventTicket.json)

{
  "authenticationToken": "",
  "backgroundColor": "rgb(60, 65, 76)",
  "barcode": {
      "format": "PKBarcodeFormatPDF417",
      "message": "",
      "messageEncoding": "iso-8859-1"
  },
  "description": "",
  "foregroundColor": "rgb(255, 255, 255)",
  "formatVersion": 1,
  "relevantDate" : "",
  "locations": [
      {
          "latitude": 33.7488889,
          "longitude": -84.3880556
      }
  ],
  "organizationName": "Make and Build",
  "passTypeIdentifier": "",
  "serialNumber": "",
  "eventTicket": {
      "backFields": [
          {
              "key": "terms",
              "label": "TERMS AND CONDITIONS",
              "value": "Make & Build Event Ticket"
          }
      ],
      "primaryFields": [
          {
              "key": "event",
              "label": "EVENT",
              "value": ""
          }
      ],
      "secondaryFields": [
          {
              "key": "loc",
              "label": "LOCATION",
              "value": ""
          }
      ]
  },
  "teamIdentifier": "ABCD1EF2G3",
  "webServiceURL": "http://localhost:3000"
}

Be sure to set the Pass Identifier and Team Identifier to what you have setup in your Apple Developers Profile.

API Views

Spree leverages RABL to generate JSON response payloads on API calls. We added the some RABL views of our own for passbook:

(in app/views/spree/api/passbooks/index.v1.rabl)

collection @passbooks

extends "spree/api/passbooks/show"

(in app/views/spree/api/passbooks/show.v1.rabl)

object @passbook

attributes :id, :pass_type_identifier, :serial_number, :barcode_message, :user_id, :redeemed

child :product => :product do
  attributes :id, :name, :description, :price, :available_on
  child :product_properties => :product_properties do
    attributes *product_property_attributes
  end
  child(:images => :images) { extends "spree/api/images/show" }
end

To return the passbooks for a specific user we can make the following call:

curl -H "X-Spree-Token: d14f50bbfed59fdb4a6b0d44932656b2536e17f637cd0d62" -H "Content-Type: application/json" -H 'Accept: application/json' -X GET http://localhost/api/users/3/passbooks

The RABL generated response payload is:

[
  {
    id: 4,
    pass_type_identifier: "pass.com.makeandbuild.event",
    serial_number: "JeqURGbRoHc8",
    barcode_message: "eV4rlb8-Jsd3",
    user_id: 3,
    redeemed: false,
    product: null
  }
]

Showing Pass on iOS App

With the API calls in place, now we turn to adding and displaying the passes in our iOS application. To do this we add a button in the View Controller in iOS. After that we add the following code and attach the button to IBAction addPassbookPass.

var accountPass : NSData?

    @IBAction func addPassbookPass(sender: AnyObject) {
        //Checks if Passbook is available on the device.
        if (passbookAvailable())
        {
            //Creates PkPass based on what you got from server
            var pass = PKPass(data: self.accountPass, error: nil)
            
            //Checks if the pass has already been added to the users Passbook. If it is not then it adds it.
            if (!PKPassLibrary().containsPass(pass))
            {
                if (pass != nil){
                    var viewTmp = PKAddPassesViewController(pass: pass)
		                // Presents View controller that allows user to view pass and add it to passbook
                    self.presentViewController(viewTmp, animated: true, completion: nil)
                }
                
            }
                //If the pass is already in the users passbook it asks the user if they wish to remove the pass.
            else
            {
                var alert = UIAlertController(title: "Passbook", message: "This pass already in your Passbook.", preferredStyle: UIAlertControllerStyle.Alert)
                alert.addAction(UIAlertAction(title: "Ok", style: UIAlertActionStyle.Default, handler:  nil))
                alert.addAction(UIAlertAction(title: "Remove Pass", style: UIAlertActionStyle.Default, handler:  { action in
                    PKPassLibrary().removePass(pass)
                    
                    var alert = UIAlertController(title: "Passbook", message: "Pass has been removed.", preferredStyle: UIAlertControllerStyle.Alert)
                    alert.addAction(UIAlertAction(title: "Ok", style: UIAlertActionStyle.Default, handler:  nil))
                    self.presentViewController(alert, animated: true, completion: nil)
                }))
                self.presentViewController(alert, animated: true, completion: nil)
            }
        }
    }
    
    func passbookAvailable()->Bool
    {
        //Checks if Passbook is available on the device, if not it displays a warning message.
        if (PKPassLibrary.isPassLibraryAvailable())
        {
            return true
        }
        else
        {
            var alert = UIAlertController(title: "Passbook", message: "Passbook is not available on this device.", preferredStyle: UIAlertControllerStyle.Alert)
            alert.addAction(UIAlertAction(title: "Ok", style: UIAlertActionStyle.Default, handler:  nil))
            self.presentViewController(alert, animated: true, completion: nil)
            
            return false
        }
    }
    
    @IBAction func isPassAlreadyInPassbook()
    {
        //Checks if Passbook is available on the device.
        if (passbookAvailable())
        {
            //Loads a demo pass saved in the applications bundle.
            var alertString = "Pass not found in your Passbook."
            var path = NSBundle.mainBundle().pathForResource("pass900452108", ofType: "pkpass")
            var data = NSData(contentsOfFile: path!)
            var pass = PKPass(data: data, error:nil)
            
            //Checks if the pass has already been added to the users Passbook. If it has it changes the message to appear.
            if (PKPassLibrary().containsPass(pass))
            {
                alertString = "You have already added this pass."
            }
            var alert = UIAlertController(title: "Passbook", message: alertString, preferredStyle: UIAlertControllerStyle.Alert)
            alert.addAction(UIAlertAction(title: "Ok", style: UIAlertActionStyle.Cancel, handler:  { action in
                self.alertString(action)
            }))
            self.presentViewController(alert, animated: true, completion: nil)
        }
    }

Next we'll have to wire up the necessary REST call to retrieve the pass. We've covered how to do this in Swift here. Once the pass has been retrieve we set it to self.accountPass.

Push Notifications

To integrate Apple Push Notification Service, we used Grocer gem. Below is the method called to send push notifications to registered passbook_devices:

(in app/models/payment_passbook.rb)

def push_update
	pusher = Grocer.pusher(
		certificate: Rails.root.join('lib/assets/push-notification.pem'),      # required
		# passphrase:  "",                       # optional
		gateway:     "gateway.push.apple.com", # optional; See note below.
		port:        2195,                     # optional
		retries:     3                         # optional
	)

	self.update(updated_at: Time.now)
	passbook_devices = self.passbook_devices
	passbook_devices.each do |passbook_device|
		notification = Grocer::PassbookNotification.new(device_token: passbook_device.push_token)
		pusher.push(notification)
	end
end

We also need to enable the push notification endpoint:

(in config/application.rb)

config.middleware.use Rack::PassbookRack

It's important we make sure that the webserviceURL field in our pass (see JSON example from earlier) is pointing to our server.

Now we have to configure the passbook notification end points by editing lib/passbook/passbook_notification.rb. Whenever a new pass is saved to a users passbook, or when notifications are re-enabled, the following registration endpoint is called:

def self.register_pass(options)
  # this is if the pass registered successfully
  # change the code to 200 if the pass has already been registered
  # 404 if pass not found for serialNubmer and passTypeIdentifier
  # 401 if authorization failed
  # or another appropriate code if something went wrong.

  payment_passbook = PaymentPassbook.find_by(pass_type_identifier: options["passTypeIdentifier"], serial_number: options['serialNumber'])

  if payment_passbook
    if payment_passbook.authentication_token == options['authToken']
      passbook_device = PassbookDevice.find_or_initialize_by(device_library_identifier: options['deviceLibraryIdentifier'], push_token: options['pushToken'], user_id: payment_passbook.user_id)
      if passbook_device.payment_passbooks.find_by(id: payment_passbook.id)
        {:status => 200}
      else
        passbook_device.save! if passbook_device.new_record?
        PaymentPassbooksDevice.create(payment_passbook_id: payment_passbook.id, passbook_device_id: passbook_device.id)
        {:status => 201}
      end
    else
      {:status => 401}
    end
  else
    {:status => 404}
  end
end

When a device receives a push notification, the following endpoint is called:

def self.passes_for_device(options)
  # the 'lastUpdated' uses integers values to tell passbook if the pass is
  # more recent than the current one.  If you just set it is the same value
  # every time the pass will update and you will get a warning in the log files.
  # you can use the time in milliseconds,  a counter or any other numbering scheme.
  # you then also need to return an array of serial numbers.
  passbook_device = PassbookDevice.find_by(device_library_identifier: options['deviceLibraryIdentifier'])
  if passbook_device
    payment_passbooks = passbook_device.payment_passbooks
    serial_numbers = []
    payment_passbooks.each do |payment_passbook|

      if options['passesUpdatedSince'].blank? || payment_passbook.updated_at > options['passesUpdatedSince']
        serial_numbers << payment_passbook.serial_number
      end

    end
    puts "lastUpdated: #{options['passesUpdatedSince']}"
    puts "serialNumbers: #{serial_numbers}"
    {'lastUpdated' => Time.now, 'serialNumbers' => serial_numbers}
  else
    # This device is not currently registered with the service
    {:status => 404}
  end
end

After the above call returns the serial numbers for all passes needing an update, the device will call the following endpoint to update each pass:

def self.latest_pass(options)
  # create your PkPass the way you did when your first created the pass.
  # you will want to return
  payment_passbook = PaymentPassbook.find_by(pass_type_identifier: options["passTypeIdentifier"],
  serial_number: options['serialNumber'], authentication_token: options['authToken'])

  if payment_passbook
    pass = payment_passbook.build
    # you will want to return the string from the stream of your PkPass object.
    pass.stream.string
  else
    #Auth Token is incorrect
    {:status => 401}
  end
end

When a pass is deleted from a device, it calls the following endpoint to un-register:

def self.unregister_pass(options)
  payment_passbook = PaymentPassbook.find_by(pass_type_identifier: options["passTypeIdentifier"],
    serial_number: options['serialNumber'], authentication_token: options['authToken'])

  if payment_passbook
    passbook_device = PassbookDevice.find_by(device_library_identifier: options['deviceLibraryIdentifier'])
    if passbook_device
      passbook_device.payment_passbooks.delete(payment_passbook.id)
      # return a status 200 to indicate that the pass was successfully unregistered.
      {:status => 200}
    else
      #No Device
      {:status => 401}
    end
  else
    #Auth Token is incorrect
    {:status => 401}
  end
end

Conclusion

Passbook integration may seem like a daunting endeavor after reading this blog, but it was quite the opposite. The Passbook gem does quite a bit of the heavy lifting, especially if you are looking to support Apple push notifications. And if you are looking to venture into the e-commerce realm, you’d be hard pressed to find an easier ready-made solution than Spree.