Build a Loyalty Program with QR Code Rewards
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.