Creating a Web Service for Google and Apple Wallet Passes with Node.js

A comprehensive guide to integrating digital wallet passes

Introduction

Digital wallet passes have become increasingly popular for loyalty programs, event tickets, and more, offering convenience for both users and businesses. After spending a couple months of development and research into creating a web service for Google and Apple Wallet using Node.js, I noticed a significant gap in available online resources on this topic. This article aims to bridge that gap by providing a comprehensive guide for developers interested in implementing similar services.

In this article, I will walk you through the process of creating and updating passes for Google Wallet. I also cover the process for Apple Wallet here.

Google Wallet Integration

Let's start with the Google integration, as it is relatively easier and faster. My code is based on the Node.js examples from the google-wallet repository, which I modified to suit my needs. You can find the repository here: https://github.com/google-wallet/rest-samples

Preparation

Before diving into the code, you need to prepare a few things. Ensure that you meet the requirements to create passes with Google. Follow the steps outlined in the Google Wallet prerequisites.

Once you meet the requirements, you can follow the Google guide or continue reading.

First, install the dependencies:

@googleapis/walletobjects
dotenv
jsonwebtoken

Creating Pass Classes and Objects

To create a pass in Google Wallet, you first need to create the Pass Class, which contains information about the pass creator, or pass issuer in Google terms. Once the Pass Class is created, you create the Pass Object, which is the instance of the pass.

Before creating the classes, I defined types to work better:

/**
 * @typedef {object} LoyaltyClass
 * @property {string} programName
 * @property {string} issuerName
 * @property {string} logoUri
 */

/**
 * @typedef {object} LoyaltyObject
 * @property {string} QrCodeLink
 * @property {string} accountId
 * @property {string} FullName
 * @property {int} [points]
 */

To work better, I created a class called LoyaltyPass, which handles everything related to passes:

// loyaltyPass.js
// LoyaltyPass
/*
 * Copyright 2022 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */



class LoyaltyPass {
  constructor() {
    /**
     * Path to service account key file from Google Cloud Console. Environment
     * variable: GOOGLE_APPLICATION_CREDENTIALS.
     */
    this.issuerId = process.env.WALLET_ISSUER_ID;
    this.keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS || '/path/to/key.json'; // You can choose if you want to use the keyFile
    this.credentials = { // Or the credentials
      type: process.env.TYPE,
      client_email: process.env.CLIENT_EMAIL,
      private_key: process.env.PRIVATE_KEY.replace(/\\n/g, '\n'),
      private_key_id: process.env.PRIVATE_KEY_ID,
      project_id: process.env.PROJECT_ID,
      client_id: process.env.CLIENT_ID,
      universe_domain: process.env.UNIVERSE_DOMAIN,
    }
    this.auth();
  }

  /**
   * Create authenticated HTTP client using a service account file.
   */
  auth() {
    const auth = new GoogleAuth({
      // Here instead of using credentials you can use `keyFile: this.keyFile`
      // I rather use credentials to prevent uploading sensitive data as the keyFile where I will be hosting my code
      credentials: this.credentials,
      scopes: ["https://www.googleapis.com/auth/wallet_object.issuer"],
    });

    this.client = walletobjects({
      version: "v1",
      auth: auth,
    });
  }
}

Next, create the Pass Class:

// loyaltyPass.js
// LoyaltyPass.createClass
/**
 * Create a class.
 *
 * @param {string} classSuffix Developer-defined unique ID for the pass class.
 * @param {LoyaltyClass} loyaltyClass The properties for the loyalty class to create.
 *
 * @returns {string} The pass class ID: `${issuerId}.${classSuffix}`
 */
async createClass(classSuffix, loyaltyClass) {
  let response;

  // Check if the class exists
  try {
    await this.client.loyaltyclass.get({
      resourceId: `${this.issuerId}.${classSuffix}`,
    });

    console.log(`Class ${this.issuerId}.${classSuffix} already exists!`);

    return `${this.issuerId}.${classSuffix}`;
  } catch (err) {
    if (err.response && err.response.status !== 404) {
      // Something else went wrong...
      console.log(err);
      return `${this.issuerId}.${classSuffix}`;
    }
  }

  // See link below for more information on required properties
  // https://developers.google.com/wallet/retail/loyalty-cards/rest/v1/loyaltyclass
  let newClass = {
    id: `${this.issuerId}.${classSuffix}`,
    issuerName: loyaltyClass.issuerName,
    reviewStatus: "UNDER_REVIEW",
    programName: loyaltyClass.programName,
    programLogo: {
      sourceUri: {
        uri: loyaltyClass.logoUri,
      },
    },
  };

  response = await this.client.loyaltyclass.insert({
    requestBody: newClass,
  });

  console.log("Class insert response status: ", response.status);

  return `${this.issuerId}.${classSuffix}`;
}

After creating the Pass Class we can create the Pass Object:

// loyaltyPass.js
// LoyaltyPass.createObject
/**
 * Create an object.
 *
 * @param {string} classSuffix Developer-defined unique ID for the pass class.
 * @param {string} objectSuffix Developer-defined unique ID for the pass object.
 * @param {LoyaltyObject} loyaltyObject The properties for the loyalty object to create.
 *
 * @returns {string} The pass object ID: `${issuerId}.${objectSuffix}`
 */
async createObject(classSuffix, objectSuffix, loyaltyObject) {
  let response;

  // Check if the object exists
  try {
    await this.client.loyaltyobject.get({
      resourceId: `${this.issuerId}.${objectSuffix}`
    });

    console.log(`Object ${this.issuerId}.${objectSuffix} already exists!`);

    return `${this.issuerId}.${objectSuffix}`;
  } catch (err) {
    if (err.response && err.response.status !== 404) {
      // Something else went wrong...
      console.log(err);
      return `${this.issuerId}.${objectSuffix}`;
    }
  }

  // See link below for more information on required properties
  // https://developers.google.com/wallet/retail/loyalty-cards/rest/v1/loyaltyobject
  let newObject = {
    'id': `${this.issuerId}.${objectSuffix}`,
    'classId': `${this.issuerId}.${classSuffix}`,
    'state': 'ACTIVE',
    'barcode': {
      'type': 'QR_CODE',
      'value': loyaltyObject.QrCodeLink
    },
    'accountId': loyaltyObject.accountId,
    'accountName': loyaltyObject.FullName,
    // Left Column
    'loyaltyPoints': {
      'label': 'Code',
      'balance': {
        'string': loyaltyObject.accountId
      }
    },
    // Right Column (Optional)
    'secondaryLoyaltyPoints': {
      'label': 'Puntos',
      'balance': {
        'int': 0
      }
    },
  };

  response = await this.client.loyaltyobject.insert({
    requestBody: newObject
  });

  console.log('Object insert response status: ', response.status);

  return `${this.issuerId}.${objectSuffix}`;
}

Updating Pass Classes and Objects

Now that we created our pass, we can update the class or the object:

// loyaltyPass.js
// LoyaltyPass.patchClass
/**
 * Patch a class.
 *
 * The PATCH method supports patch semantics.
 *
 * @param {string} classSuffix Developer-defined unique ID for this pass class.
 * @param {LoyaltyClass} loyaltyClass The properties for the loyalty class to patch.
 *
 * @returns {string} The pass class ID: `${issuerId}.${classSuffix}`
 */
async patchClass(classSuffix, loyaltyClass) {
  let response;

  // Check if the class exists
  try {
    response = await this.client.loyaltyclass.get({
      resourceId: `${this.issuerId}.${classSuffix}`,
    });
  } catch (err) {
    if (err.response && err.response.status === 404) {
      console.log(`Class ${this.issuerId}.${classSuffix} not found!`);
      return `${this.issuerId}.${classSuffix}`;
    } else {
      // Something else went wrong...
      console.log(err);
      return `${this.issuerId}.${classSuffix}`;
    }
  }

  // Patch the class
  let patchBody = {
    id: `${this.issuerId}.${classSuffix}`,
    issuerName: loyaltyClass.issuerName,
    programName: loyaltyClass.programName,
    programLogo: {
      sourceUri: {
        uri: loyaltyClass.logoUri,
      },
    },
    // Note: reviewStatus must be 'UNDER_REVIEW' or 'DRAFT' for updates
    reviewStatus: "UNDER_REVIEW",
  };

  response = await this.client.loyaltyclass.patch({
    resourceId: `${this.issuerId}.${classSuffix}`,
    requestBody: patchBody,
  });

  console.log("Class patch response status: ", response.status);

  return `${this.issuerId}.${classSuffix}`;
}
// loyaltyPass.js
// LoyaltyPass.patchObject
/**
 * Patch an object.
 *
 * @param {string} objectSuffix Developer-defined unique ID for the pass object.
 * @param {LoyaltyObject} loyaltyObject The properties for the loyalty object to patch.
 *
 * @returns {string} The pass object ID: `${issuerId}.${objectSuffix}`
 */
async patchObject(objectSuffix, loyaltyObject) {
  let response;

  // Check if the object exists
  try {
    response = await this.client.loyaltyobject.get({
      resourceId: `${this.issuerId}.${objectSuffix}`
    });
  } catch (err) {
    if (err.response && err.response.status === 404) {
      console.log(`Object ${this.issuerId}.${objectSuffix} not found!`);
      return `${this.issuerId}.${objectSuffix}`;
    } else {
      // Something else went wrong...
      console.log(err);
      return `${this.issuerId}.${objectSuffix}`;
    }
  }

  let patchBody = {
    'barcode': {
      'type': 'QR_CODE',
      'value': loyaltyObject.QrCodeLink
    },
    'accountId': loyaltyObject.accountId,
    'accountName': loyaltyObject.FullName,
    // Left Column
    'loyaltyPoints': {
      'label': 'Code',
      'balance': {
        'string': loyaltyObject.accountId
      }
    },
    // Right Column (Optional)
    'secondaryLoyaltyPoints': {
      'label': 'Points',
      'balance': {
        'int': loyaltyObject.points
      }
    },
  }

  response = await this.client.loyaltyobject.patch({
    resourceId: `${this.issuerId}.${objectSuffix}`,
    requestBody: patchBody
  });

  console.log('Object patch response status: ', response.status);

  return `${this.issuerId}.${objectSuffix}`;
}

Expiring Pass Objects

We can also expire the pass:

// loyaltyPass.js
// LoyaltyPass.expireObject
/**
 * Expire an object.
 *
 * Sets the object's state to Expired. If the valid time interval is
 * already set, the pass will expire automatically up to 24 hours after.
 *
 * @param {string} objectSuffix Developer-defined unique ID for the pass object.
 *
 * @returns {string} The pass object ID: `${issuerId}.${objectSuffix}`
 */
async expireObject(objectSuffix) {
  let response;

  // Check if the object exists
  try {
    response = await this.client.loyaltyobject.get({
      resourceId: `${this.issuerId}.${objectSuffix}`
    });
  } catch (err) {
    if (err.response && err.response.status === 404) {
      console.log(`Object ${this.issuerId}.${objectSuffix} not found!`);
      return `${this.issuerId}.${objectSuffix}`;
    } else {
      // Something else went wrong...
      console.log(err);
      return `${this.issuerId}.${objectSuffix}`;
    }
  }

  // Patch the object, setting the pass as expired
  let patchBody = {
    'state': 'EXPIRED'
  };

  response = await this.client.loyaltyobject.patch({
    resourceId: `${this.issuerId}.${objectSuffix}`,
    requestBody: patchBody
  });

  console.log('Object expiration response status: ', response.status);

  return `${this.issuerId}.${objectSuffix}`;
}