Security
Fraud Detection

Fraud Detection

Loyalite includes a layered verification system called Verification Presets to prevent customers from sharing their loyalty accounts or QR codes with others. Store owners choose one of three protection levels; the system requests additional verification for sensitive actions based on that choice — with minimal friction for the customer experience.


Preset Comparison

FeatureStandardBalancedStrict
PIN on stamp earn
PIN on points earn
PIN on points redeem
PIN on coupon redemption
PIN on balance adjustment
Manual customer code entry
QR code typeStatic (24 h)Static (24 h)Rotating (TOTP, 30 s)

What is a PIN? A 2 or 4-digit one-time code pushed instantly to the customer's dashboard when an action is initiated. The cashier asks the customer to read it aloud, then enters it in the PIN Sheet on the merchant app.


Choosing a Preset

Standard (default)

Frictionless experience. Suitable for trusted, low-risk environments.

Best for:

  • Small café or bakery where cashiers recognise regulars
  • Solo-operator boutique where throughput matters

Protection against card sharing: HMAC-signed QR validation only. Sharing a QR code is technically possible, but the practical abuse risk is low.


Balanced

PIN required for high-value actions (reward redemption, points spend, balance adjustment); routine stamp and points earn remains fast.

Best for:

  • Restaurant or café chain where cashiers don't always know the customer
  • Boutique or beauty salon with a mid-size customer base
  • Programmes where rewards carry cash value (e.g. discount coupons)

Protection against card sharing: Anyone trying to use accumulated points or a coupon needs the PIN that appears on the customer's phone screen. Sharing the QR code alone is not enough.


Strict

Every action requires a PIN. Manual code entry is disabled. The QR code rotates every 30 seconds (TOTP).

Best for:

  • Shopping mall or retail chain — high staff turnover, centralised POS
  • High-value reward programmes (e.g. flights, merchandise)
  • Industries with compliance requirements

Protection against card sharing: An attacker would need both a valid QR (within 30 seconds) and the customer's PIN simultaneously — practically impossible without physical access to the customer's device.

⚠️

Strict mode affects the customer experience. Every transaction requires the customer to check their phone. Slow internet connections or notification delays add extra friction. Consider Balanced for medium-to-high volume locations.


How PIN Works

Cashier initiates an action

The cashier scans the customer's QR code or enters their code manually in the merchant app, then initiates an action such as "Add Stamp" or "Redeem Coupon".

Backend generates and delivers the PIN

The server:

  1. Generates a cryptographically secure random PIN (crypto/rand)
  2. bcrypt-hashes it (cost 10) and writes it to the database
  3. Pushes the plaintext PIN over SSE (Server-Sent Events) exclusively to that customer's active dashboard session
  4. Returns 412 Pin Required + challenge_id to the merchant app

Customer sees the PIN

A modal appears on the customer's dashboard showing the PIN in large digits. It also displays which action the code is for (e.g. "Tell the cashier — Adding a Stamp").

Cashier enters the PIN

The PIN Sheet opens on the merchant app. The cashier types in the PIN the customer reads aloud.

Action completes

The backend verifies the PIN (bcrypt comparison) and marks the challenge as consumed. The action is executed. The PIN is now permanently invalid.

PIN Limits

ParameterValue
Time to live90 seconds
Maximum attempts3 (exceeded → challenge force-consumed, locked)
Length2 or 4 digits (owner configures)
ReuseSingle-use — consumed challenges cannot be retried
Range0099 (2-digit) or 00009999 (4-digit) — leading zeros preserved

Rotating QR (Strict Preset)

In Strict mode the QR code on the customer's dashboard is not static. It is recomputed every 30 seconds.

How it works

Each customer is assigned a unique qr_totp_secret (20 random bytes) in the database at enrollment. The dashboard holds this secret in memory only — it is never written to localStorage, sessionStorage, or any cookie.

The QR payload contains an RFC 6238 TOTP-based signature:

v2|{org_slug}|{customer_code}|{window_counter}|{totp_code}

When validating, the server uses the customer's secret to compute the current and adjacent TOTP windows (±1 window, tolerating up to ±30 s of clock drift) and compares them against the submitted code.

Visual feedback

The rotating QR on the dashboard changes colour based on remaining time:

Time remainingColour
> 10 secondsPrimary (blue)
4–10 secondsWarning (amber)
≤ 4 secondsDanger (red)
💡

If a customer shows a nearly-expired QR to the cashier, the system automatically accepts the next window — no manual refresh needed.


Why Sharing Doesn't Work

Scenario: A customer forwards their QR code to someone else to redeem accumulated points.

PresetScenarioOutcome
StandardQR is shared✗ Attacker can redeem points
BalancedQR shared, tries to redeem a reward✓ PIN required — attacker cannot access the customer's phone
StrictQR is shared✓ QR expires within 30 s + PIN required

Scenario: A customer takes a screenshot of their PIN before reading it to the cashier and shares it with someone else.

Every challenge is single-use and expires in 90 seconds. Even if an attacker has both the PIN and the QR simultaneously, they would need to use it at a different POS terminal within 90 seconds — a coordinated and traceable act.


PIN Validation API Flow

Endpoints that gate on the verification preset all follow the same challenge-and-retry pattern. POS integrations and other API clients implement it once and reuse it for every gated action.

Which endpoints gate

The matrix below mirrors the Preset Comparison table at the top of this page — same rules, expressed per endpoint instead of per action. The matrix is identical for the POS API and the merchant app's interactive path; the only difference is the auth layer in front (API key vs merchant JWT).

EndpointStandardBalancedStrict
POST /webhook/pos (earn)
POST /webhook/redeem
POST /webhook/coupons/redeem
POST /loyalty/earn (merchant app)
POST /loyalty/redeem (merchant app)
POST /loyalty/adjust (merchant app)
POST /loyalty/point-transaction (merchant app)
POST /coupons/redeem (merchant app)

The principle: Balanced protects value-deducting actions (redeem, adjust, coupon redemption) — sharing a QR alone shouldn't be enough to drain a customer's account. Strict adds earn on top: at that preset even stacking stamps requires the customer to be physically present, since that's the threat model where QR-only attacks should fail completely.

Request shape

{
  "token": "cq_7f3a9b2e...",
  "verification_pin": "4821",
  "manual_code": false
}
FieldDescription
verification_pinThe PIN read from the customer's dashboard. Omit on the first call.
manual_codetrue when the cashier typed the customer code or coupon token by hand. Strict orgs reject this with manual_code_disabled (422).

Response codes

HTTPBody shapeMeaning
200normal success bodyThe action completed. PIN was either not required or already verified.
412 pin_required{ "error": "pin_required", "challenge_id": "..." }The org runs Balanced or Strict and this action needs a PIN. The customer's dashboard now shows a fresh PIN. Retry with verification_pin set.
422 pin_invalid{ "error": "pin_invalid", "remaining_attempts": N }Wrong PIN. The challenge is still alive — retry with a corrected verification_pin. After 3 wrong attempts the challenge is force-consumed; the next call should omit verification_pin to issue a new one.
422 pin_expired{ "error": "pin_expired" }The challenge is older than 90 seconds (TTL elapsed) or already consumed. Retry without verification_pin to start a fresh challenge.
429 pin_attempts_exceeded{ "error": "pin_attempts_exceeded" }The 3-attempt limit was hit. Retry without verification_pin to issue a new challenge.
422 manual_code_disabled{ "error": "manual_code_disabled" }The cashier sent manual_code=true on a Strict org. The action must come from an actual QR scan.

Flow diagram

The same pattern works for every gated endpoint — the only thing that changes is the request body ({token} for coupon redeem, {customer_code, value, ...} for /webhook/redeem, etc).

1.  POS calls a gated endpoint (e.g. /webhook/redeem)

2.  ←  412 pin_required + challenge_id

3.  Cashier asks customer to read PIN from their dashboard

4.  POS calls the same endpoint with verification_pin populated

5.  ←  200 OK  →  outbound webhook fires (if applicable)

Retry loop — JavaScript

This helper is endpoint-agnostic — pass any URL and base body, and it handles the PIN challenge on top. End-to-end implementations for specific endpoints live in POS Examples (redeemPointsWithPin, redeemCouponWithPin).

/**
 * Calls a Loyalite endpoint with automatic PIN-challenge retry.
 *
 * @param url        – full endpoint URL (e.g. `${BASE}/webhook/redeem`)
 * @param baseBody   – the JSON body without verification_pin (the helper adds it)
 * @param promptForPin – async ({ remainingAttempts, challengeId }) => "1234"
 * @returns the parsed Response (caller decides what to do with non-PIN errors)
 */
async function callWithPinRetry(url, baseBody, promptForPin) {
  let res = await call(url, baseBody);
 
  while (res.status === 412 || res.status === 422 || res.status === 429) {
    const body = await res.json();
    const code = body.error;
 
    // Not a PIN error — surface to caller (e.g. insufficient_balance is also 422).
    if (!['pin_required', 'pin_invalid', 'pin_expired', 'pin_attempts_exceeded', 'manual_code_disabled'].includes(code)) {
      // Re-package the response so the caller can read the body.
      return new Response(JSON.stringify(body), { status: res.status });
    }
 
    if (code === 'manual_code_disabled') {
      throw new Error('This org does not allow manual entry — scan the QR.');
    }
 
    // pin_expired / pin_attempts_exceeded both invalidate the previous
    // challenge: retry without a PIN to issue a fresh one (server pushes the
    // new code to the customer's dashboard).
    if (code === 'pin_expired' || code === 'pin_attempts_exceeded') {
      res = await call(url, baseBody);
      continue;
    }
 
    // pin_required (412) or pin_invalid (422): ask the cashier for the PIN
    // displayed on the customer's phone and retry.
    const pin = await promptForPin({
      remainingAttempts: body.remaining_attempts, // present on pin_invalid
      challengeId: body.challenge_id,             // present on pin_required
    });
    res = await call(url, { ...baseBody, verification_pin: pin });
  }
 
  return res;
}
 
async function call(url, body) {
  return fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY },
    body: JSON.stringify(body),
  });
}

Retry loop — Python

import requests
 
PIN_ERRORS = {"pin_required", "pin_invalid", "pin_expired", "pin_attempts_exceeded", "manual_code_disabled"}
 
def call_with_pin_retry(url: str, base_body: dict, prompt_for_pin) -> requests.Response:
    """Calls a gated endpoint, retrying on PIN challenges until success or
    a non-PIN error. Caller handles 200/4xx/5xx as usual."""
    res = _call(url, base_body)
 
    while res.status_code in (412, 422, 429):
        body = res.json()
        code = body.get("error")
 
        # Not a PIN error (e.g. insufficient_balance) — return as-is.
        if code not in PIN_ERRORS:
            return res
 
        if code == "manual_code_disabled":
            raise RuntimeError("This org does not allow manual entry — scan the QR.")
 
        if code in ("pin_expired", "pin_attempts_exceeded"):
            res = _call(url, base_body)
            continue
 
        pin = prompt_for_pin(
            remaining_attempts=body.get("remaining_attempts"),
            challenge_id=body.get("challenge_id"),
        )
        res = _call(url, {**base_body, "verification_pin": pin})
 
    return res
 
 
def _call(url: str, payload: dict) -> requests.Response:
    return requests.post(url, headers={"X-Api-Key": API_KEY}, json=payload)

Implementation notes

  • One challenge per (org, customer, action). Issuing a new challenge invalidates the previous one — don't try to keep multiple in flight.
  • The PIN is single-use. Once verified successfully (200), it is consumed. The next gated action triggers a brand-new 412.
  • Leading zeros are significant — PINs are exchanged as strings ("04", "0007"), not integers.
  • Don't cache challenges across cashiers. A challenge is bound to the customer; if a different cashier handles the next transaction, just let them issue a fresh one.

Settings API

Verification settings are changed by the owner in the merchant app under Account → Security.

GET   /merchant/settings/verification  →  { level, pin_length, manual_code_enabled }
PATCH /merchant/settings/verification  →  { level?, pin_length? }

Every change writes a structured audit: log line on the backend capturing the actor and old → new values for both level and pin_length.

Changing the PIN length only affects challenges created from that point on. Any challenge already in flight completes with the previous length.