Loyalty.lt
SDKsJavaScript

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 time

2. 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.

EventDescriptionData
card_identifiedCustomer scanned QR{ card_data: CardData }
status_updateSession 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

On this page