Developer documentation

Integrate FinLoyalty

One integration API for POS terminals, servers and the FinWallet app; a realtime channel for awards and balances; four dependency-free SDKs that expose the same surface.

Getting started

  1. Create an account in the portal (or sign in with the dev seed admin@finloyalty.local / admin123 on a local server).
  2. Under Applications, create an application and copy its key. The seed ships one: demo-app-key-finloyalty-000.
  3. Under Merchants, import from FinConnect or create one by hand. The seed has DEMO-STORE with terminals DEMO-001 and DEMO-002.
  4. Under Programs, pick a preset, assign merchants and activate.
  5. Report a transaction:
curl -X POST http://localhost:4200/v1/transactions \
  -H "X-App-Token: demo-app-key-finloyalty-000" -H "Content-Type: application/json" \
  -d '{"externalId":"POS-000123","merchantId":"DEMO-STORE","terminalId":"DEMO-001",
       "amountCents":1250,"scheme":"MASTERCARD","entryMode":"CONTACTLESS_CARD",
       "maskedPan":"************1234","bin":"510000","issuerCountry":"GR","walletCode":"FW-7K4M-2P2X"}'

Open Live in the portal while you do it: the transaction and its awards arrive over the WebSocket.

Concepts

TermMeaning
AccountThe customer / tenant. Staff, applications, merchants and programs belong to exactly one account. Cross-tenant access answers 404.
ApplicationAn SDK credential (appKey, sent as X-App-Token). Platforms: wallet, pos, web, server.
MerchantA business with terminals (ECR ids). Imported from FinConnect (source: finconnect) or created by hand.
ProgramA configured instance of a loyalty model, scoped to all merchants of the account or a list, optionally to terminals. Status: draft → scheduled/active → paused → ended.
MemberA FinWallet user identified by the Virtual ID code (FW-7K4M-2P2X). Global; balances are per customer account, not per program — every wallet program of the account credits the same balance.
TransactionA payment reported by a POS or a server. Card data is only a masked PAN, the BIN (six digits) and an opaque cardRef. The wallet reports wallet events instead.
Award / Ledger entryThe audit record of every decision: win, no win, points earned, redeemed, reserved, released.

Loyalty models

instant_win — a prize pool released per day (dailyQuota, summing to totalPool) with rollover of unawarded prizes; selection by TIME_SLOTTED windows, EVERY_NTH_TRANSACTION or RANDOM_PROBABILISTIC; eligibility by card (card type or BIN range, chosen per program with cardMatch), issuer country, product, entry mode and amount; caps per card and per terminal per day. Every eligible transaction gets receipt lines (winner or default message) and winners get an audio cue.

pointsearnRate points per euro, rounding (floor or round), minAmountCents and currencies: the earn rule only. The redemption tiers a member can set aside for the next till transaction, the promotional offers, the reservation lifetime (reservationTtlMinutes) and auto-link on the first event (autoLink) are the account's wallet settings (portal → Wallet programs → Settings, GET/PUT /api/wallet/settings), shared by every wallet program of the account — not points-model fields.

The full field list, with types and defaults, is served by GET /api/model-types and drives the portal's editor.

Authentication

Every /v1 request carries the application key in the X-App-Token header (or Authorization: Bearer <key>). The key identifies the account; nothing else is needed. Rotate keys from the portal — the old key stops working immediately. An SDK's base URL may be the host root, https://host/v1 or https://host/api/v1: it appends v1/… itself and drops a trailing version segment, and the server also answers /v1/v1/….

GET /v1/health
X-App-Token: demo-app-key-finloyalty-000

→ 200 {"ok":true,"appId":"…","accountId":"…","platform":"pos"}

SDKs call /v1/health for reachability; the wallet's "provider down" state is driven by it.

The application's platform decides which routes it may call: a wallet key reports wallet events and reads and changes members and claims QR sign-ins; a pos or server key reports and voids transactions, reads merchant programs and fulfils awards; web reads member snapshots and merchant programs. A key on the wrong platform answers 403 {"error":"platform not allowed"}. New applications are created as pos or wallet; web and server are not available yet.

Transactions

POST /v1/transactions

Report a payment. Either merchantId (your external id) or terminalId (an ECR id the merchant owns) is required. Idempotent on externalId per application: a repeat returns the original decisions with duplicate: true.

{
  "externalId": "POS-000123",           // your id, for idempotency and voids
  "merchantId": "DEMO-STORE",           // or terminalId alone
  "terminalId": "DEMO-001",
  "walletCode": "FW-7K4M-2P2X",         // optional: the FinWallet Virtual ID
  "cardRef": "tok_9f1…",                // opaque card token (per-card win caps)
  "maskedPan": "************1234",      // as the terminal prints it
  "bin": "510000",                      // optional: six digits; a BIN_RANGES program uses it, else the masked PAN's prefix
  "scheme": "MASTERCARD",               // the card type; a CARD_TYPE program matches it
  "entryMode": "CONTACTLESS_CARD",      // CONTACTLESS_MOBILE, CONTACTLESS_WEARABLE, CONTACT_CHIP, MAGSTRIPE, MANUAL
  "cardProduct": "DEBIT",
  "issuerCountry": "GR",                // optional: enforced only when sent
  "commercialCard": false,
  "transactionType": "PURCHASE",        // default PURCHASE
  "authResult": "APPROVED",             // default APPROVED
  "amountCents": 1250,
  "currency": "EUR",
  "rrn": "123456789012",
  "occurredAt": "2023-11-04T18:22:31+02:00"
}

Response — one decision per program in scope:

{
  "transactionId": "5c1f…", "merchantId": "m_…", "merchantName": "Demo Store (Glassbox)",
  "decisions": [
    { "programId": "…", "programName": "Mastercard × TIFF 2023", "modelType": "instant_win",
      "outcome": "win", "prize": "DOUBLE_INVITATION", "awardId": "…", "poolRemaining": 143,
      "receiptLines": ["ΣΥΓΧΑΡΗΤΗΡΙΑ, ΚΕΡΔΙΣΑΤΕ ΜΙΑ ΔΙΠΛΗ ΠΡΟΣΚΛΗΣΗ", "…"],
      "audioCue": "MASTERCARD_SOUND",
      "meta": { "dayAvailable": "12", "dayAwarded": "8" } }
  ]
}
outcomeModelMeaning
wininstant_winA prize was released. Print receiptLines, play audioCue, hand the prize over and fulfil the award.
no_wininstant_winEligible, no prize. reason may say why (day_quota_exhausted, pool_exhausted, max_wins_per_card, max_wins_per_terminal). Print the default message.
not_eligibleinstant_winThe card / transaction did not match eligibility; reason names the rule. Usually nothing to print.

Only POS-family programs run on a transaction. Points, welcome bonuses and missions belong to the wallet family and are driven by wallet events, never by this endpoint.

POST /v1/transactions/{id}/void

Reversal. {id} is FinLoyalty's transaction id or your own externalId. Winning awards return their prize to the pool of the day they were awarded on. Returns the transaction with voided: true.

Wallet events

POST /v1/wallet/events

What FinWallet reports: the wallet-family counterpart of /v1/transactions. Wallet applications only. Every live wallet program of the account (points, welcome & referral, missions) sees the event and answers with a decision; the points they move land on the account balance of the wallet.

{
  "externalId": "evt-778f21",           // your id, part of the idempotency key
  "walletCode": "FW-7K4M-2P2X",         // the FinWallet Virtual ID, required
  "type": "payment_settled",            // see the table below
  "occurredAt": "2026-09-10T12:03:00Z", // defaults to the receive time
  "amountCents": 1250,
  "currency": "EUR",                    // default EUR
  "merchantRef": "DEMO-STORE",          // optional: FinLoyalty id or external id
  "terminalId": "DEMO-001",             // optional: resolves the merchant too
  "counterpartyCode": "FW-9Q2R-4T8V",   // the other wallet on a transfer
  "reservationId": "…",                 // payment_settled: consume a held reservation
  "referrerCode": "FW-3M5N-7P1K"        // enrolled: who invited this wallet
}
typeAmountMeaning
payment_settledyesA payment completed. Earns points; redeems a held reservation when the event carries its reservationId.
payment_reversedyesThat payment was reversed. It reuses the settled payment's externalId, and undoes what that event earned, program by program.
transfer_sent / transfer_receivedyesA wallet-to-wallet transfer; counterpartyCode names the other wallet.
request_paidyesA payment request was settled.
split_settledyesA bill split settled.
enrollednoThe wallet joined FinWallet; pays the welcome bonus once and credits referrerCode.

Response — the decisions and the account balance after them:

{
  "eventId": "…",
  "duplicate": false,
  "decisions": [
    { "programId": "…", "programName": "Points", "modelType": "points",
      "outcome": "earn", "points": 12, "balance": 512, "asOf": "2026-09-10T12:03:00Z",
      "meta": { "earned": "12" } }
  ],
  "balance": { "points": 512, "reservedPoints": 0, "asOf": "2026-09-10T12:03:00Z" }
}
outcomeMeaning
earnPoints were credited (points signed, balance and asOf after the move).
redeemA held reservation was consumed; points is negative and reservationId names it.
progressA mission advanced without completing; meta carries "pay5": "2/5".
reverseA payment_reversed event took an earlier earn back.
skippedNothing moved; reason says why (not_linked, out_of_scope, min_amount, currency, not_payment, no_mission).

Idempotency is per application, type and externalId — not per externalId alone, because a payment_reversed event deliberately reuses the settled payment's externalId: it names the same real-world payment. A retry of the exact same triple returns the stored decisions again with duplicate: true instead of evaluating twice. Reversing a payment that was never reported is a 404. An event whose amountCents is above the customer's per-event limit (wallet settings, EUR 10,000 unless changed) is recorded with a skipped decision, reason amount_over_limit, and earns nothing.

Merchant resolution (merchantRef and/or terminalId) is best-effort: an event that names no merchant, or one the account does not own, is still accepted with the merchant left blank — but a program restricted with merchantScope then answers skipped / out_of_scope. A wallet not yet linked to the account is linked on its first event when the account's autoLink is on; otherwise the event is stored with one skipped / not_linked decision and nothing moves.

Merchant programs

GET /v1/merchants/{merchantId}/programs

Active programs at a merchant (by FinLoyalty id or external id), for terminals that want to know what is running before a sale, and for wallets that show "programs here".

{ "merchantId": "m_…", "externalId": "DEMO-STORE", "name": "Demo Store (Glassbox)",
  "programs": [ { "id": "…", "name": "Mastercard × TIFF 2023", "accountId": "…", "accountName": "FinTechIQ Demo",
                  "modelType": "instant_win", "status": "active", "endAt": "2023-11-30T23:59:59Z" } ] }

Terminal registration

POST /v1/terminals/register

POS keys only. A terminal registers itself to learn which programs apply to it and how to render and print their receipts.

{ "terminalId": "DEMO-001", "merchantId": "DEMO-STORE",  // optional when terminalId alone resolves the merchant
  "vatNumber": "EL123456789",                            // optional: identifies the merchant when its id is unknown
  "vendor": "Ingenico", "appVersion": "1.4.2", "printer": true }

The merchant is resolved in the order a POS can supply it: merchantId (a FinLoyalty id or the external id), then vatNumber, then the terminal id. When none of them names a merchant of the account the terminal still registers, unassigned: merchant comes back empty and programs holds the programs that apply to every merchant of the account. The portal lists it so staff can attach it to a merchant.

{
  "terminalId": "DEMO-001",
  "merchant": { "id": "m_…", "externalId": "DEMO-STORE", "name": "Demo Store (Glassbox)" },
  "programs": [
    { "id": "…", "name": "Mastercard × TIFF 2023", "modelType": "instant_win", "status": "active",
      "startAt": "2023-11-01T00:00:00Z", "endAt": "2023-11-30T23:59:59Z", "timezone": "Europe/Athens",
      "receipt": { "logoId": "…", "logoUrl": "https://loyalty.example.com/assets/….png",
        "logoWidth": 480, "logoHeight": 160, "emphasisLines": 2,
        "winnerMessage": ["…"], "winnerAudioCue": "MASTERCARD_SOUND",
        "defaultMessage": ["…"], "nonEligibleMessage": [] } }
  ],
  "refreshAfterSeconds": 3600,
  "serverTime": "2026-09-10T12:03:00Z"
}

Call it at boot, whenever the app key or the base URL changes, again every refreshAfterSeconds, and whenever a transaction decision names a program the terminal does not recognise. Only programs that are active or scheduled and not past their endAt are listed — a scheduled program is included so its logo can be downloaded before it opens. Logos are addressed by logoId and downloaded once: cache the PNG at that id and only fetch it again when the id changes.

Images

GET /assets/{id}.png

Public, no authentication required. The id is content-derived, so the response is immutable — cache it forever (Cache-Control: public, max-age=31536000, immutable).

Awards

POST /v1/awards/{id}/fulfil

Mark a prize as handed over (box office). Returns the award with fulfilled: true and fulfilledAt. Staff can do the same from the program dashboard.

Members

Members are addressed by Virtual ID code; the application vouches for the code it presents (the wallet holds it, the POS read it).

GET /v1/members/{walletCode}

One balance per account, not per program: every wallet program of a customer moves the same pool, so a member sees one number, one set of tiers and one set of offers for each customer they are credited at or have linked to.

{
  "walletCode": "FW-7K4M-2P2X", "linked": true, "asOf": "2026-09-10T12:03:00Z",
  "balances": [
    { "accountId": "…", "accountName": "FinTechIQ Demo",
      "points": 1252, "reservedPoints": 500, "available": 752, "asOf": "2026-09-10T12:03:00Z",
      "tiers": [ { "points": 500, "valueCents": 500, "label": "5 € off" }, { "points": 1000, "valueCents": 1000, "label": "10 € off" } ],
      "offers": [ { "id": "welcome", "title": "5 € off a bill over 25 €", "terms": "500 points" } ],
      "missions": [ { "id": "pay5", "title": "Pay 5 times this month", "progress": 2, "count": 5, "period": "month", "rewardPoints": 100, "completed": false } ],
      "linked": true }
  ],
  "reservations": [
    { "id": "…", "accountId": "…", "points": 500, "valueCents": 500, "label": "5 € off",
      "status": "held", "createdAt": "…", "expiresAt": "2026-09-10T13:03:00Z" }
  ]
}

Show points next to its asOf — always. available is points − reservedPoints. An unknown code returns an empty snapshot with linked: false, which is the wallet's "not linked" state.

GET /v1/members/{walletCode}/offers

[ { "id": "welcome", "title": "5 € off a bill over 25 €", "terms": "500 points", "accountId": "…", "accountName": "FinTechIQ Demo" } ]

GET /v1/members/{walletCode}/activity?limit=100

The ledger, newest first. type is earn, redeem, reserve, release, adjust or reverse; points is signed.

[ { "id": "…", "accountId": "…", "programId": "…", "walletCode": "FW-7K4M-2P2X", "merchantName": "Demo Store (Glassbox)",
    "type": "earn", "points": 12, "balanceAfter": 1252, "eventId": "…", "createdAt": "…" } ]

POST /v1/members/{walletCode}/link

{"accountId": "…"} — enrol the wallet with a customer account, so it earns there even when that account's autoLink is off. A wallet key acts for the code it holds, not for one tenant: any account on the platform may be named, and an unknown one answers 404. Returns the member with linkedAccountIds.

POST /v1/members/{walletCode}/reservations

{"accountId": "…", "tierPoints": 500} — set one of that account's tiers aside for the next payment. Returns 201 with the reservation (status: held, expiresAt); reservedPoints moves on the account balance. The wallet passes that reservationId on the payment_settled event to consume it; unused reservations are released when they expire.

DELETE /v1/members/{walletCode}/reservations/{id}

Release a held reservation. Returns it with status: released.

QR sign-in (portal ⇄ wallet)

The portal's sign-in screen shows a QR whose payload is FLQR:<challenge>@<portal host>. When FinWallet scans it, the app calls:

POST /v1/qr-login/claim

{ "payload": "FLQR:qr_a1b2…@loyalty.example.com", "walletCode": "FW-7K4M-2P2X" }
// or {"challenge": "qr_a1b2…", "walletCode": "…"}
→ 200 { "ok": true, "challenge": "qr_a1b2…" }

The browser that created the challenge is told over login:<challenge> and lands on My rewards. Challenges expire after 3 minutes; a claimed challenge can be exchanged once. The SDKs' claimQrLogin(scannedPayload, walletCode) returns true only when the scan was a FinLoyalty QR, so the wallet's scanner can try it before treating the scan as a Virtual ID.

Errors

Every error is {"error": "message"} with a conventional status: 400 invalid request (a walletCode must look like FW-XXXX-XXXX), 401 missing or unknown key, 404 not found (including anything outside your account), 409 conflict (already voided, already claimed), 410 expired, 429 too many requests (an address that keeps sending unknown keys is refused for a minute; see Retry-After), 501 not configured, and 503 with Retry-After when a report with the same externalId is still being processed — retry with the same externalId to get its result.

Realtime protocol

Plain WebSocket at /ws, JSON frames. Authenticate at upgrade time with ?appKey=<key> (SDKs) or ?token=<jwt> (portal). Anonymous connections may only follow login: topics.

← on connect   {"type":"hello","data":{"kind":"app","accountId":"…"},"ts":1699112551000}
              {"type":"subscribe","topic":"account:<accountId>:awards"}
              {"type":"subscribed","topic":"account:<accountId>:awards","ts":…}
              {"type":"event","topic":"account:…:awards","event":"award.created","data":{…award…},"ts":…}
              {"type":"unsubscribe","topic":"…"}
              {"type":"ping"}         {"type":"pong","ts":…}
              {"type":"error","topic":"…","message":"subscription not allowed"}

Reconnect with backoff (1 s → 30 s) and resubscribe; the server pings every 30 s and drops a connection silent for 90 s. Every consumer has a polling fallback (GET /api/auth/qr/{c}, GET /v1/members/{code}).

Topics & events

TopicWho may subscribeEvents · data
login:<challenge>anyone holding the challenge idlogin.claimed · {challenge, walletCode, appId}
account:<id>:transactionsstaff and apps of the accounttransaction.received, transaction.voided · the transaction with its decisions
account:<id>:awardsstaff and apps of the accountaward.created, award.voided, award.fulfilled · the award
account:<id>:walletstaff of the account (not apps: the feed carries every member's events)event.received · the wallet event with its decisions
account:<id>:importstaffimport.progress, import.finished · the import job {status, counts:{nodes,records,merchants,created,updated}, error}
program:<id>staff and apps of the owning accountaward.created, award.voided, award.fulfilled, program.updated (the program), program.state
member:<walletCode>that member (portal), any wallet app presenting the codeevent.received · the wallet event; balance.updated · {walletCode, accountId, points, asOf}; reservation.updated · the reservation; award.created · the award

JavaScript · @fintechiq/finloyalty

ESM, browser and Node ≥ 18, fetch + WebSocket, no dependencies.

// npm install @fintechiq/finloyalty
import { FinLoyalty } from '@fintechiq/finloyalty';

FinLoyalty.init('YOUR-APP-KEY', { apiUrl: 'https://loyalty.example.com/', timeoutMs: 2500 });

// POS: register the terminal once, then evaluate every sale as the terminal reported it
await FinLoyalty.registerTerminal({ terminalId: 'DEMO-001', merchantId: 'DEMO-STORE', vendor: 'NexiPos', appVersion: '1.4.2', printer: true });
const { presentation: p } = await FinLoyalty.evaluateSale({
  externalId: 'POS-000123', merchantId: 'DEMO-STORE', terminalId: 'DEMO-001', amountCents: 1250, approved: true,
  schemeText: 'MASTERCARD', captureText: 'CTLS', maskedPan: '************1234', bin: '510000',
  productLabel: 'Debit Mastercard', vendor: 'NexiPos',
});
switch (p.outcome) {
  case 'win': show(logos.get(p.logoId), p.headline, p.body); play(p.audioCue); break;
  case 'no_win': show(null, [], p.body); break;
  default: // 'none', 'not_eligible', 'fail_safe': nothing promotional
}

// With a wallet application key: balances (one per customer account) and activity
const me = await FinLoyalty.getMember('FW-7K4M-2P2X');
const activity = await FinLoyalty.getActivity('FW-7K4M-2P2X', { limit: 20 });

// Realtime
const off = FinLoyalty.subscribe('member:FW-7K4M-2P2X', (ev) => { if (ev.event === 'balance.updated') refresh(); });

// Reachability → "provider down" state
const state = await FinLoyalty.health();   // 'up' | 'down'

registerTerminal caches the programs that apply at the terminal with their receipt blocks (logo, emphasised lines), which evaluateSale uses to split a win into headline and body; downloadLogo(receipt.logoUrl) fetches each logo once per logoId, without the app key. evaluateSale and reportTransaction answer a fail-safe result on a timeout, a network error or a 5xx; only a 4xx rejects. An empty app key makes the SDK dormant: every call resolves to a neutral result and nothing is sent, so a build without the key never throws.

Go · github.com/fintechiq/finloyalty-sdk-go

// go get github.com/fintechiq/finloyalty-sdk-go
import finloyalty "github.com/fintechiq/finloyalty-sdk-go"

client, err := finloyalty.New(finloyalty.Config{AppKey: "YOUR-APP-KEY", APIURL: "https://loyalty.example.com/", Timeout: 2500 * time.Millisecond})

// POS: register the terminal once, then evaluate every sale as the terminal reported it
if _, err := client.RegisterTerminal(ctx, finloyalty.TerminalRegistration{TerminalID: "DEMO-001", MerchantID: "DEMO-STORE", Vendor: "NexiPos"}); err != nil {
    log.Println("keeping the last good config:", err)
}
out, err := client.EvaluateSale(ctx, finloyalty.PosSale{
    ExternalID: "POS-000123", MerchantID: "DEMO-STORE", TerminalID: "DEMO-001", AmountCents: 1250, Approved: true,
    SchemeText: "MASTERCARD", CaptureText: "CTLS", MaskedPan: "************1234", BIN: "510000",
    ProductLabel: "Debit Mastercard", Vendor: "NexiPos",
})
if err != nil { /* only a 4xx: an integration bug; a timeout presents PresentFailSafe */ }
switch p := out.Presentation; p.Outcome {
case finloyalty.PresentWin:
    show(logos[p.LogoID], p.Headline, p.Body)
    play(p.AudioCue)
case finloyalty.PresentNoWin:
    show(nil, nil, p.Body)
}

// With a wallet application key: balances, one per customer account
member, _ := client.GetMember(ctx, "FW-7K4M-2P2X")
for _, b := range member.Balances { fmt.Println(b.AccountName, b.Points, "as of", b.AsOf) }
cancel := client.Subscribe(finloyalty.TopicMember("FW-7K4M-2P2X"), func(ev finloyalty.Event) { log.Println(ev.Event) })
defer cancel()

RegisterTerminal caches the programs with their receipt blocks for EvaluateSale; DownloadLogo(ctx, receipt.LogoURL) fetches each logo once per LogoID, without the app key. Only net/http and a minimal RFC 6455 client; no third-party modules.

Android · Kotlin · gr.fintechiq.finloyalty.FinLoyalty

// app/build.gradle.kts — the AAR checked in under app/libs
implementation(files("libs/finloyalty-sdk-release.aar"))

// Application.onCreate — empty key = dormant
FinLoyalty.setApiUrl("https://loyalty.example.com/")
FinLoyalty.init(BuildConfig.FINLOYALTY_KEY, timeoutMs = 2500)

// POS bridge app, on a background thread: register the terminal once, then evaluate every sale
FinLoyalty.registerTerminal(TerminalRegistration("DEMO-001", "DEMO-STORE", vendor = "NexiPos"))
val p = FinLoyalty.evaluateSale(PosSale(
    externalId = "POS-000123", merchantId = "DEMO-STORE", terminalId = "DEMO-001", amountCents = 1250, approved = true,
    schemeText = "MASTERCARD", maskedPan = "************1234", bin = "510000", captureText = "CTLS",
    productLabel = "Debit Mastercard", vendor = "NexiPos"
)).presentation
when (p.outcome) {
    LoyaltyPresentation.WIN -> { show(logos[p.logoId], p.headline, p.body); play(p.audioCue) }
    LoyaltyPresentation.NO_WIN -> show(null, emptyList(), p.body)
    else -> Unit   // NONE, NOT_ELIGIBLE, FAIL_SAFE: nothing promotional
}

// FinWallet, wallet key: report a settled payment; when fail-safe, keep it and send it again with the same externalId
val result = FinLoyalty.reportEvent(WalletEvent(payment.id, walletCode, WalletEvent.PAYMENT_SETTLED,
    amountCents = payment.amountCents, merchantRef = payment.merchantId))
if (result.failSafe && result.failReason != "dormant") outbox.keep(payment.id)

// Rewards screens: three states (linked / not linked / provider down), one balance per customer account
FinLoyalty.rewardsState(walletCode) { state -> render(state) }
FinLoyalty.reserve(walletCode, accountId, 500) { reservation, error -> showReservation(reservation, error) }
FinLoyalty.subscribe("member:$walletCode") { event -> if (event.event == "balance.updated") reload() }

// Scanner: a FinLoyalty portal QR before the Virtual ID path
if (FinLoyalty.isFinLoyaltyQr(scannedText)) FinLoyalty.claimQrLogin(scannedText, walletCode) { ok -> toast(ok) } else handleVirtualId(scannedText)

Kotlin object with @JvmStatic members: synchronous calls for background threads, callback overloads for the rest; HttpURLConnection for HTTP and its own RFC 6455 client for the WebSocket; no android.* imports. registerTerminal caches the programs with their receipt blocks for evaluateSale; downloadLogo(receipt.logoUrl) fetches each logo once per logoId, without the app key.

iOS · Swift · FinLoyalty (SwiftPM)

// Package.swift — by path (or Xcode → Add Package Dependency), product FinLoyalty
.package(path: "../FinLoyalty/source/client-sdks/ios/finloyalty-sdk")

import FinLoyalty

FinLoyalty.configure(apiURL: "https://loyalty.example.com/")
FinLoyalty.activate(appKey: Secrets.finLoyaltyAppKey)   // empty key = dormant

// POS bridge app: register the terminal once, then evaluate every sale
_ = try await FinLoyalty.registerTerminal(TerminalRegistration(terminalId: "DEMO-001", merchantId: "DEMO-STORE", vendor: "NexiPos"))
let p = try await FinLoyalty.evaluateSale(PosSale(
    externalId: "POS-000123", merchantId: "DEMO-STORE", terminalId: "DEMO-001", amountCents: 1250, approved: true,
    schemeText: "MASTERCARD", maskedPan: "************1234", bin: "510000", captureText: "CTLS",
    productLabel: "Debit Mastercard", vendor: "NexiPos"
)).presentation
switch p.outcome {
case LoyaltyPresentation.outcomeWin: show(logos[p.logoId], p.headline, p.body); play(p.audioCue)
case LoyaltyPresentation.outcomeNoWin: show(nil, [], p.body)
default: break   // none, not_eligible, fail_safe: nothing promotional
}

// FinWallet, wallet key: report a settled payment; when fail-safe, keep it and send it again with the same externalId
let result = try await FinLoyalty.reportEvent(WalletEvent(externalId: payment.id, walletCode: code,
    type: WalletEvent.paymentSettled, amountCents: payment.amountCents, merchantRef: payment.merchantId))
if result.failSafe && result.failReason != "dormant" { outbox.keep(payment.id) }

let member = try await FinLoyalty.getMember(walletCode: code)   // one balance per customer account
for b in member?.balances ?? [] { print(b.accountName ?? b.accountId, b.points, "as of", b.asOf ?? Date()) }
let reservation = try await FinLoyalty.reserve(walletCode: code, accountId: accountId, tierPoints: 500)
let sub = FinLoyalty.subscribe(topic: "member:FW-7K4M-2P2X") { event in Task { await reload() } }

if await FinLoyalty.claimQrLogin(payload: scanned, walletCode: code) { /* portal signed in */ }

iOS 15+ / macOS 12+, URLSession and URLSessionWebSocketTask, async/await throughout. registerTerminal caches the programs with their receipt blocks for evaluateSale; downloadLogo(url: receipt.logoUrl) fetches each logo once per logoId, without the app key.

Portal API (/api/v1)

What the portal SPA uses; authenticated by the finloyalty_token JWT cookie or a Bearer token. Useful for automation from a staff account. The same routes are also served under the unversioned /api, kept for clients written before the version existed.

AreaEndpoints
AuthPOST auth/register · login · logout · forgot · reset, GET auth/session, POST auth/qr, GET auth/qr/{c}, POST auth/qr/{c}/exchange
ApplicationsGET/POST apps, GET/PATCH/DELETE apps/{id}, POST apps/{id}/key/rotate
MerchantsGET merchants?search=&program=&offset=&limit={items,total}, POST merchants, GET/PATCH merchants/{id}, POST merchants/import, GET merchants/import/status
TerminalsGET terminals?merchantId=
ImagesPOST assets (admin only; the body is the PNG itself, Content-Type: image/png) → {id,url,width,height,size}
ProgramsGET/POST programs (?preset=), GET/PUT/DELETE programs/{id}, POST programs/{id}/status, POST programs/{id}/merchants, GET programs/{id}/state · awards · awards.csv · transactions · members · report · report.csv, POST programs/{id}/state/reset
WalletGET/PUT wallet/settings (PUT admin only), GET wallet/events?walletCode=&limit=
LedgerGET programs/{id}/ledger?limit= (wallet programs)
ActivityGET transactions, POST transactions/{id}/void, GET awards, POST awards/{id}/fulfil
UsersGET users, POST users/{id}/role · active, POST invites {email, role} (role admin or member, into the session's customer), DELETE invites/{token}, GET invites/peek, POST invites/accept
Member portalGET me · me/offers · me/activity · me/accounts, POST me/accounts/{id}/link · unlink, POST me/reservations {accountId, tierPoints}, DELETE me/reservations/{id}
PlatformGET platform/overview,POST platform/customers/{id}/open (re-issues the session in that customer), GET platform/staff (every customer's users, read-only), GET platform/users (administrators and pending invites), POST platform/users {email} (promotes an existing user; an email without an account gets a platform-administrator invite into the sender's own customer, 201), POST platform/users/{id}/revoke, DELETE platform/invites/{token}
PublicGET model-types, GET presets