Loyalty.lt
API Basics

Realtime

Channels, events and payloads — how a result reaches the screen that is waiting for it

Realtime

Whenever one screen has to react to something that happened on another — a QR code being scanned, a till updating the basket the customer is watching in the app, points landing on a card — the result arrives over a realtime channel.

This is the path to build. Polling exists as a fallback for an environment that cannot hold a WebSocket open; it costs a request per second per session while the customer stands at the till waiting.

The channel speaks the Pusher protocol, so any Pusher client library works — pusher-js in a browser, laravel-echo, or a raw WebSocket.

Fetch the connection details

GET /{locale}/shop/realtime/config, with your API credentials.

Subscribe

Public channels need nothing. Private channels are signed by POST /{locale}/broadcasting/auth using a customer JWT.

Bind the event

Each channel below lists its event names and exact payload.

Connecting

curl -X GET "https://api.loyalty.lt/lt/shop/realtime/config" \
  -H "X-API-Key: $LOYALTY_API_KEY" \
  -H "X-API-Secret: $LOYALTY_API_SECRET"
{
  "success": true,
  "code": 200,
  "request_id": "…",
  "message": "Realtime configuration",
  "data": {
    "key": "…",
    "host": "…",
    "port": 443,
    "scheme": "https",
    "channel_prefix": { "qr_login": "qr-login.", "qr_card": "qr-card." },
    "event": "status_update"
  }
}

channel_prefix and event are there so you can build channel names without hardcoding the strings in this page.

Read host from this endpoint. Do not pin it — it is infrastructure, and an integration that hardcodes it breaks the day it moves.

const { data: config } = await (await fetch(
  'https://api.loyalty.lt/lt/shop/realtime/config',
  { headers: { 'X-API-Key': apiKey, 'X-API-Secret': apiSecret } },
)).json();

const pusher = new Pusher(config.key, {
  wsHost: config.host,
  wsPort: config.port,
  wssPort: config.port,
  forceTLS: config.scheme === 'https',
  enabledTransports: ['ws', 'wss'],
  cluster: '',        // unused, but pusher-js requires the option
  disableStats: true,
});

Public channels

No authorization step. The unguessable session ID in the channel name is the secret, so there is no token to manage.

qr-login.{session_id}

Opened by POST /{locale}/shop/auth/qr-login/generate, which returns the channel's session_id plus two ways in: qr_code to render, and manual_code — nine digits shown as XXX-XXX-XXX — for a customer who cannot scan. Both reach this same channel.

EventWhen
status_updateEvery time the session changes state
{
  "session_id": "a3f1…",
  "timestamp": "2026-09-11T10:04:12.000000Z",
  "status": "authenticated",
  "user": { "id": 42, "name": "…", "email": "…", "phone": "+3706…" },
  "token": "eyJ0eXAi…",
  "refresh_token": "…",
  "loyalty_card": { "id": 91, "card_number": "900-042-003", "points": 1500, "partner_id": 3 }
}
statusMeaningExtra fields
scannedCustomer scanned; waiting for them to confirm in the app—
authenticatedDoneuser, token, refresh_token, and loyalty_card when a partner is bound
failedThe app could not complete the login—
expiredThe 5-minute window elapsed; generate a new session—
pusher.subscribe(`qr-login.${sessionId}`).bind('status_update', (p) => {
  if (p.status === 'authenticated') signIn(p.token, p.user);
  if (p.status === 'expired') regenerate();
});

qr-card.{session_id}

Opened by POST /{locale}/shop/qr-card/generate. The till learns which customer is standing in front of it. Like QR login it returns both a qr_code and a typable manual_code; either brings the customer onto this channel.

EventWhenBind it?
card_identifiedThe customer scannedYes
status_updateSame moment, overlapping bodyLegacy — bind one or the other, not both
{
  "session_id": "b7c2…",
  "status": "authenticated",
  "timestamp": "2026-09-11T10:07:45.000000Z",
  "card_data": {
    "loyalty_card_id": 91,
    "card_number": "900-042-003",
    "points": 1500,
    "user": { "id": 42, "name": "…", "email": "…", "phone": "+3706…" },
    "redemption": {
      "enabled": true,
      "points_per_currency": 100,
      "currency_amount": 1,
      "min_points": 100,
      "max_points": 5000
    },
    "scanned_at": "2026-09-11T10:07:45.000000Z"
  }
}

Private channels

Anything tied to a real customer is private and must be signed.

Authorizing

POST /{locale}/broadcasting/auth
Authorization: Bearer <customer JWT>

socket_id=<from pusher:connection_established>
channel_name=private-<channel>

Answers {"auth": "<app_key>:<hmac_sha256>"}, or 403 when the channel is not yours. Your Pusher client calls it for you:

const pusher = new Pusher(config.key, {
  // …transport options as above
  authEndpoint: 'https://api.loyalty.lt/lt/broadcasting/auth',
  auth: { headers: { Authorization: `Bearer ${token}` } },
});

Authorization is per channel and strict: a customer reaches only their own channels.

shopping-session.{session_id}

The live basket, shared by the till and the customer's app. Both sides subscribe, and both sides send — see client events below.

EventSent by
session_updateEither side, as a client event
{
  "sessionId": "…",
  "lastUpdatedBy": "staff",
  "timestamp": "2026-09-11T10:09:02.000000Z",
  "purchaseAmount": "25.50",
  "pointsToRedeem": "500",
  "customerPointsInput": "500",
  "calculatedPoints": 255,
  "paymentMethod": "card",
  "description": "…",
  "status": "editing"
}

Only sessionId, lastUpdatedBy and timestamp are always present; send whichever of the rest changed.

user.{id}

The customer's own channel. {id} is their user ID — a customer can only subscribe to their own.

EventSent byPayload
card_scan_progressServerscan_id, step, percent, message
session_createdTillFull session: sessionId, cardId, shopId, staffId, customerInfo{…}, staffInfo{…}, shopInfo{…}, pointsRules, status: "editing"
session_endedTillsession_ended: true, sessionId, timestamp, and the basket fields reset
transaction_completedTilltransaction_id, amount, currency, points_awarded, points_redeemed, points_discount_amount, final_amount

user.{id}.notifications

EventPayload
notification:receivednotification_id, title, body, type, timestamp, unread_count_delta

Note

Other channel names appear in the platform's authorization rules — loyalty-card.{id}, transaction.{id}, shop.{id}, partner.{id}, payment.{ref}. Nothing publishes to them today. Subscribing is harmless but you will never receive anything; use the events above instead.

Client events

A client event is a message sent by one subscriber to the others, without passing through your server. It is how the till drives the customer's screen with no round-trip.

Three rules:

  1. The event name must start with client-. Receivers bind the name without the prefix — trigger('client-session_update', …) arrives as session_update.
  2. The sender must already be subscribed to that exact channel. Sending to a channel you have not joined returns pusher:error code 4009.
  3. Both parties must be subscribed before either sends — there is no replay.
const channel = pusher.subscribe(`shopping-session.${sessionId}`);

channel.bind('pusher:subscription_succeeded', () => {
  channel.trigger('client-session_update', {
    sessionId,
    lastUpdatedBy: 'staff',
    timestamp: new Date().toISOString(),
    purchaseAmount: '25.50',
    calculatedPoints: 255,
  });
});

channel.bind('session_update', (p) => renderBasket(p));

A till and the customer app, end to end

Identify the customer

The till opens a QR card session and renders the code. The customer scans it in the app; card_identified arrives on qr-card.{session_id} with their card and balance.

Open the session

The till sends client-session_created on the customer's user.{id} channel. The app switches to the live basket screen.

Scan the basket

Each change goes out as client-session_update on shopping-session.{session_id}. The customer watches the total and the points they are about to earn. If they enter points to redeem, their app sends the same event back with lastUpdatedBy: "customer".

Take payment and close

The till calls POST /{locale}/shop/transactions/create — that is what actually awards the points — then sends client-transaction_completed and client-session_ended.

Event naming

Two conventions coexist. Neither is a typo — copy the name exactly as listed.

StyleExamples
snake_casestatus_update, session_update, card_identified, card_scan_progress
colon:separatednotification:received
kebab-caseimage-upload, mobile-joined, mobile-left

Channel names never contain : — the Pusher protocol forbids it.

Fallback: polling

Only when a WebSocket is genuinely impossible.

FlowEndpoint
QR loginPOST /{locale}/shop/auth/qr-login/poll/{session_id}
QR card scanGET /{locale}/shop/qr-card/status/{session_id}

Both are plain reads; QR login's is a POST for historical reasons, not because it changes anything. Once a second is enough — sessions expire after 5 minutes either way.

Note

The QR login poll predates the shared response envelope: it answers {success, data:{session_id, status, expires_at}}, and on a missing session a 404 with a string code (QR_SESSION_NOT_FOUND) rather than the numeric application code used everywhere else. Handle it on the HTTP status.

There is no polling equivalent for the live basket. That one is realtime or nothing.

On this page