WalletWallet API
Back to Blog

How to Build a Loyalty Program with Points, Rewards, and Tiers

Plan and build a loyalty program with points, rewards, and tiers. Follow a complete Node.js example for Apple Wallet and Google Wallet cards.

2026-07-13 By Alen Todorov loyalty loyalty-card points apple-wallet google-wallet node-js tutorial api

A loyalty program needs a published rule set, a ledger of verified events, a current customer balance, a safe redemption process, and a customer-facing way to see progress. The card is only the last part. Reliable points come from the ledger and the rules that turn a purchase, refund, or correction into a balance.

This guide designs a points program and builds the full lifecycle in one Node.js file: issue a card, earn from a verified sale, unlock a reward, upgrade a tier, redeem without erasing tier progress, reverse a refund, send a win-back message, and revoke a closed account. Apple Wallet and Google Wallet make that lifecycle available without a loyalty-app install: the balance, tier, reward, and latest update stay together on one current card rather than being scattered across app screens and email messages.

A quiet coffee counter with two percentage-marked jars beside the till
Photo by QingYu on Unsplash.

In this guide

How a points-based loyalty program works

The program record and the customer card have different jobs.

The record stores every earn, redemption, refund, adjustment, expiration, and tier event. It calculates the current state from those events. The card shows a small projection of that state: current points, reward progress, tier, member name, and an identifier staff can scan.

StateProgram eventCustomer-facing result
IssuedCustomer enrollsZero balance and earning rule
EarningVerified sale adds pointsHigher balance and distance to reward
Reward readySpendable balance crosses the thresholdRedeemable reward appears
RedeemedStaff consumes the reward onceSpendable points fall; tier progress stays
Tier upgradedQualifying points cross a tier thresholdNew status and benefits appear
InactiveNo qualifying event in the chosen windowOptional, relevant win-back notice
ClosedAccount is cancelled or replacedInstalled card is revoked

Deleting the card from a phone must not delete points. Removing a loyalty card from Google Wallet, for example, does not change the balance held by the loyalty program (Google Wallet Help). The database remains authoritative and can deliver the current card again.

Loyalty programs can influence behavior, but simple member-versus-nonmember comparisons exaggerate the effect because frequent buyers are more likely to join. A representative study of 1,909 Dutch households found a small positive share-of-wallet effect; the naïve estimate was seven times larger when member self-selection was ignored (Leenheer et al., 2007). Longitudinal convenience-store research also found different responses by prior purchase level: low and moderate buyers changed behavior, while heavy buyers mainly claimed rewards they had earned (Liu, 2007).

The practical lesson is to define the behavior you want and measure it against a credible baseline. Enrollment and reward claims alone do not establish incremental loyalty.

Choose the right loyalty model

ModelGood fitCustomer mental modelOperational complexity
Punch cardSimilar repeat actions such as coffees or classesComplete a fixed number of visitsLow
Redeemable pointsPurchase values vary and rewards have point pricesEarn currency, then spend itMedium
Stored balance or creditCustomer owns a monetary or service balanceUse the remaining balanceMedium to high
Points plus tiersStatus should reflect longer-term activitySpend points without losing status progressHigh

Use the punch card software guide when one purchase or visit should equal one step. A points model is more suitable when a $10 purchase and a $100 purchase should earn differently. A stored balance needs additional accounting and consumer-law analysis because it may behave more like value owed to the customer.

Add tiers only when they grant benefits worth explaining. A decorative Gold label introduces qualification periods, downgrade rules, refunds, and staff questions without creating customer value.

Separate the balances before setting thresholds

Many loyalty bugs start with one field called points. A maintainable program separates four concepts:

  1. Spendable points (pointsBalance). Currency the customer can redeem.
  2. Qualifying points (tierPoints). Progress used for status. Redeeming a reward does not remove it, although refunds may.
  3. Reward entitlements. Coupons, free items, or benefits already unlocked. Some programs create these separately; others spend points directly.
  4. Transaction ledger. Immutable earn, redemption, refund, correction, and expiration events that explain the balances.

The runnable example uses direct point redemption: 1,500 spendable points buy a free drink. It therefore does not keep a separate coupon balance. It still keeps spendable and qualifying points apart, plus an event ledger. A more complex program can add a reward-entitlement table without changing the wallet model.

Plan the earn and redemption rules

Write the customer rule and the implementation rule separately.

Customer rule: Earn 10 points for every dollar spent on eligible purchases. A free standard drink costs 1,500 points. Gold begins at 5,000 qualifying points in the calendar year.

Implementation rule: Convert the paid amount to integer cents. Award one point per ten eligible cents after discounts and before or after tax according to the published terms. Use the paid-sale ID once. Redemption subtracts 1,500 from pointsBalance and leaves tierPoints unchanged. A refund creates a reversing ledger event.

Check the economics with a simple example. If 1,500 points require $150 in eligible spend and the free drink costs $1.10 to fulfill, the direct reward cost is about 0.73 percent of that qualifying revenue. This is illustrative. Use contribution margin, expected redemption, outstanding point liability, platform cost, and the share of purchases that would have happened anyway in your own model.

Decide these cases before launch:

  • whether points are based on gross, net, pre-tax, or post-tax spend;
  • rounding of partial dollars and multiple currencies;
  • excluded products and promotion multipliers;
  • reward availability, substitution, and partial redemption;
  • refund, chargeback, and manual-correction behavior;
  • point and tier qualification periods;
  • expiration notices and local legal requirements;
  • account merging, closure, and data retention; and
  • which staff roles may earn, correct, or redeem.

Publish the customer-facing version in plain language and give staff a one-page decision sheet. Confirm tax, accounting, expiration, and consumer rules in each jurisdiction where the program operates.

Choose how to implement the program

ApproachCheckout connectionCustomer frictionControlBest fit
Spreadsheet and manual entryManualStaff lookupLimited audit and concurrencyA controlled pilot
POS loyalty moduleNative to one POSPhone number or accountVendor rulesOne checkout platform
Standalone loyalty platformConnectors and importsApp, web card, or walletConfigurableA team wanting managed operations
Apple Wallet and Google Wallet pass plus existing backendYour webhook or staff toolAdd once from a link or QR; no separate loyalty appHigh program controlA lightweight customer layer for an existing program
Custom customer appYour full stackInstall and retain an appMaximumA broader app customers already use

Why Apple Wallet and Google Wallet fit loyalty programs

For many programs, the ledger or POS remains the trusted source while Apple Wallet and Google Wallet become the place customers check progress. A pass can be issued, updated after every earn or refund, moved into reward-ready and tier states, notified, and revoked without convincing the customer to keep another app. Email and SMS remain useful for delivering or recovering the add link, while the live balance sits in a less crowded space beside the payment cards used at checkout.

A dedicated app is worth the extra install only when it already provides a broader experience such as ordering, account management, or community features. For a card, balance, reward, and update lifecycle, an Apple Wallet or Google Wallet pass is the shorter path.

Apple documents that Apple Wallet loyalty passes can display points, balances, offers, and tier changes and can provide update alerts (Apple Developer). Google provides a dedicated loyalty-card model for direct integrations (Google for Developers). The example below uses WalletWallet’s generic cross-wallet layer, so one record creates a compatible Apple Wallet pass and Google Wallet object.

In the Reserve Bank of Australia’s 2025 diary survey, 43 percent of participants used a mobile device for a contactless payment during the survey week, up from 35 percent in 2022 (Reserve Bank of Australia). That is evidence of growing familiarity with using a phone at checkout, not a measure of loyalty-pass adoption.

Architecture for a digital loyalty program

The safe data flow is:

  1. The POS webhook or authenticated staff action supplies a verified sale or redemption event.
  2. The backend validates the event ID, amount, member, and rule version.
  3. A database transaction appends the ledger entry and recalculates the member.
  4. The backend rebuilds the complete customer-facing card.
  5. WalletWallet pushes that projection to Apple Wallet and Google Wallet.

The pass barcode holds a stable member identifier. Scanning it finds the member; it does not authorize points or a reward. Staff authentication and the current ledger state control the write.

WalletWallet provides digital loyalty card delivery, signing, hosted install links, and updates. It does not replace the ledger, POS, customer database, or redemption rules.

Try the lifecycle on your laptop

This optional example runs on your computer, with no deployment or database setup. It stores test members in members.json and records sale, redemption, and refund IDs so you can see how the balance changes. Readers who only need the program model can skip ahead to the lifecycle steps and measurement guidance.

To run it, install Node.js 22 or newer, use any text editor, and get a WalletWallet API key from signup.

1. Set up the example

mkdir points-loyalty-program
cd points-loyalty-program

Create .env:

WALLETWALLET_API_KEY=ww_live_replace_with_your_key

Create .gitignore:

.env
members.json

2. Add the complete program

Create loyalty-program.mjs and copy the program below. The complete listing is collapsed by default so the article stays easy to scan.

import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

const API_BASE = "https://api.walletwallet.dev";
const API_KEY = process.env.WALLETWALLET_API_KEY;
const DB_FILE = new URL("./members.json", import.meta.url);
const POINTS_PER_DOLLAR = 10;
const REWARD_COST = 1500;
const GOLD_AT = 5000;

if (!API_KEY) throw new Error("Add WALLETWALLET_API_KEY to .env first.");

async function loadMembers() {
  try { return JSON.parse(await readFile(DB_FILE, "utf8")); }
  catch (error) {
    if (error.code === "ENOENT") return {};
    throw error;
  }
}

async function saveMembers(members) {
  await writeFile(DB_FILE, `${JSON.stringify(members, null, 2)}\n`);
}

async function walletRequest(path, method, body) {
  const response = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  const text = await response.text();
  let data;
  try { data = text ? JSON.parse(text) : {}; }
  catch { data = { message: text }; }
  if (!response.ok) {
    throw new Error(`${method} ${path} failed (${response.status}): ${JSON.stringify(data)}`);
  }
  return data;
}

function normaliseEmail(value = "") {
  const email = value.trim().toLowerCase();
  if (!email.includes("@")) throw new Error("Use a valid customer email.");
  return email;
}

function centsFromAmount(value) {
  if (!/^\d+(\.\d{1,2})?$/.test(value)) {
    throw new Error("Use a positive amount such as 18.50.");
  }
  const [whole, fraction = ""] = value.split(".");
  return Number(whole) * 100 + Number(fraction.padEnd(2, "0"));
}

function tierFor(member) {
  return member.tierPoints >= GOLD_AT ? "Gold" : "Member";
}

function passBody(member) {
  const rewardReady = member.pointsBalance >= REWARD_COST;
  return {
    barcodeValue: member.memberId,
    barcodeFormat: "QR",
    logoText: "Bay Roast Coffee",
    organizationName: "Bay Roast Coffee",
    description: "Bay Roast Coffee loyalty card",
    colorPreset: tierFor(member) === "Gold" ? "orange" : "green",
    sharingProhibited: true,
    headerFields: [{ label: "POINTS", value: String(member.pointsBalance) }],
    primaryFields: [{
      label: "REWARD",
      value: rewardReady
        ? "Free drink ready"
        : `${REWARD_COST - member.pointsBalance} points to go`,
    }],
    secondaryFields: [
      { label: "MEMBER", value: member.name },
      { label: "TIER", value: tierFor(member) },
    ],
    backFields: [
      { label: "Earn", value: `${POINTS_PER_DOLLAR} points per eligible dollar` },
      { label: "Redeem", value: `${REWARD_COST} points for a standard drink` },
      { label: "Member ID", value: member.memberId },
      { label: "Latest update", value: member.notification, changeMessage: "%@" },
    ],
  };
}

function getLiveMember(members, email) {
  const member = members[email];
  if (!member) throw new Error(`No member found for ${email}.`);
  if (member.revokedAt) throw new Error(`${email} is closed and revoked.`);
  return member;
}

function requireUniqueEvent(members, eventId) {
  if (!eventId) throw new Error("Add a unique event ID.");
  const duplicate = Object.values(members).some((member) =>
    member.ledger.some((event) => event.eventId === eventId),
  );
  if (duplicate) {
    throw new Error(`Event ${eventId} was already processed.`);
  }
}

async function updatePass(member) {
  const body = passBody(member);
  const result = await walletRequest(
    `/api/passes/${encodeURIComponent(member.serialNumber)}`,
    "PUT",
    body,
  );
  member.lastPassBody = body;
  member.lastPassApiUpdatedAt = new Date().toISOString();
  return result;
}

async function issue(members, email, name) {
  if (!name) throw new Error("Add the customer's name after their email.");
  if (members[email]) {
    throw new Error(`A record already exists for ${email}; use a linked replacement record.`);
  }
  const member = {
    email,
    name,
    memberId: `LOY-${randomUUID()}`,
    pointsBalance: 0,
    tierPoints: 0,
    notification: "Points balance: 0.",
    ledger: [],
    createdAt: new Date().toISOString(),
  };
  const body = passBody(member);
  const result = await walletRequest("/api/passes", "POST", body);
  member.serialNumber = result.serialNumber;
  member.shareUrl = result.shareUrl;
  member.googleSaveUrl = result.googleSaveUrl;
  member.lastPassBody = body;
  member.lastPassApiUpdatedAt = new Date().toISOString();
  members[email] = member;
  await saveMembers(members);
  console.log(`Loyalty card issued for ${name}`);
  console.log(`Send this install link: ${member.shareUrl}`);
}

async function earn(members, email, amount, saleId) {
  const member = getLiveMember(members, email);
  requireUniqueEvent(members, saleId);
  const cents = centsFromAmount(amount);
  const points = Math.floor((cents * POINTS_PER_DOLLAR) / 100);
  if (points < 1) throw new Error("This sale earns no whole points.");
  const oldTier = tierFor(member);

  member.pointsBalance += points;
  member.tierPoints += points;
  member.ledger.push({
    eventId: saleId,
    type: "earn",
    amountCents: cents,
    points,
    createdAt: new Date().toISOString(),
  });
  const newTier = tierFor(member);
  member.notification = newTier !== oldTier
    ? `You reached ${newTier} with ${member.tierPoints} qualifying points.`
    : member.pointsBalance >= REWARD_COST
      ? `Reward ready. Your balance is ${member.pointsBalance} points.`
      : `You now have ${member.pointsBalance} points.`;
  await updatePass(member);
  await saveMembers(members);
  console.log(`${member.name}: +${points}; balance ${member.pointsBalance}; tier ${newTier}`);
}

async function redeem(members, email, redemptionId) {
  const member = getLiveMember(members, email);
  requireUniqueEvent(members, redemptionId);
  if (member.pointsBalance < REWARD_COST) {
    throw new Error(`Need ${REWARD_COST}; current balance is ${member.pointsBalance}.`);
  }
  member.pointsBalance -= REWARD_COST;
  member.ledger.push({
    eventId: redemptionId,
    type: "redeem",
    points: -REWARD_COST,
    createdAt: new Date().toISOString(),
  });
  member.notification = `Reward redeemed. ${member.pointsBalance} points remain.`;
  await updatePass(member);
  await saveMembers(members);
  console.log(`Reward redeemed for ${member.name}; tier progress unchanged.`);
}

async function refund(members, email, originalSaleId, refundId) {
  const member = getLiveMember(members, email);
  requireUniqueEvent(members, refundId);
  const sale = member.ledger.find(
    (event) => event.eventId === originalSaleId && event.type === "earn",
  );
  if (!sale) throw new Error(`Earn event ${originalSaleId} was not found.`);
  if (member.ledger.some((event) => event.reverses === originalSaleId)) {
    throw new Error(`${originalSaleId} was already reversed.`);
  }
  member.pointsBalance -= sale.points;
  member.tierPoints = Math.max(0, member.tierPoints - sale.points);
  member.ledger.push({
    eventId: refundId,
    type: "refund",
    points: -sale.points,
    reverses: originalSaleId,
    createdAt: new Date().toISOString(),
  });
  member.notification = `Refund recorded. Balance: ${member.pointsBalance} points.`;
  await updatePass(member);
  await saveMembers(members);
  console.log(`Reversed ${sale.points} points from ${originalSaleId}.`);
}

async function nudge(members, email, message) {
  if (!message) throw new Error("Add reminder text after the email.");
  const member = getLiveMember(members, email);
  if (member.notification === message) {
    throw new Error("Use different text so the notification field changes.");
  }
  member.notification = message;
  await updatePass(member);
  await saveMembers(members);
  console.log(`Pass update accepted for ${member.name}; wallet delivery is asynchronous.`);
}

async function close(members, email) {
  const member = getLiveMember(members, email);
  await walletRequest(
    `/api/passes/${encodeURIComponent(member.serialNumber)}`,
    "DELETE",
  );
  member.revokedAt = new Date().toISOString();
  await saveMembers(members);
  console.log(`Closed and revoked ${member.name}.`);
}

async function main() {
  const [command, rawEmail, ...args] = process.argv.slice(2);
  if (!command || !rawEmail) {
    throw new Error("Usage: node --env-file=.env loyalty-program.mjs COMMAND EMAIL [ARGS]");
  }
  const email = normaliseEmail(rawEmail);
  const members = await loadMembers();

  if (command === "issue") return issue(members, email, args.join(" "));
  if (command === "earn") return earn(members, email, args[0], args[1]);
  if (command === "redeem") return redeem(members, email, args[0]);
  if (command === "refund") return refund(members, email, args[0], args[1]);
  if (command === "nudge") return nudge(members, email, args.join(" "));
  if (command === "close") return close(members, email);
  if (command === "show") return console.log(JSON.stringify(members[email] ?? null, null, 2));
  throw new Error(`Unknown command: ${command}`);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

3. Issue the loyalty card

node --env-file=.env loyalty-program.mjs issue [email protected] "Sam Whitaker"

Send the printed shareUrl to the customer. The API creates the Apple and Google versions from the same body and returns the serial that every update uses. The member ID in the QR code is stable but does not contain the email or a points balance.

4. Earn points from a verified sale

node --env-file=.env loyalty-program.mjs earn [email protected] 18.50 sale-10458

At 10 points per dollar, $18.50 earns 185 points. Repeating sale-10458 is rejected. In production, that unique event ID should come from a verified paid POS transaction or an authenticated staff action.

The example performs money arithmetic in integer cents, avoiding floating-point rounding in the earn rule. It appends the ledger event, updates both point balances, calculates reward and tier state, and then sends the complete pass body with PUT /api/passes/<serial>.

5. Unlock a reward and upgrade the tier

Continue earning with distinct sale IDs. At 1,500 spendable points, the same card changes to Free drink ready. At 5,000 qualifying points, it moves to Gold. Those thresholds are independent: a customer may redeem several rewards while their tier progress continues.

6. Redeem without reducing tier progress

node --env-file=.env loyalty-program.mjs redeem [email protected] redemption-775

The command requires enough spendable points and a new redemption ID. It removes 1,500 from pointsBalance; tierPoints stays unchanged. The ledger explains the new visible balance and prevents the same redemption event from running twice.

7. Reverse a refunded sale

node --env-file=.env loyalty-program.mjs refund [email protected] sale-10458 refund-10458

The example adds a reversal rather than deleting history. If points from the sale were already spent, the balance can become negative. That makes the debt visible instead of silently inventing points; your published policy must decide whether future earnings repay it, the account is reviewed, or the program caps the reversal.

8. Nudge an inactive member

node --env-file=.env loyalty-program.mjs nudge [email protected] "You are 220 points from a free drink."

Use actual progress and a defined inactivity window. The message field was seeded when the pass was issued, and changing its value lets Apple use it as a custom lock-screen message. WalletWallet’s Google update is generic, with the new text inside the card. Device settings and delivery timing still apply.

9. Close and revoke the account

node --env-file=.env loyalty-program.mjs close [email protected]

Revocation is terminal. The pass becomes invalid or expired rather than being remotely deleted from the phone. This local example refuses to reuse an email so it cannot overwrite the old ledger and revocation record. In production, create a linked replacement account and card with a new identifier.

Before you use this with customers

The local file makes the rules visible, but it is not a live loyalty database. For a customer program, connect the same lifecycle to your POS, commerce system, or existing backend and confirm four safeguards:

  • every sale, redemption, and refund has a unique event ID;
  • balance changes and their history are saved together;
  • the API key and all point-changing actions stay behind staff authentication or a verified payment event; and
  • failed wallet updates are retried from the saved balance rather than changing the balance again.

Publish the customer rules for earning, redemption, refunds, expiration, and notifications before enrollment. A developer can handle the integration while the program owner stays focused on the thresholds, reward cost, staff process, and customer experience.

Measure whether the program changes behavior

Track the whole funnel:

  • enrollment and first-earn activation;
  • active earners and repeat interval;
  • time to first reward;
  • reward-ready and redemption rates;
  • tier movement;
  • refund and correction rate;
  • direct reward cost and outstanding point liability;
  • inactive-customer recovery; and
  • member behavior against a matched or randomized comparison where possible.

Frequent customers self-select into loyalty programs. Comparing all members with all nonmembers will usually flatter the program. A pilot, staggered rollout, matched cohorts, or holdout gives a more defensible estimate. Keep operational success—correct balances, fast redemption, low support burden—separate from causal claims about extra purchases.

Common implementation problems

SymptomLikely causeFix
One sale earns twiceWebhook retriedMake the sale ID unique before changing balances
Tier falls after reward redemptionSpendable and qualifying points were conflatedKeep pointsBalance and tierPoints separate
Pass fields disappearUpdate sent only the changed balanceSend the complete pass body on every PUT
Reward is used twiceRedemption was not atomic or idempotentUse a unique redemption event inside a transaction
Refund leaves unearned pointsOriginal earn was deleted or ignoredAppend a reversal tied to the sale event
Barcode screenshot earns pointsIdentifier was treated as authorizationRequire a verified sale or staff action
Database is right but card is staleProjection update failedQueue, retry, and reconcile from the ledger
Win-back notice does not appearNotification value did not changeSeed the field on issue and send distinct text

Loyalty program questions

How much should one point be worth?

There is no universal rate. Choose the earn rate, reward price, direct reward cost, expected redemption, and customer clarity together. Test the rule with real transactions and publish examples.

What is the difference between points and a punch card?

A punch card gives equal progress for a defined action. Points usually scale with spend and can buy different rewards. Use the simpler model that matches the purchase pattern.

Should tier points reset when a reward is redeemed?

Usually not. Spendable points pay for the reward; qualifying points measure status over a period. A tier-year reset is a separate scheduled rule.

Can Apple Wallet or Google Wallet replace a loyalty app?

They can replace the customer-facing card and show live balances without a separate branded app. They do not replace the ledger, POS connection, customer database, or authenticated redemption system.

How should refunds work?

Append a reversing event tied to the original sale. Decide how to handle points already spent and whether the refund also reverses tier qualification. Keep the history rather than editing the original earn.

Can the card show a live balance?

Yes. Rebuild the full pass body after a ledger change and update the existing serial. Apple Wallet or Google Wallet refreshes the installed credential; exact delivery timing depends on the platform and device.

Launch the smallest useful version

Start with one earning rule, one reward, and no tier unless the status benefit is real. Connect one verified event source and test issue, earn, reward-ready, redeem, refund, notification, and closure before adding multipliers or partner offers.

The digital loyalty cards for Apple Wallet and Google Wallet page covers delivery and operations. Use email or SMS to place the add link in front of the customer, then let the installed pass carry the current balance, reward, and tier. Keep the create, update, and revoke API references beside the one-page program rules your staff and customers use.

Build your first wallet pass

Turn one JSON request into a pass that installs in Apple Wallet and Google Wallet, with live updates that reach both.