Loyalty & Cashback
- 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:
| Component | What it does |
|---|---|
| Campaign (cashback) | Returns money to the user on qualifying operations |
| Points | Earns points on operations; points convert into balance |
| Tier | Ranks users by spend and grants privileges |
| Badge | Grants 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.
| Field | Description |
|---|---|
asset | Required. The campaign's single asset — every minor-unit field below is denominated in it |
ruleSet | Which operations trigger the campaign |
calculatorType / calculatorParams | How the rebate is computed |
payerType / payerAccountRef | Who funds the rebate |
accrualTiming / accrualDelayDays | Immediate or delayed accrual |
perOperationCapMinor | Cap per operation |
perUserCapMinor | Cap per user |
totalBudgetMinor | Total campaign budget |
minTransactionAmountMinor | Minimum qualifying amount |
stackable / exclusiveGroup / priority | Whether it stacks with other campaigns |
startsAt / endsAt | Validity 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:
| Leaf | Constrains |
|---|---|
operationType | Operation (e.g. PAYMENT_BUY) — single value or array |
currency | Asset |
category | Merchant category (see below) |
amountRange | minMinor / maxMinor amount range |
channel | Channel |
timeWindow | Day/hour/weekend window (Istanbul time) |
firstNOperations | User's first N operations |
frequency | At most N per period (DAY / MONTH) |
userSegment / segment | Derived 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 forThe 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
| Field | Description |
|---|---|
earnKind | Earn type (rate, fixed, multiplier) |
ratePpm | Proportional earn |
fixedPoints | Fixed points |
multiplierBps | Multiplier |
priority | Ordering when several rules match |
ruleSet | Which operations the rule applies to |
Balance and history
GET /api/portal/v1/wallet/me/loyalty/balance
GET /api/portal/v1/wallet/me/loyalty/historyPoints 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| Field | Required | Description |
|---|---|---|
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).
| Field | Description |
|---|---|
metricKind | Which metric is measured |
windowDays | Measurement window |
demotionGraceDays | Grace period before demotion |
tiers[].code / rank / thresholdMinor | Tier code, order and threshold |
tiers[].feeScheduleOverrideId | Tier-specific fee schedule |
tiers[].limitTierBoostId | Tier-specific limit boost |
tiers[].campaignMultiplierBps | Cashback multiplier at that tier |
GET /api/portal/v1/wallet/me/tierA 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.
| Field | Description |
|---|---|
code / name / description / iconRef | Definition |
hidden | Hidden until earned |
repeatable | Can be earned more than once |
rewardCampaignId | Reward triggered on earning |
Rule: triggerEvent / operationType / condition | When it is granted |
GET /api/portal/v1/wallet/me/badgesWebhooks
| Event | When |
|---|---|
wallet.loyalty.earned | Points earned |
wallet.loyalty.redeemed | Points redeemed |
wallet.loyalty.expired | A point lot expired |
wallet.loyalty.tier_changed | The user's tier changed |
wallet.badge.granted | A badge was granted |
wallet.badge.reward_credited | A badge reward was credited |
wallet.campaign.paused_insufficient_funds | Campaign budget exhausted, paused |
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Cashback not paid | Campaign budget exhausted or paused | Subscribe to paused_insufficient_funds |
| Two campaigns stacked unintentionally | stackable on, no exclusiveGroup | Put them in one group and set priority |
| Points dropped unexpectedly | A lot expired | Subscribe to expired and warn users ahead of time |
| Tier did not change | Measurement window or grace period | Check windowDays and demotionGraceDays |