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.
| Event | When |
|---|---|
status_update | Every 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 }
}status | Meaning | Extra fields |
|---|---|---|
scanned | Customer scanned; waiting for them to confirm in the app | — |
authenticated | Done | user, token, refresh_token, and loyalty_card when a partner is bound |
failed | The app could not complete the login | — |
expired | The 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.
| Event | When | Bind it? |
|---|---|---|
card_identified | The customer scanned | Yes |
status_update | Same moment, overlapping body | Legacy — 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.
| Event | Sent by |
|---|---|
session_update | Either 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.
| Event | Sent by | Payload |
|---|---|---|
card_scan_progress | Server | scan_id, step, percent, message |
session_created | Till | Full session: sessionId, cardId, shopId, staffId, customerInfo{…}, staffInfo{…}, shopInfo{…}, pointsRules, status: "editing" |
session_ended | Till | session_ended: true, sessionId, timestamp, and the basket fields reset |
transaction_completed | Till | transaction_id, amount, currency, points_awarded, points_redeemed, points_discount_amount, final_amount |
user.{id}.notifications
| Event | Payload |
|---|---|
notification:received | notification_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:
- The event name must start with
client-. Receivers bind the name without the prefix —trigger('client-session_update', …)arrives assession_update. - The sender must already be subscribed to that exact channel. Sending to a
channel you have not joined returns
pusher:errorcode4009. - 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.
| Style | Examples |
|---|---|
snake_case | status_update, session_update, card_identified, card_scan_progress |
colon:separated | notification:received |
kebab-case | image-upload, mobile-joined, mobile-left |
Channel names never contain : — the Pusher protocol forbids it.
Fallback: polling
Only when a WebSocket is genuinely impossible.
| Flow | Endpoint |
|---|---|
| QR login | POST /{locale}/shop/auth/qr-login/poll/{session_id} |
| QR card scan | GET /{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.