Panel — Campaigns & Loyalty
- Base path
/api/panel/v1/wallet- Identity
- Panel session (JWT) —
Authorization: Bearer <panel access token>. The token comes from panel login; an s2s signing key does NOT open these endpoints. - Authorization
- RBAC permission:
wallet:campaign:manage·wallet:loyalty:read·wallet:loyalty:manage·wallet:report:readFour-eyes: the requester and the approver cannot be the same person — this is enforced by identity comparison, not by a role gate. - Cross-surface
- The endpoints on this page have NO equivalent in the s2s API; you cannot call them from your own panel. These capabilities exist only in the merchant panel, behind a panel session.
Where retention tooling is managed. Campaigns give money away, so they are versioned, simulated and approved.
Campaigns
GET campaigns
POST campaigns
GET campaigns/:id
POST campaigns/:id/versions
PATCH campaigns/versions/:versionId
POST campaigns/versions/:versionId/submit
POST campaigns/versions/:versionId/approve
POST campaigns/:id/status
PATCH campaigns/:id/payer
POST campaigns/:id/simulate
GET campaigns/:id/reportSimulate before going live
POST campaigns/:id/simulateAlways simulate. Simulation runs the rule set against past operations and shows how many would have matched and how much budget it would have burned. A mistake in the rule set (a wrong operation-type filter, say) can exhaust the budget within hours in production.
Simulation accepts a date range, a sample limit and — optionally — a rule set/calculator that has not been saved yet.
The rule set (ruleSet) is a JSON condition tree (all / any / not + leaf predicates: operationType, currency, category, amountRange, channel, timeWindow, firstNOperations, frequency, userSegment / segment). The rule editor lists the available categories beneath it (the same canonical dictionary as spend policy).
The category leaf only resolves on merchant-bearing operations (today PAYMENT_BUY); for an unclassified merchant or a wallet operation it is fail-closed (no cashback). Full axis list and examples: Loyalty & Cashback — Rule set axes. Simulation feeds category exactly like the live path, so a category-targeted campaign looks realistic in simulation.
Budget and funding — the payer
PATCH campaigns/:id/payerDeclares who funds the campaign. There are three payer types and not all of them are available on every deployment:
payerType | Meaning | Selectable | Can go live |
|---|---|---|---|
TENANT | The merchant pays from its own funded wallet account | Every deployment | ✅ Yes |
SYSTEM | The platform's funded account pays | ON_PREM only | ✅ Yes |
SPLIT | Merchant + platform co-fund | ON_PREM only | ❌ No — it is stored and simulated, but cannot be activated |
On a SaaS deployment only TENANT is accepted. A request carrying SYSTEM or SPLIT is rejected in the service layer — the panel hiding an option is not a security control; server-to-server and direct service calls bypass that layer. The decision is read from the program's deploymentMode, which is pinned at provisioning; there is no flag or environment variable, and the field cannot be changed afterwards.
Rationale: on SaaS the "platform" is Lunixi. Both SYSTEM and SPLIT would mean "Lunixi funds the tenant's campaign".
SPLIT cannot be activated — this is a deliberate decision, not "not written yet". The ledger v1.1 contract is frozen and a single transfer carries exactly one debit source. Splitting co-funding into two transfers would open a half-paid cashback window where one leg posted and the other did not. The accounting meaning of co-funding (whose expense, who gets invoiced) is a separate decision as well.
A SPLIT configuration is stored and can be simulated; activation is refused when you try to move the campaign to ACTIVE.
The server is the authority — do not invent your own list
The response answers two separate questions with two separate fields:
| Field | Question | Contents |
|---|---|---|
selectablePayerTypes | "What may I choose?" | The types that can be stored on this program. null = not computed in this response (not an array!) — read selectableCampaignPayerTypes from the program detail endpoint instead |
deploymentMode | "Why this list?" | SAAS | ON_PREM — read-only, pinned at provisioning |
activationAllowed | "Can I take it live?" | true / false |
activationBlockedReason | "Why not?" | The reason text when blocked, otherwise null |
Selectable ≠ activatable. On ON_PREM, SPLIT appears in selectablePayerTypes but returns activationAllowed=false. Collapsing the two into one field would force the panel to either hide SPLIT entirely or offer an option the user can store but never activate.
Do not build your payer picker from a hard-coded list of three: when the server rule changes your list goes stale silently and you show the user an option they cannot save. Read selectableCampaignPayerTypes when loading the program, and selectablePayerTypes when writing the payer.
payerAccountRef is required for every payer type
The funding wallet account (payerAccountRef) is required for every payer type. It used to be optional for SYSTEM, and that was a silent non-payment defect: with no account named, the engine kept accruing cashback that could never be posted and stayed RESERVED forever. The campaign looked live, the user was told "you earned cashback", and the money never arrived.
It is now refused at configuration time, and verified a second time at activation: for a TENANT payer, the account is proven to belong to this tenant and to be ACTIVE inside the approval transaction — an account frozen or closed between configuration and activation stops the activation.
| Payer type | Required fields | Account ownership |
|---|---|---|
SYSTEM | payerAccountRef | Not checked — the account is the platform's by definition; only its format is validated |
TENANT | payerAccountRef | Must be an ACTIVE wallet account owned by this tenant |
SPLIT | payerAccountRef + payerSplitTenantBps (1–9999) + platformPayerAccountRef | The merchant leg's ownership is verified; the two legs must be distinct accounts |
The payer of an ACTIVE campaign cannot be changed — pause it first.
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 and returns to you as user complaints.
Status control
POST campaigns/:id/statusPause, resume or end a campaign.
Segments
GET segments
POST segments
DELETE segments/:id
GET segments/:id/members
POST segments/:id/members # upload members by CSV, { csv, replace? }Segments target campaigns at a specific set of customers.
replace: true replaces the member list entirely; false appends. The wrong choice silently widens or empties your audience — note the current member count before uploading.
Loyalty program
GET loyalty/programs
PUT loyalty/programs
GET loyalty/earn-rules
PUT loyalty/earn-rules
GET loyalty/balance
GET loyalty/historyThe loyalty program defines the points asset, conversion rate, minimum redemption, lot expiry and funding account.
When setting lot expiry (lotExpiryDays), make sure you can show it to users. Expiring points emit wallet.loyalty.expired; without advance warning that converts straight into complaints.
Tier schemes
GET loyalty/tier-schemes
PUT loyalty/tier-schemes
GET loyalty/tierTier thresholds and the privileges of each tier: fee schedule override, limit boost, campaign multiplier.
Make tiers mean something concrete. A tier system that only changes a label does not land with users.
Badges
GET loyalty/badge-definitions
PUT loyalty/badges
GET loyalty/badges
PUT loyalty/badge-rulesBadge definition (code, name, icon, hidden, repeatable, reward campaign) and rule (trigger event, operation type, condition).
Reporting
GET campaigns/:id/report
GET reports/campaignsHow many operations a campaign touched and how much budget it consumed. See Panel — Reports.
Checklist
- The rule set was simulated and the match count looks sane
- Total budget, per-user and per-operation caps are set
- The funding account has sufficient balance
-
paused_insufficient_fundsis subscribed to - Overlap with other campaigns (
stackable/exclusiveGroup) was checked - Start and end dates are correct
- A different operator approved it