QR Card Scan
Identify customers via QR code scanning for POS systems
QR Card Scan
QR Card Scan enables POS systems to identify customers by displaying a QR code that customers scan with their Loyalty.lt app. Unlike QR Login, this returns the customer's loyalty card data without authentication tokens.
How It Works
Generate QR Session
POS system generates a QR code for customer identification
Display on Customer Screen
QR code is displayed on the customer-facing display
Customer Scans
Customer scans the QR code with Loyalty.lt mobile app
Automatic Identification
POS receives customer's loyalty card data automatically (no confirmation needed)
Process Transaction
POS can now award points, apply discounts, etc.
Implementation
1. Generate QR Card Session
const session = await sdk.generateQrCardSession(
'POS Terminal #1', // Device name
shopId // Optional shop ID
);
console.log(session.session_id); // Unique session ID
console.log(session.qr_code); // Deep link for QR code
console.log(session.expires_at); // Expiration time2. Display QR Code
// Generate QR code image URL using Loyalty.lt API
const qrImageUrl = `https://api.loyalty.lt/qr?data=${encodeURIComponent(session.qr_code)}&size=250`;
// Update customer display
document.getElementById('customer-qr').src = qrImageUrl;3. Subscribe to Card Identification Events
The simplest option is the SDK helper, which connects to Reverb for you:
const unsubscribe = sdk.subscribeToQrCardScan(session.session_id, (payload) => {
setCurrentCustomer(payload.card_data);
});If you prefer to drive the connection yourself, use pusher-js directly:
// 1. Fetch Reverb connection details
const config = await sdk.getRealtimeConfig();
// 2. Connect to Reverb (Pusher protocol)
const pusher = new Pusher(config.key, {
wsHost: config.host,
wsPort: config.port,
wssPort: config.port,
forceTLS: config.scheme === 'https',
enabledTransports: ['ws', 'wss'],
cluster: '', // Reverb has no clusters, but pusher-js throws without this option
disableStats: true
});
// 3. Subscribe to the public session channel
const channel = pusher.subscribe(`${config.channel_prefix.qr_card}${session.session_id}`);
// 4. Listen for card identification
channel.bind('card_identified', (payload) => {
const cardData = payload.card_data;
console.log('Customer identified!');
console.log('Name:', cardData.user?.name);
console.log('Card Number:', cardData.card_number);
console.log('Points Balance:', cardData.points);
// Update POS with customer data
setCurrentCustomer(cardData);
});
// Session status changes (scanned / expired / completed)
channel.bind(config.event, (payload) => {
console.log('Status:', payload.status);
});Note
There are no realtime tokens anymore. The card channel (qr-card.{session_id}) is a public
channel — the unguessable session ID is the secret — and pusher-js reconnects
automatically, so nothing has to be refreshed. Connection details come from
GET /{locale}/shop/realtime/config.
4. Polling Fallback
async function pollCardStatus(sessionId: string) {
const interval = setInterval(async () => {
try {
const status = await sdk.pollQrCardStatus(sessionId);
if (status.status === 'completed' && status.card_data) {
clearInterval(interval);
setCurrentCustomer(status.card_data);
} else if (status.status === 'expired') {
clearInterval(interval);
regenerateQR();
}
} catch (error) {
console.error('Polling error:', error);
}
}, 2000);
// Stop polling after 5 minutes
setTimeout(() => clearInterval(interval), 5 * 60 * 1000);
}Complete POS Example
class POSSystem {
private sdk: LoyaltySDK;
private currentCustomer: any = null;
private pusherClient: Pusher | null = null;
constructor() {
this.sdk = new LoyaltySDK({
apiKey: 'lty_...',
apiSecret: '...',
environment: 'production'
});
}
async startCustomerIdentification() {
// Generate QR session
const session = await this.sdk.generateQrCardSession('POS Terminal');
// Display QR on customer screen
this.updateCustomerDisplay(session.qr_code);
// Fetch Reverb connection details
const config = await this.sdk.getRealtimeConfig();
// Connect to Reverb (Pusher protocol)
this.pusherClient = new Pusher(config.key, {
wsHost: config.host,
wsPort: config.port,
wssPort: config.port,
forceTLS: config.scheme === 'https',
enabledTransports: ['ws', 'wss'],
cluster: '', // Reverb has no clusters, but pusher-js throws without this option
disableStats: true
});
const channel = this.pusherClient.subscribe(
`${config.channel_prefix.qr_card}${session.session_id}`
);
channel.bind('card_identified', (payload) => {
this.handleCustomerIdentified(payload.card_data);
});
// Auto-regenerate on expiry
setTimeout(() => {
if (!this.currentCustomer) {
this.startCustomerIdentification();
}
}, 5 * 60 * 1000);
}
handleCustomerIdentified(cardData: any) {
this.currentCustomer = {
name: cardData.user?.name || 'Customer',
phone: cardData.user?.phone,
cardId: cardData.id,
cardNumber: cardData.card_number,
points: cardData.points_balance || 0
};
// Update POS display
this.updatePOSDisplay();
// Calculate available discount
const pointsValue = this.currentCustomer.points * 0.01; // €0.01 per point
console.log(`Customer can redeem up to €${pointsValue.toFixed(2)}`);
}
async processTransaction(cartTotal: number, pointsToRedeem: number = 0) {
if (!this.currentCustomer) {
throw new Error('No customer identified');
}
// Calculate points to award
const pointsToAward = Math.floor(cartTotal * 10); // 10 points per €1
// Create transaction
const transaction = await this.sdk.createTransaction({
card_id: this.currentCustomer.cardId,
amount: cartTotal,
points: pointsToAward,
type: 'earn',
description: 'Purchase',
reference: `TXN-${Date.now()}`
});
return {
transactionId: transaction.id,
pointsEarned: pointsToAward,
pointsRedeemed: pointsToRedeem
};
}
clearCustomer() {
this.currentCustomer = null;
this.startCustomerIdentification();
}
}Card Data Structure
When a customer is identified, you receive:
interface CardData {
id: number;
card_number: string;
points_balance: number;
status: 'active' | 'blocked' | 'expired';
user: {
id: number;
name: string;
phone: string;
email: string;
};
partner: {
id: number;
name: string;
};
redemption?: {
enabled: boolean;
points_per_currency: number; // e.g., 100 points = 1 EUR
currency_amount: number; // e.g., 1
min_points: number; // Minimum points to redeem
};
created_at: string;
updated_at: string;
}Reverb Channel Events
Channel: qr-card.{session_id} (public — no channel authentication needed). The session
response also returns this name in the realtime_channel field.
| Event | Description | Data |
|---|---|---|
card_identified | Customer scanned QR | { card_data: CardData } |
status_update | Session status changed | { status } |
Auto-Regeneration
QR codes expire after 5 minutes. Implement auto-regeneration:
let qrTimeout: NodeJS.Timeout;
async function generateAndDisplayQR() {
// Clear previous timeout
if (qrTimeout) clearTimeout(qrTimeout);
// Generate new session
const session = await sdk.generateQrCardSession('POS');
displayQR(session.qr_code);
subscribeToEvents(session.session_id);
// Schedule regeneration
qrTimeout = setTimeout(generateAndDisplayQR, 4.5 * 60 * 1000);
}Next Steps
Pre-built QR display components
Complete POS implementation