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
| Feature | Standard | Balanced | Strict |
|---|---|---|---|
| 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 type | Static (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:
- Generates a cryptographically secure random PIN (
crypto/rand) - bcrypt-hashes it (cost 10) and writes it to the database
- Pushes the plaintext PIN over SSE (Server-Sent Events) exclusively to that customer's active dashboard session
- Returns
412 Pin Required+challenge_idto 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
| Parameter | Value |
|---|---|
| Time to live | 90 seconds |
| Maximum attempts | 3 (exceeded → challenge force-consumed, locked) |
| Length | 2 or 4 digits (owner configures) |
| Reuse | Single-use — consumed challenges cannot be retried |
| Range | 00–99 (2-digit) or 0000–9999 (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 remaining | Colour |
|---|---|
| > 10 seconds | Primary (blue) |
| 4–10 seconds | Warning (amber) |
| ≤ 4 seconds | Danger (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.
| Preset | Scenario | Outcome |
|---|---|---|
| Standard | QR is shared | ✗ Attacker can redeem points |
| Balanced | QR shared, tries to redeem a reward | ✓ PIN required — attacker cannot access the customer's phone |
| Strict | QR 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).
| Endpoint | Standard | Balanced | Strict |
|---|---|---|---|
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
}| Field | Description |
|---|---|
verification_pin | The PIN read from the customer's dashboard. Omit on the first call. |
manual_code | true when the cashier typed the customer code or coupon token by hand. Strict orgs reject this with manual_code_disabled (422). |
Response codes
| HTTP | Body shape | Meaning |
|---|---|---|
200 | normal success body | The 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.