Wallet
Panel Guide
Campaigns & Loyalty

Panel — Campaigns & Loyalty

Who calls these endpoints?Merchant panel
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/report

Simulate before going live

POST campaigns/:id/simulate
⚠️

Always 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/payer

Declares who funds the campaign. There are three payer types and not all of them are available on every deployment:

payerTypeMeaningSelectableCan go live
TENANTThe merchant pays from its own funded wallet accountEvery deployment✅ Yes
SYSTEMThe platform's funded account paysON_PREM only✅ Yes
SPLITMerchant + platform co-fundON_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:

FieldQuestionContents
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 typeRequired fieldsAccount ownership
SYSTEMpayerAccountRefNot checked — the account is the platform's by definition; only its format is validated
TENANTpayerAccountRefMust be an ACTIVE wallet account owned by this tenant
SPLITpayerAccountRef + payerSplitTenantBps (1–9999) + platformPayerAccountRefThe 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/status

Pause, 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/history

The 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/tier

Tier 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-rules

Badge definition (code, name, icon, hidden, repeatable, reward campaign) and rule (trigger event, operation type, condition).

Reporting

GET campaigns/:id/report
GET reports/campaigns

How 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_funds is subscribed to
  • Overlap with other campaigns (stackable / exclusiveGroup) was checked
  • Start and end dates are correct
  • A different operator approved it