Wallet
Loyalty & Cashback

Loyalty & Cashback

Who calls these endpoints?Portal / SDK
Base path
/api/portal/v1/wallet/me
Identity
Publishable key (pk_live_…) → OTP → customer session. The session belongs to the end user, not to the merchant, and reaches only that user's own data.
Authorization
The session itself is the authorization; a user reaches only their own record.
Cross-surface
The s2s API has NO loyalty endpoint — neither read nor write. The end-user surface is the portal; rules, tiers and badge schemes are managed in the panel (wallet:loyalty:read / wallet:loyalty:manage).

The loyalty engine has four components, all running on the same wallet infrastructure:

ComponentWhat it does
Campaign (cashback)Returns money to the user on qualifying operations
PointsEarns points on operations; points convert into balance
TierRanks users by spend and grants privileges
BadgeGrants behaviour-based achievements, optionally with a reward

Cashback campaigns

A campaign defines which operations earn a rebate, how much, for whom and against which budget.

FieldDescription
assetRequired. The campaign's single asset — every minor-unit field below is denominated in it
ruleSetWhich operations trigger the campaign
calculatorType / calculatorParamsHow the rebate is computed
payerType / payerAccountRefWho funds the rebate
accrualTiming / accrualDelayDaysImmediate or delayed accrual
perOperationCapMinorCap per operation
perUserCapMinorCap per user
totalBudgetMinorTotal campaign budget
minTransactionAmountMinorMinimum qualifying amount
stackable / exclusiveGroup / priorityWhether it stacks with other campaigns
startsAt / endsAtValidity window
🚫

A campaign is single-asset. asset is required, must be one of the program's currencies, and cannot be changed once the campaign exists (there is no update surface).

This has a direct behavioural consequence: an operation in a different asset does not trigger the campaign. Your TRY campaign will not fire on a USD payment — in a multi-currency program, create one campaign per asset.

Why: totalBudgetMinor, perUserCapMinor, perOperationCapMinor and minTransactionAmountMinor are all minor-unit values, and only an asset can say what that minor unit is. Without the asset column, "100 kuruş" and "100 cents" landed in the same budget counter and the total budget cap became meaningless.

⚠️

Budget protection: if the funding account runs dry the campaign is paused automatically and wallet.campaign.paused_insufficient_funds fires. Subscribe to it — a paused campaign silently stops paying rebates and comes back to you as user complaints.

Rule set (ruleSet) axes

ruleSet is a JSON condition tree combined with all / any / not; its leaves say which operation triggers the campaign:

LeafConstrains
operationTypeOperation (e.g. PAYMENT_BUY) — single value or array
currencyAsset
categoryMerchant category (see below)
amountRangeminMinor / maxMinor amount range
channelChannel
timeWindowDay/hour/weekend window (Istanbul time)
firstNOperationsUser's first N operations
frequencyAt most N per period (DAY / MONTH)
userSegment / segmentDerived attribute / static CSV membership

Example — cashback only on grocery spend, excluding entertainment:

{ "all": [
  { "operationType": "PAYMENT_BUY" },
  { "category": "GROCERY" },
  { "not": { "category": "ENTERTAINMENT" } }
] }
⚠️

The category leaf uses the SAME canonical dictionary as the spend-policy category axis (GET spend-policy-categories: GROCERY · FUEL · HEALTH …) and only resolves on merchant-bearing operations (today PAYMENT_BUY). A wallet operation (transfer/topup) or an unclassified merchant has no category ⇒ the category leaf is fail-closed (no match, no cashback). This is deliberate — never pay the wrong recipient.

A category is assigned to a merchant via PUT end-users/:id/merchant-category; a category not in the dictionary is rejected at write time (no dead rule). See Spend Policy — Category.

Card authorization (CARD_AUTH) carries no real MCC today — card-side category campaigns arrive with card issuing (real network MCC mapping).

Campaign creation, versioning, simulation and approval happen in the panel: Panel — Campaigns & Loyalty.

Simulate a campaign in the panel before going live: run it against past operations to see how much budget it would burn. A mistake in the rule set can exhaust the budget within hours in production.

End-user surface

GET /api/portal/v1/wallet/me/cashback           # earned rebates
GET /api/portal/v1/wallet/me/cashback/eligible  # campaigns this user qualifies for

The eligible endpoint exists so you can show the user "which campaigns can I benefit from right now".

Points

Points differ from cashback: they are not money but a separate asset, converted into balance to be spent.

Earn rules

FieldDescription
earnKindEarn type (rate, fixed, multiplier)
ratePpmProportional earn
fixedPointsFixed points
multiplierBpsMultiplier
priorityOrdering when several rules match
ruleSetWhich operations the rule applies to

Balance and history

GET /api/portal/v1/wallet/me/loyalty/balance
GET /api/portal/v1/wallet/me/loyalty/history

Points are held as lots: each accrual creates a lot with its own expiry. Spending consumes the oldest lot first.

⚠️

When lot expiry (lotExpiryDays) is configured, points do not vanish silently — wallet.loyalty.expired fires. Show users the points that are about to expire; that is the only real answer to "my points disappeared".

Redeeming

POST /api/portal/v1/wallet/me/loyalty/redeem
FieldRequiredDescription
loyaltyProgramId✅
walletAccountId✅Account the points convert into
points✅Points to spend
idempotencyKey✅Double-spend protection

The conversion rate (redemptionRatePointsPerMinor) and minimum (minRedeemPoints) are defined on the loyalty program.

Tiers

A tier scheme ranks users by a metric (for example spend over a period).

FieldDescription
metricKindWhich metric is measured
windowDaysMeasurement window
demotionGraceDaysGrace period before demotion
tiers[].code / rank / thresholdMinorTier code, order and threshold
tiers[].feeScheduleOverrideIdTier-specific fee schedule
tiers[].limitTierBoostIdTier-specific limit boost
tiers[].campaignMultiplierBpsCashback multiplier at that tier
GET /api/portal/v1/wallet/me/tier

A tier must mean something concrete: lower fees, higher limits or better cashback. A tier system that only changes a label does not land with users — those three fields (feeScheduleOverrideId, limitTierBoostId, campaignMultiplierBps) exist exactly for this.

demotionGraceDays prevents an instant drop after one quiet period.

Badges

A badge is a behaviour-based achievement and can be tied to a reward campaign.

FieldDescription
code / name / description / iconRefDefinition
hiddenHidden until earned
repeatableCan be earned more than once
rewardCampaignIdReward triggered on earning
Rule: triggerEvent / operationType / conditionWhen it is granted
GET /api/portal/v1/wallet/me/badges

Webhooks

EventWhen
wallet.loyalty.earnedPoints earned
wallet.loyalty.redeemedPoints redeemed
wallet.loyalty.expiredA point lot expired
wallet.loyalty.tier_changedThe user's tier changed
wallet.badge.grantedA badge was granted
wallet.badge.reward_creditedA badge reward was credited
wallet.campaign.paused_insufficient_fundsCampaign budget exhausted, paused

Common mistakes

SymptomCauseFix
Cashback not paidCampaign budget exhausted or pausedSubscribe to paused_insufficient_funds
Two campaigns stacked unintentionallystackable on, no exclusiveGroupPut them in one group and set priority
Points dropped unexpectedlyA lot expiredSubscribe to expired and warn users ahead of time
Tier did not changeMeasurement window or grace periodCheck windowDays and demotionGraceDays