Back to Blog

Build a Loyalty Program with QR Code Rewards

QRWorks Team13 min read

Loyalty cards get lost. Apps get deleted. But a simple QR scan at checkout? Frictionless.

A QR-based loyalty program captures every interaction. You see which customers visit most, what rewards drive repeat purchases, and where your program succeeds or fails.

In this tutorial, you'll build a loyalty system that:

  • Issues points when customers scan at checkout
  • Tracks reward redemptions via QR scans
  • Provides customer lifetime value analytics
  • Identifies your most valuable customers

System architecture

┌─────────────────────────────────────────────────────────────┐
│                     Customer Journey                         │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   ┌──────────┐     ┌──────────┐     ┌──────────┐            │
│   │  Sign Up │────▶│  Earn    │────▶│  Redeem  │            │
│   │  (scan)  │     │  Points  │     │  Rewards │            │
│   └──────────┘     └──────────┘     └──────────┘            │
│        │                │                │                   │
│        ▼                ▼                ▼                   │
│   ┌──────────────────────────────────────────────────┐      │
│   │              QRWorks Analytics                    │      │
│   │  • Scan frequency  • Location data  • Device info │      │
│   └──────────────────────────────────────────────────┘      │
│                          │                                   │
│                          ▼                                   │
│   ┌──────────────────────────────────────────────────┐      │
│   │            Your Loyalty Database                  │      │
│   │  • Points balance  • Transaction history  • LTV   │      │
│   └──────────────────────────────────────────────────┘      │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Database schema

-- customers
CREATE TABLE loyalty_customers (
  id UUID PRIMARY KEY,
  email VARCHAR(255) UNIQUE,
  name VARCHAR(255),
  phone VARCHAR(50),
  qr_analytics_id VARCHAR(100), -- Their personal QR code
  points_balance INTEGER DEFAULT 0,
  lifetime_points INTEGER DEFAULT 0,
  tier VARCHAR(50) DEFAULT 'bronze', -- bronze, silver, gold, platinum
  created_at TIMESTAMP DEFAULT NOW(),
  last_activity_at TIMESTAMP
);

-- transactions
CREATE TABLE point_transactions (
  id UUID PRIMARY KEY,
  customer_id UUID REFERENCES loyalty_customers(id),
  type VARCHAR(50), -- earn, redeem, expire, bonus
  points INTEGER, -- positive for earn, negative for redeem
  description TEXT,
  order_total DECIMAL(10,2), -- purchase amount if earn
  location_id UUID,
  created_at TIMESTAMP DEFAULT NOW()
);

-- rewards catalog
CREATE TABLE rewards (
  id UUID PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  description TEXT,
  points_required INTEGER NOT NULL,
  reward_type VARCHAR(50), -- discount, free_item, experience
  reward_value JSONB, -- { "discount_percent": 20 } or { "item": "free coffee" }
  available BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT NOW()
);

-- redemptions
CREATE TABLE redemptions (
  id UUID PRIMARY KEY,
  customer_id UUID REFERENCES loyalty_customers(id),
  reward_id UUID REFERENCES rewards(id),
  points_spent INTEGER,
  redemption_code VARCHAR(100) UNIQUE,
  qr_analytics_id VARCHAR(100), -- QR for this specific redemption
  status VARCHAR(50) DEFAULT 'active', -- active, used, expired
  expires_at TIMESTAMP,
  used_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

-- locations (for multi-location businesses)
CREATE TABLE locations (
  id UUID PRIMARY KEY,
  name VARCHAR(255),
  address TEXT,
  earn_qr_analytics_id VARCHAR(100) -- QR code at this location
);

Customer enrollment

Generate a personal QR code when customers join:

// customers.js
import fetch from 'node-fetch';

const API_KEY = process.env.QRWORKS_API_KEY;
const BASE_URL = process.env.QRWORKS_BASE_URL;

async function enrollCustomer(email, name, phone) {
  const customerId = generateId();

  // Create personal loyalty QR code
  const profileUrl = `https://loyalty.yourbrand.com/member/${customerId}`;

  const response = await fetch(`${BASE_URL}/v1/generate/dynamic`, {
    method: 'POST',
    headers: {
      'X-API-Key': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      redirect_url: profileUrl,
      metadata: {
        customer_id: customerId,
        type: 'loyalty_member'
      }
    })
  });

  const data = await response.json();

  // Save customer
  await db.query(`
    INSERT INTO loyalty_customers
    (id, email, name, phone, qr_analytics_id)
    VALUES ($1, $2, $3, $4, $5)
  `, [customerId, email, name, phone, data.analytics_id]);

  // Award signup bonus
  await awardPoints(customerId, 100, 'Signup bonus', null);

  return {
    customerId,
    qrCodeUrl: data.qr_code_url, // Add to Apple/Google Wallet
    pointsBalance: 100,
    message: 'Welcome! You earned 100 bonus points.'
  };
}

Store check-in QR codes

Set up QR codes at each location for earning points:

async function createLocationQR(locationId, locationName) {
  const checkInUrl = `https://loyalty.yourbrand.com/checkin/${locationId}`;

  const response = await fetch(`${BASE_URL}/v1/generate/dynamic`, {
    method: 'POST',
    headers: {
      'X-API-Key': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      redirect_url: checkInUrl,
      metadata: {
        location_id: locationId,
        location_name: locationName,
        type: 'earn_points'
      }
    })
  });

  const data = await response.json();

  await db.query(
    'UPDATE locations SET earn_qr_analytics_id = $1 WHERE id = $2',
    [data.analytics_id, locationId]
  );

  return {
    locationId,
    qrCodeUrl: data.qr_code_url,
    printUrl: data.qr_code_url + '?size=2048' // High-res for printing
  };
}

Earning points

When customers scan at checkout:

// server.js
import express from 'express';

const app = express();
app.use(express.json());

app.get('/checkin/:locationId', async (req, res) => {
  const { locationId } = req.params;
  const customerId = req.query.member; // From their app/wallet

  if (!customerId) {
    // Show login/signup form
    return res.redirect(`/signup?location=${locationId}`);
  }

  // Get customer
  const customer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  if (!customer) {
    return res.status(404).json({ error: 'Member not found' });
  }

  // Check for recent check-in (prevent abuse)
  const recentCheckin = await db.query(`
    SELECT * FROM point_transactions
    WHERE customer_id = $1
      AND location_id = $2
      AND created_at > NOW() - INTERVAL '1 hour'
  `, [customerId, locationId]).then(r => r.rows[0]);

  if (recentCheckin) {
    return res.json({
      message: 'You already checked in recently!',
      pointsBalance: customer.points_balance,
      nextCheckinAvailable: new Date(
        new Date(recentCheckin.created_at).getTime() + 60 * 60 * 1000
      )
    });
  }

  res.json({
    customerId,
    customerName: customer.name,
    pointsBalance: customer.points_balance,
    tier: customer.tier,
    locationId,
    message: 'Ready to earn points! Show this to the cashier.'
  });
});

// Cashier endpoint - award points after purchase
app.post('/earn', async (req, res) => {
  const { customerId, locationId, orderTotal, staffId } = req.body;

  // Calculate points (1 point per dollar, tier bonuses)
  const customer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  const tierMultiplier = {
    bronze: 1,
    silver: 1.25,
    gold: 1.5,
    platinum: 2
  }[customer.tier] || 1;

  const basePoints = Math.floor(orderTotal);
  const earnedPoints = Math.floor(basePoints * tierMultiplier);

  await awardPoints(
    customerId,
    earnedPoints,
    `Purchase at location`,
    locationId,
    orderTotal
  );

  // Check for tier upgrade
  await checkTierUpgrade(customerId);

  const updatedCustomer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  res.json({
    pointsEarned: earnedPoints,
    newBalance: updatedCustomer.points_balance,
    tier: updatedCustomer.tier,
    message: `You earned ${earnedPoints} points!`
  });
});

async function awardPoints(customerId, points, description, locationId, orderTotal = null) {
  await db.query(`
    INSERT INTO point_transactions
    (id, customer_id, type, points, description, location_id, order_total)
    VALUES ($1, $2, 'earn', $3, $4, $5, $6)
  `, [generateId(), customerId, points, description, locationId, orderTotal]);

  await db.query(`
    UPDATE loyalty_customers
    SET points_balance = points_balance + $1,
        lifetime_points = lifetime_points + $1,
        last_activity_at = NOW()
    WHERE id = $2
  `, [points, customerId]);
}

Tier system

Upgrade customers based on lifetime points:

async function checkTierUpgrade(customerId) {
  const customer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  const tierThresholds = {
    platinum: 10000,
    gold: 5000,
    silver: 1000,
    bronze: 0
  };

  let newTier = 'bronze';
  for (const [tier, threshold] of Object.entries(tierThresholds)) {
    if (customer.lifetime_points >= threshold) {
      newTier = tier;
      break;
    }
  }

  if (newTier !== customer.tier) {
    await db.query(
      'UPDATE loyalty_customers SET tier = $1 WHERE id = $2',
      [newTier, customerId]
    );

    // Notify customer of upgrade
    await sendNotification(customerId, {
      title: 'Congratulations!',
      message: `You've been upgraded to ${newTier.toUpperCase()} status!`,
      type: 'tier_upgrade'
    });

    return { upgraded: true, newTier };
  }

  return { upgraded: false };
}

Rewards catalog

Let customers browse and claim rewards:

app.get('/rewards', async (req, res) => {
  const { customerId } = req.query;

  const customer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  const rewards = await db.query(`
    SELECT * FROM rewards
    WHERE available = true
    ORDER BY points_required ASC
  `).then(r => r.rows);

  // Mark which rewards are redeemable
  const rewardsWithStatus = rewards.map(reward => ({
    ...reward,
    canRedeem: customer.points_balance >= reward.points_required,
    pointsNeeded: Math.max(0, reward.points_required - customer.points_balance)
  }));

  res.json({
    pointsBalance: customer.points_balance,
    tier: customer.tier,
    rewards: rewardsWithStatus
  });
});

Reward redemption

Generate a unique QR code for each redemption:

app.post('/rewards/:rewardId/redeem', async (req, res) => {
  const { rewardId } = req.params;
  const { customerId } = req.body;

  const customer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  const reward = await db.query(
    'SELECT * FROM rewards WHERE id = $1 AND available = true',
    [rewardId]
  ).then(r => r.rows[0]);

  if (!reward) {
    return res.status(404).json({ error: 'Reward not found' });
  }

  if (customer.points_balance < reward.points_required) {
    return res.status(400).json({
      error: 'Insufficient points',
      pointsNeeded: reward.points_required - customer.points_balance
    });
  }

  // Generate unique redemption code
  const redemptionCode = generateRedemptionCode();
  const redemptionId = generateId();

  // Create QR code for this specific redemption
  const redeemUrl = `https://loyalty.yourbrand.com/use/${redemptionCode}`;

  const qrResponse = await fetch(`${BASE_URL}/v1/generate/dynamic`, {
    method: 'POST',
    headers: {
      'X-API-Key': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      redirect_url: redeemUrl,
      metadata: {
        redemption_id: redemptionId,
        customer_id: customerId,
        reward_id: rewardId,
        type: 'reward_redemption'
      }
    })
  });

  const qrData = await qrResponse.json();

  // Create redemption record
  await db.query(`
    INSERT INTO redemptions
    (id, customer_id, reward_id, points_spent, redemption_code, qr_analytics_id, expires_at)
    VALUES ($1, $2, $3, $4, $5, $6, NOW() + INTERVAL '30 days')
  `, [redemptionId, customerId, rewardId, reward.points_required, redemptionCode, qrData.analytics_id]);

  // Deduct points
  await db.query(`
    INSERT INTO point_transactions
    (id, customer_id, type, points, description)
    VALUES ($1, $2, 'redeem', $3, $4)
  `, [generateId(), customerId, -reward.points_required, `Redeemed: ${reward.name}`]);

  await db.query(`
    UPDATE loyalty_customers
    SET points_balance = points_balance - $1
    WHERE id = $2
  `, [reward.points_required, customerId]);

  res.json({
    redemptionCode,
    qrCodeUrl: qrData.qr_code_url,
    reward: reward.name,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    message: 'Show this QR code to redeem your reward!'
  });
});

function generateRedemptionCode() {
  return 'RWD-' + Math.random().toString(36).substring(2, 8).toUpperCase();
}

Redemption validation

When staff scan a reward QR:

app.get('/use/:code', async (req, res) => {
  const { code } = req.params;

  const redemption = await db.query(`
    SELECT r.*, rw.name as reward_name, rw.reward_value,
           c.name as customer_name, c.tier
    FROM redemptions r
    JOIN rewards rw ON r.reward_id = rw.id
    JOIN loyalty_customers c ON r.customer_id = c.id
    WHERE r.redemption_code = $1
  `, [code]).then(r => r.rows[0]);

  if (!redemption) {
    return res.status(404).json({ error: 'Invalid redemption code' });
  }

  if (redemption.status === 'used') {
    return res.status(400).json({
      error: 'Already redeemed',
      usedAt: redemption.used_at
    });
  }

  if (redemption.status === 'expired' || new Date() > new Date(redemption.expires_at)) {
    return res.status(400).json({ error: 'Reward has expired' });
  }

  res.json({
    valid: true,
    redemption: {
      code: redemption.redemption_code,
      reward: redemption.reward_name,
      value: redemption.reward_value,
      customer: redemption.customer_name,
      tier: redemption.tier
    },
    actions: [
      { action: 'confirm', label: 'Mark as Used' }
    ]
  });
});

app.post('/use/:code/confirm', async (req, res) => {
  const { code } = req.params;
  const { staffId, locationId } = req.body;

  await db.query(`
    UPDATE redemptions
    SET status = 'used', used_at = NOW()
    WHERE redemption_code = $1
  `, [code]);

  res.json({
    success: true,
    message: 'Reward redeemed successfully!'
  });
});

Customer engagement analytics

Track scan patterns to understand engagement:

async function getCustomerEngagement(customerId) {
  const customer = await db.query(
    'SELECT * FROM loyalty_customers WHERE id = $1',
    [customerId]
  ).then(r => r.rows[0]);

  // Get scan history from QRWorks
  const analytics = await fetch(
    `${BASE_URL}/v1/analytics/${customer.qr_analytics_id}`,
    { headers: { 'X-API-Key': API_KEY } }
  ).then(r => r.json());

  // Transaction history
  const transactions = await db.query(`
    SELECT * FROM point_transactions
    WHERE customer_id = $1
    ORDER BY created_at DESC
    LIMIT 50
  `, [customerId]).then(r => r.rows);

  // Calculate engagement metrics
  const daysSinceJoin = Math.floor(
    (Date.now() - new Date(customer.created_at)) / (1000 * 60 * 60 * 24)
  );

  const earnTransactions = transactions.filter(t => t.type === 'earn');
  const totalSpend = earnTransactions.reduce(
    (sum, t) => sum + parseFloat(t.order_total || 0), 0
  );

  return {
    customer: {
      id: customerId,
      name: customer.name,
      tier: customer.tier,
      pointsBalance: customer.points_balance,
      lifetimePoints: customer.lifetime_points
    },
    engagement: {
      totalScans: analytics.total_scans,
      totalVisits: earnTransactions.length,
      daysSinceJoin,
      visitsPerMonth: (earnTransactions.length / (daysSinceJoin / 30)).toFixed(1),
      totalSpend,
      averageOrderValue: earnTransactions.length > 0
        ? (totalSpend / earnTransactions.length).toFixed(2)
        : 0,
      customerLifetimeValue: totalSpend
    },
    recentActivity: transactions.slice(0, 10),
    scanLocations: [...new Set(analytics.scans.map(s => s.city))]
  };
}

Program analytics dashboard

async function getProgramDashboard() {
  // Overall stats
  const stats = await db.query(`
    SELECT
      COUNT(*) as total_members,
      COUNT(*) FILTER (WHERE last_activity_at > NOW() - INTERVAL '30 days') as active_30d,
      SUM(points_balance) as outstanding_points,
      SUM(lifetime_points) as total_points_issued
    FROM loyalty_customers
  `).then(r => r.rows[0]);

  // Tier distribution
  const tierDistribution = await db.query(`
    SELECT tier, COUNT(*) as count
    FROM loyalty_customers
    GROUP BY tier
    ORDER BY CASE tier
      WHEN 'platinum' THEN 1
      WHEN 'gold' THEN 2
      WHEN 'silver' THEN 3
      WHEN 'bronze' THEN 4
    END
  `).then(r => r.rows);

  // Points activity this month
  const monthlyActivity = await db.query(`
    SELECT
      SUM(CASE WHEN points > 0 THEN points ELSE 0 END) as earned,
      SUM(CASE WHEN points < 0 THEN ABS(points) ELSE 0 END) as redeemed
    FROM point_transactions
    WHERE created_at > DATE_TRUNC('month', NOW())
  `).then(r => r.rows[0]);

  // Top customers
  const topCustomers = await db.query(`
    SELECT id, name, tier, lifetime_points, last_activity_at
    FROM loyalty_customers
    ORDER BY lifetime_points DESC
    LIMIT 10
  `).then(r => r.rows);

  // Popular rewards
  const popularRewards = await db.query(`
    SELECT rw.name, COUNT(r.id) as redemptions
    FROM redemptions r
    JOIN rewards rw ON r.reward_id = rw.id
    WHERE r.created_at > NOW() - INTERVAL '30 days'
    GROUP BY rw.id, rw.name
    ORDER BY redemptions DESC
    LIMIT 5
  `).then(r => r.rows);

  return {
    overview: {
      totalMembers: parseInt(stats.total_members),
      activeMembers: parseInt(stats.active_30d),
      outstandingPoints: parseInt(stats.outstanding_points),
      pointsLiability: (stats.outstanding_points * 0.01).toFixed(2) // $0.01 per point
    },
    tierDistribution,
    monthlyActivity,
    topCustomers,
    popularRewards
  };
}

Output:

Loyalty Program Dashboard
─────────────────────────
Total Members: 12,345    Active (30d): 4,567
Outstanding Points: 2,456,789    Liability: $24,567.89

Tier Distribution:
Platinum ██               234 (2%)
Gold     ████             567 (5%)
Silver   ████████       1,234 (10%)
Bronze   ████████████  10,310 (83%)

This Month:
Points Earned: 125,678
Points Redeemed: 45,234

Top Rewards:
1. Free Coffee (234 redemptions)
2. $10 Off Purchase (156 redemptions)
3. Double Points Day (89 redemptions)

Segmentation for marketing

Identify customer segments:

async function getCustomerSegments() {
  // RFM Analysis (Recency, Frequency, Monetary)
  const segments = await db.query(`
    WITH customer_metrics AS (
      SELECT
        c.id,
        c.name,
        c.email,
        c.tier,
        EXTRACT(DAY FROM NOW() - c.last_activity_at) as days_since_active,
        COUNT(pt.id) as transaction_count,
        COALESCE(SUM(pt.order_total), 0) as total_spend
      FROM loyalty_customers c
      LEFT JOIN point_transactions pt ON c.id = pt.customer_id AND pt.type = 'earn'
      GROUP BY c.id, c.name, c.email, c.tier, c.last_activity_at
    )
    SELECT
      CASE
        WHEN days_since_active <= 30 AND transaction_count >= 4 AND total_spend >= 200 THEN 'champions'
        WHEN days_since_active <= 30 AND transaction_count >= 2 THEN 'loyal'
        WHEN days_since_active <= 60 AND total_spend >= 100 THEN 'potential_loyalist'
        WHEN days_since_active > 90 AND total_spend >= 100 THEN 'at_risk'
        WHEN days_since_active > 180 THEN 'hibernating'
        ELSE 'new_or_casual'
      END as segment,
      COUNT(*) as customer_count,
      ROUND(AVG(total_spend)::numeric, 2) as avg_spend
    FROM customer_metrics
    GROUP BY segment
    ORDER BY avg_spend DESC
  `);

  return segments.rows;
}

Output:

Customer Segments
─────────────────
Segment           │ Customers │ Avg Spend
──────────────────┼───────────┼──────────
Champions         │       234 │   $456.78
Loyal             │       567 │   $234.56
Potential Loyalist│       890 │   $156.78
At Risk           │       345 │   $189.00
Hibernating       │     1,234 │    $78.90
New/Casual        │     9,075 │    $23.45

Re-engagement campaigns

Target at-risk customers:

async function getReEngagementTargets() {
  // Customers who were active but have dropped off
  const atRisk = await db.query(`
    SELECT
      c.id,
      c.email,
      c.name,
      c.points_balance,
      c.last_activity_at,
      EXTRACT(DAY FROM NOW() - c.last_activity_at) as days_inactive
    FROM loyalty_customers c
    WHERE c.last_activity_at BETWEEN NOW() - INTERVAL '180 days' AND NOW() - INTERVAL '30 days'
      AND c.lifetime_points >= 100
    ORDER BY c.lifetime_points DESC
    LIMIT 1000
  `).then(r => r.rows);

  return atRisk.map(customer => ({
    ...customer,
    suggestedOffer: getSuggestedOffer(customer)
  }));
}

function getSuggestedOffer(customer) {
  if (customer.points_balance >= 500) {
    return {
      type: 'reminder',
      message: `You have ${customer.points_balance} points! Redeem them before they expire.`
    };
  }

  if (customer.days_inactive > 90) {
    return {
      type: 'bonus_points',
      message: 'We miss you! Visit this week and earn 2x points.'
    };
  }

  return {
    type: 'discount',
    message: 'Come back and enjoy 20% off your next purchase!'
  };
}

Summary

You now have a loyalty program that:

  • Enrolls customers with personal QR codes
  • Awards points on purchase via store QR scans
  • Manages tier upgrades automatically
  • Issues redeemable rewards with unique QR codes
  • Tracks engagement through scan analytics
  • Segments customers for targeted marketing
  • Identifies at-risk members for re-engagement

The advantage of QR-based loyalty: no app to download, no card to carry. Customers scan, earn, and redeem with zero friction. And you get complete visibility into every interaction.


Ready to launch your loyalty program? Create your free account and start building customer relationships with every scan.

Ready to get started?

Create your free account and start generating QR codes in minutes.