Appearance
Loyalty Program
Overview
The Loyalty Program rewards customers for repeat purchases. Customers earn points (or stars, or a custom unit) on orders and unlock rewards once their balance reaches a reward's fixed threshold.
Key Purpose: Encourage repeat business through threshold-based rewards.
Purpose
This page lets you configure a loyalty program that awards points to customers based on their spending or visits, and define rewards they can unlock once they accumulate enough points.
Key Concepts
- Amount-Based Program: Customers earn points proportional to the order total, calculated as order total multiplied by the configured
points_earned_each_dollarrate (there is no subtotal option, and no per-category exclusion). - Visit-Based Program: Customers earn a fixed number of points (
points_earned_each_visit) per completed order regardless of the amount spent, optionally requiring a minimum spend threshold (using_minimum_spend/minimum_spend_to_earn_points). - Reward Redemption (threshold-based): Each reward defines a fixed
points_neededthreshold (orvisits_neededfor visit-based programs). A reward unlocks once the customer's balance reaches that threshold, and redeeming it deducts the required points. There is no point-to-currency conversion, no point value, and no maximum-redemption percentage cap. Rewards are either a Discount (percentage or fixed amount) or a Free Item with selected eligible items. - Reward Unit: The points are labelled by the configured
reward_unit— Points, Stars, or a Custom singular/plural label (e.g. "Bean"/"Beans"). - Bonus Time Multiplier: During configured bonus windows (specific days, optionally a start/end time), earned points are automatically doubled or tripled based on
specific_day_time_bonus_amount(Double Points / Triple Points). - Suspicious Activity Flag: When
program_alertis on, if a customer completes more than 3 orders at the same location in a single day (location timezone), the customer is flagged as suspicious and an alert email is sent to the merchant. - Paused: A per-location on/off switch on the Loyalty overview. A paused program stops earning, redemption and bonuses immediately while keeping every existing point balance intact. Distinct from a program that cannot run because the connected POS does not support loyalty.
Actions
Configure Loyalty Program
Set the reward unit (Points/Stars/Custom), program type (Amount Based or Visit Based), earning rate, point expiry period (in months), sign-up bonus, birthday bonus, and day/time bonus settings via the 5-step wizard. Loyalty is free of charge — completing the wizard's final "Review & Create" step saves and activates the program directly for that location; there is no payment or subscription step. In production, activation is available only for locations whose connected POS is on the supported allowlist (MplusKassa, or Kassanet — Hendrickx/Vanhoutte); other POS integrations are blocked with a "Loyalty is not yet available for this POS integration" error. Test/staging environments are not restricted by POS.
On a MplusKassa-linked merchant the point-expiry step is not shown. See Point expiry does not apply to MplusKassa merchants.
Once a program exists it can be edited -- every step stays reachable from the overview -- and it can be paused with the switch in the page header (see below). There is still no delete control: a configured program cannot be removed from a location in the back office. If what you are trying to switch off is your location rather than the loyalty program, that is a different page: Settings → Locations → Status.
Pause or resume the program
The Loyalty overview carries an on/off switch, showing Running or Paused. Pausing asks for confirmation first, because it takes effect immediately; resuming goes straight through. While the program is paused a notice stays visible on the page, since every other control on it still reads as normal.
While paused:
- Customers stop earning points on new orders.
- Customers cannot redeem rewards, and bonuses (sign-up, birthday, day/time) are not applied.
- Existing point balances are kept and are available again as soon as you turn the program back on. Pausing deletes nothing.
- Loyalty disappears from the ordering channels — the storefront, the kiosk and the receipt settings all stop offering it — because pausing switches off the same signal that tells them a program is running.
The switch is disabled when the location's connected POS does not support loyalty. That is a separate condition from pausing: a program can be inactive either because you paused it or because the POS integration is not on the supported list, and the page distinguishes the two rather than showing both as simply "off".
Pausing requires the same permission as editing the program (edit-loyalty), and is set per location.
Manage Rewards
Create, update, or delete rewards that customers unlock with their points. Each reward is a Discount (percentage or fixed amount) or a Free Item with eligible item selection, and defines a points_needed (amount-based) or visits_needed (visit-based) threshold.
Manage Birthday Bonuses
Configure a birthday bonus reward per location, including discount type, value, and eligible items for free-item bonuses.
Manual point adjustments
A customer's balance can be changed by hand from Customers -> (a customer) -> Loyalty Points -> Adjust points, not from the Loyalty page. Four modes:
| Mode | What it does | Effect on lifetime points |
|---|---|---|
| Grant | Writes a new point bucket with source manual_grant. Expiry follows the program's own months_to_expire_points setting, exactly like an earned bucket — and, like an earned bucket, does not expire at all on a MplusKassa-linked merchant. | Raises it |
| Deduct | Writes a point_redemption document with source manual_deduct and consumes buckets soonest-expiring first through the same allocator a reward redemption uses (see Which points are spent first). It is not a negative bucket, so the balance can never go negative. | Unchanged |
| Set | Not a distinct write — the server compares the target against the current balance and turns it into a grant or a deduct. A difference smaller than 0.005 is a no-op: no ledger row, no activity-log entry, just a 200 with the unchanged balances. | Follows whichever it became |
| Reset | Voids every non-voided bucket — exhausted and already-expired ones included — and writes one loyalty_reset audit document. | Zeroes it |
Every mode requires a reason (3–500 characters); it is stored on the ledger row and shown in the tab. Grant/deduct/set also take a whole-number points value (1–1,000,000; set allows 0).
A deduct cannot overdraw. Asking for more than the customer has is rejected with "This customer only has N points available." (HTTP 422) — nothing is written.
Reset zeroes lifetime, and that is deliberate. Lifetime points are summed over non-voided buckets, so a reset that left exhausted buckets alone would leave a "cleared" customer still reporting thousands of lifetime points. The one case that writes nothing at all is a customer with no non-voided bucket whatsoever.
Permission: adjust-customer-points, in the Customer Management group. No default role holds it — out of the box only Merchant Owner can adjust a balance. See Permissions.
Which points are spent first
Points are earned in buckets, each with its own expiry date. Whenever points are spent — redeeming a reward, a manual deduct, or a redemption at the till — buckets are drained soonest-expiring first. Buckets that never expire are drained last, and where two buckets share an expiry the older one goes first.
This is what protects the balance: spending a later-expiring bucket ahead of an earlier one would let the earlier one expire carrying points the customer had already effectively spent, and the balance would drop with nothing to show for it.
The order only makes a visible difference where some of a customer's buckets carry an expiry date. Where none do — a program that has never used expiry, and every MplusKassa-linked merchant, where expiry does not apply at all — every bucket sorts equally and the order falls back to oldest-first. A customer who holds both kinds at once (for instance because expiry was switched on or off partway through) has their dated buckets spent before their never-expiring ones.
(Verified: upvendo-backend app/Repositories/LoyaltyPointRepository.php:60-76 — allocatableFor() sorts by expiry, then created_at, then _id, projecting never-expiring buckets to the end; inherited by all three allocator entry points at app/Services/Loyalty/LoyaltyService.php:238,295,419.)
Point ledger
The Loyalty Points tab on the customer detail merges three record types into one dated, paginated list. Row types:
| Type | Row means |
|---|---|
earn_transaction | Points earned on an order |
earn_signup | The sign-up bonus |
manual_grant | A back-office grant |
redemption | A reward redeemed (row names the reward) |
manual_deduct | A back-office deduction |
reset | A balance reset |
pos_import | A balance brought in from the POS when the customer's points were first taken over from the till. Deliberately not shown as a grant or an order — nobody granted these points and no Upvendo order produced them |
pos_sync | A balance change picked up from the POS by a Pull from POS or by the nightly sweep |
pos_redeem | A redemption that happened at the till rather than through Upvendo |
Earn rows carry a remaining figure (what is left of that bucket) and, on rows written since the ledger shipped, a breakdown of how the points were calculated — program type, rate, base amount, bonus multiplier, whether it fell in a bonus window, and the reward unit in force at the time. Older rows have no breakdown and say so; they are not recalculated retroactively.
Expanding a row also shows which points paid for what: a redemption lists the buckets that paid for it, an earn row lists the redemptions that consumed it. Where the split across several buckets was never recorded, the row says the amount was not recorded rather than showing 0 — those are two different things. Where the share was reconstructed rather than recorded, it carries an estimated marker, so a merchant is never shown an inferred figure as if it were a recorded one.
Both remaining and the displayed points are rounded to 2 decimals, and remaining never renders below zero. On a merchant without a MplusKassa integration, earned points are not whole numbers — an amount-based program multiplies the rate by the order total with no rounding — so fractional balances are normal. On a MplusKassa-linked merchant, earned points are rounded to whole points at the moment they are earned, so the Upvendo balance matches the till's counter instead of drifting from it by a fraction; the exact unrounded figure is kept on the row's breakdown so the ledger can still explain, say, 46.1 becoming 46. (Verified: upvendo-backend app/Services/Loyalty/LoyaltyService.php, rounding applied only when the MplusKassa loyalty gate is active for the merchant.)
MplusKassa-linked customers: each adjustment is also pushed to Mplus and the row shows whether that push synced or failed. A failed push does not fail the adjustment; the nightly mplus:reconcile-points sweep is the backstop. Mplus' own API is whole-number only, so a fractional grant or deduct is rounded on the way out and the sub-point difference is settled by that same nightly sweep. A push that failed at the time but was later settled by that sweep reads Reconciled manually rather than staying a permanent failure.
Pull points from the POS
For a customer linked to MplusKassa, the Loyalty Points tab carries a Pull from POS button beside Adjust points. It fetches that customer's balance from the till and brings any difference into the Upvendo ledger as a pos_sync row, so points a customer earned or spent at the register show up in Upvendo straight away. The button appears only for customers who are actually linked to Mplus, and it works for any Mplus-linked location — it does not depend on the overnight sync having been set up for that merchant.
The result is reported back on screen, and "nothing happened" has several distinct causes worth telling apart: the balances already agreed; the POS answered but holds no relation under that customer's number (deleted at the till, or a stale link — that needs re-linking, not retrying); or the pull is deferred because Upvendo's balance is higher than the POS's, which is settled in the other direction rather than by overwriting the customer's points.
Same permission as a manual adjustment: adjust-customer-points. (Verified: upvendo-backend routes/api/backoffice/customers.php line 43 → CustomerController::pullLoyaltyPoints, gated on the same permission as /loyalty-points/adjust; upvendo-backoffice src/views/marketing/customers/components/LoyaltyPointsTable.vue renders the button only when the customer has an Mplus link.)
Point expiry does not apply to MplusKassa merchants
Where a MplusKassa POS is connected, the till owns the points balance — it is a plain counter with no concept of dated buckets, so an expiry period cannot be applied to it. Upvendo therefore does not offer the setting at all for these merchants:
- The wizard hides the whole point-expiry step rather than showing a switch that would do nothing, and the final summary omits the expiry row entirely (showing "–" would read as "expiry is set to off", when the truth is that expiry does not apply here).
- Saving a program with expiry switched on is refused for these merchants, rather than being silently accepted and then ignored.
- A merchant who configured expiry before connecting MplusKassa keeps the stored value, but it is not applied — no points are expired. The setting is normalised away the next time the program is opened, so it does not block an otherwise untouched save.
This is a safety rule, not a limitation: expiring points in Upvendo pushes a matching deduction to the till, so honouring expiry here would silently destroy points customers earned at the register and never spent through Upvendo at all.
Merchants without a MplusKassa integration — the large majority — are unaffected and configure expiry normally. (Verified: upvendo-backend app/Support/Loyalty/MplusLoyaltyGate.php, app/Http/Resources/BackOffice/Loyalty/LoyaltyResource.php, app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php, and the three expiry call sites in app/Services/Loyalty/LoyaltyService.php; upvendo-backoffice Step2CreateLoyalty.vue, Step5CreateLoyalty.vue, src/store/modules/loyalty.ts.)
Location
- Backoffice Route:
/marketing/loyalty - Backend Controller:
app/Http/Controllers/Api/BackOffice/LoyaltyController.php - Backend Service:
app/Services/BackOffice/LoyaltyService.php - Validation:
app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php - Vue Page:
src/pages/marketing/loyalty/index.vue(renderssrc/views/marketing/loyalty/Loyalty.vue) - Create/Edit Wizard:
src/views/marketing/loyalty/LoyaltyWizard.vue, rendered fromsrc/pages/marketing/loyalty/create-loyalty.vue(5 steps undersrc/views/marketing/loyalty/components/loyaltyCreateStep/)
Fields
Fields below come from
StoreLoyaltyRequest(validation) andRawModels/Loyalty.php(persisted shape). The wizard is organized into 5 steps; the step each field belongs to is noted.
Reward Unit
| Property | Value |
|---|---|
| Field ID | reward_unit (label, singular, plural) |
| Label | What do you call your points? |
| Type | Object — label is one of Points, Stars, Custom |
| Required | reward_unit.label required. For Custom, singular and plural are also required |
| Step | 1 (Customize) |
Description: Controls how the loyalty currency is labelled. For Points and Stars the singular/plural are filled automatically; for Custom the merchant supplies their own (e.g. "Bean"/"Beans").
Program Type
| Property | Value |
|---|---|
| Field ID | program |
| Label | Program Type |
| Type | Select (enum LoyaltyPrograms) |
| Options | Amount Based, Visit Based |
| Required | Yes |
| Step | 2 (Choose Program) |
Description: Whether customers earn by amount spent or by completed visits. One type per program — not both.
Points Earned Each Dollar
| Property | Value |
|---|---|
| Field ID | points_earned_each_dollar |
| Label | Points earned per currency unit |
| Type | Number |
| Required | Required when program = Amount Based |
| Step | 2 |
Description: Points awarded per unit of order total for amount-based programs. Points earned = points_earned_each_dollar × order total (× bonus multiplier). Calculated on the order total — there is no subtotal option.
Points Earned Each Visit
| Property | Value |
|---|---|
| Field ID | points_earned_each_visit |
| Label | Points earned per visit |
| Type | Number |
| Required | Required when program = Visit Based |
| Step | 2 |
Description: Fixed points awarded for each completed order, regardless of amount, for visit-based programs.
Using Minimum Spend
| Property | Value |
|---|---|
| Field ID | using_minimum_spend |
| Label | Require a minimum spend |
| Type | Boolean |
| Required | Required when program = Visit Based (defaults false) |
| Step | 2 |
Description: When enabled (visit-based only), an order must meet the minimum spend before any points are awarded.
Minimum Spend To Earn Points
| Property | Value |
|---|---|
| Field ID | minimum_spend_to_earn_points |
| Label | Minimum spend |
| Type | Numeric (money) |
| Required | Required when using_minimum_spend is true |
| Step | 2 |
Description: The order total must be greater than or equal to this amount for visit-based points to be awarded.
Do Points Expire
| Property | Value |
|---|---|
| Field ID | do_points_expire |
| Label | Do points expire? |
| Type | Boolean |
| Required | Yes |
| Step | 2 |
Description: Master switch for point expiry. When off, points never expire. Not shown, and refused on save, for MplusKassa-linked merchants — see Point expiry does not apply to MplusKassa merchants.
Months To Expire Points
| Property | Value |
|---|---|
| Field ID | months_to_expire_points |
| Label | Points expire after |
| Type | Select (enum LoyaltyMonthsToExpirePoints), months |
| Options | 3, 6, 12, 18, 24 months — these are the only options |
| Required | Required when do_points_expire is true |
| Step | 2 |
Description: Number of months after earning that points expire (not days). On earn, expires_at = now + months_to_expire_points. Expired point records are marked is_expired and skipped during redemption. Like the switch above, this field is not offered to MplusKassa-linked merchants.
Sign-Up Bonus
| Property | Value |
|---|---|
| Field ID | sign_up_bonus (boolean) + sign_up_bonus_points (number) |
| Label | Sign-up bonus |
| Type | Boolean + Number |
| Required | sign_up_bonus required; sign_up_bonus_points required when it is true |
| Step | 4 (Personalize) |
Description: One-time fixed points granted when a customer first joins (no prior customer-vendor relationship). Respects the same expiry settings as regular points.
How Customer Sign Up
| Property | Value |
|---|---|
| Field ID | how_customer_sign_up |
| Label | Sign-up method |
| Type | Select (enum LoyaltySignUpOptions) |
| Options | Email, Phone, Both |
| Required | No (nullable; defaults to empty) |
| Step | Not surfaced in the setup wizard |
Description: A persisted enum for how customers identify/join the program. Note: this field is accepted by the API but is not collected anywhere in the current back-office loyalty setup wizard, so in practice it stays at its default (empty) value.
Birthday Bonus
| Property | Value |
|---|---|
| Field ID | birthday_bonus (boolean) + birthday_bonus_details (object) |
| Label | Birthday bonus |
| Type | Boolean + Object |
| Required | birthday_bonus required; when true, birthday_bonus_details.type (enum RewardTypes: Discount / Free Item) is required |
| Step | 4 |
Description: A birthday gift — a Discount or a Free Item with eligible items. It is a reward-style gift, not a fixed points grant. Stored per location as a boolean map plus a linked BirthdayBonus document. For a Discount the sub-fields are birthday_bonus_details.discount_unit (percent / amount) and birthday_bonus_details.discount — note the value field is discount, not discount_value as on rewards; percentage values must be >0 and <=100, amounts >0. For a Free Item, birthday_bonus_details.eligible_items[] is required. (Verified: upvendo-backend app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php lines 218-242.)
Specific Day/Time Bonus
| Property | Value |
|---|---|
| Field ID | specific_day_time_bonus (boolean), specific_day_time_bonus_amount, specific_day_time_bonus_days[], specific_day_time_bonus_time (boolean), specific_day_time_bonus_time_start, specific_day_time_bonus_time_end |
| Label | Bonus days/times |
| Type | Boolean + select + array + time window |
| Options | specific_day_time_bonus_amount = Double Points or Triple Points (enum LoyaltyDayTimeBonusOptions); days are weekday names; times are H:i |
| Required | specific_day_time_bonus required; when true, amount, days and the time toggle are required (and start/end when the time toggle is on) |
| Step | 4 |
Description: During the selected days (and optional time window), earned points are multiplied ×2 (Double) or ×3 (Triple).
Program Alert
| Property | Value |
|---|---|
| Field ID | program_alert |
| Label | Suspicious activity alert |
| Type | Boolean |
| Required | Yes |
| Step | 4 |
Description: When on, if a customer completes more than 3 completed orders at the same location in a single day, they are flagged suspicious and the merchant is emailed.
Name & Description
| Property | Value |
|---|---|
| Field ID | name, description |
| Type | String |
| Required | Yes |
| Step | 4 |
Description: Program name and description shown to customers.
Rewards
| Property | Value |
|---|---|
| Field ID | rewards[] |
| Type | Array of reward objects |
| Required | Yes (at least one) |
| Step | 3 (Rewards) |
Each reward object (RawModels/Reward.php):
| Sub-field | Notes |
|---|---|
type | enum RewardTypes: Discount or Free Item |
name | required string |
discount_unit | enum DiscountUnits (required for Discount) |
discount_value | required for Discount; for percentage must be >0 and <=100; for amount must be >0 |
eligible_items[] | required for Free Item (item ids) |
points_needed | threshold for Amount Based programs (required, >0). On an Amount Based save, if it is missing the request back-fills it from visits_needed |
visits_needed | threshold for Visit Based programs (required, >0). On a Visit Based save, if it is missing the request derives it as points_needed ÷ points_earned_each_visit |
Description: Rewards are unlocked by reaching a fixed points_needed (amount-based) or visits_needed (visit-based) threshold. Redemption itself is always checked against the stored points_needed, for both program types. There is no point-to-currency conversion or maximum-redemption cap. (Verified: upvendo-backend app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php lines 65-73; app/Services/Loyalty/LoyaltyService.php lines 738-750.)
Business Logic
Points Earning
Customer places a Complete order
│
▼
Is it bonus time? (specific_day_time_bonus, day/time match)
- Yes → multiplier = 2 (Double) or 3 (Triple)
- No → multiplier = 1
│
▼
Amount Based:
points = points_earned_each_dollar × order TOTAL × multiplier
Visit Based:
if (!using_minimum_spend OR total >= minimum_spend_to_earn_points):
points = points_earned_each_visit × multiplier
(no minimum met → no points)
│
▼
If points == 0 → skip
│
▼
Save LoyaltyPoint record (with expires_at = now + months_to_expire_points
when do_points_expire is on)
│
▼
Recalculate customer lifetime points / visits / spendReward Redemption (threshold-based)
Customer selects one or more rewards at checkout
│
▼
checkIfRewardsAreRedeemable:
- For each reward, available points >= reward.points_needed?
└── No → 422 "You do not have enough points to redeem <reward name>."
- And available points >= SUM(all selected rewards' points_needed)?
└── No → 422 "You do not have enough points to redeem the selected rewards."
- (Mplus-linked customers: the balance is re-fetched live first; if the POS is
unreachable and no prior balance is known →
422 "We could not verify your points balance right now. Please try again in a moment.")
│
└── Yes → apply reward(s):
- Discount: percentage or fixed amount off eligible/all items
- Free Item: eligible item becomes free
│
▼
Deduct points across active (non-expired) LoyaltyPoint records
in order, spanning multiple records if needed
(expired records are marked is_expired and skipped)Note: redemption is threshold-based. There is NO point-to-currency value, NO minimum-redemption-points setting, and NO maximum-redemption-% cap.
Points Balance
Available points = sum of non-expired LoyaltyPoint records,
minus points already used (points_used) on each.
(For Mplus-linked customers the online balance is read from the POS
instead, falling back to the local balance if Mplus is unavailable.)Customer Impact
Online Ordering
- Account: Points balance shown in profile
- Ways to earn: Earn rate, bonus days, birthday treat shown
- Cart: "You'll earn X points on this order"
- Checkout: An inline "Use your points" section in the order summary on the Order & Pay page (
CheckoutLoyaltyRewards.vue) shows unlocked rewards and today's birthday bonus so the customer can redeem or remove them without leaving checkout. If they still have unused redeemable rewards when they tap to pay, a one-time prompt (per checkout session) offers to redeem points first or continue without them. - Rewards: Each reward shows its points/visits threshold; unlocks when the balance reaches it
- Confirmation: "You earned X points"
Kiosk
- Points features may be limited
- Requires customer identification (phone/email)
- Balance shown if identified
Receipt
--- Loyalty ---
Points Earned: 25
Reward Redeemed: Free Coffee (100 points)
New Balance: 425 pointsRelations
Depends On
- Customers: Points tied to customer-merchant records
- Items: For Free Item rewards / birthday Free Item (eligible items)
- POS Integration: Loyalty is free of charge (no subscription), but in production it's only available for locations connected to a supported POS (
MplusKassa, or Kassanet —Hendrickx/Vanhoutte); not restricted in test/staging environments
Affects
- Online Ordering: Points display and reward redemption
- Transactions: Points earned and rewards redeemed recorded
- Mplus (MplusKassa) POS: Points/redemptions synced for linked customers
Related Features
Business Rules
- Points are only awarded on orders with a status of "Complete"; pending or cancelled orders do not earn points.
- For visit-based programs with a minimum spend enabled, the order total must meet or exceed the configured minimum spend threshold before any points are awarded.
- When redeeming rewards, the system iterates through active (non-expired) point records in order and deducts points across multiple records if needed; if total available points are insufficient, the redemption fails with an exception.
- Points expiry is set in months (not days) from the time of earning; expired point records are marked as expired and skipped during redemption.
- Point expiry is unavailable where a MplusKassa POS is connected. The control is hidden in the wizard, a program that switches expiry on is refused on save, and a value stored before the integration was connected is kept but never applied. No points are expired for these merchants.
- MplusKassa-linked merchants earn whole points. Points are rounded to a whole number as they are earned, so the Upvendo balance matches the till's counter; every other merchant keeps fractional earning.
- A configured program can be paused, but not deleted. The Loyalty overview carries a switch that pauses or resumes the program for the selected location; there is no delete control for the program itself. Individual rewards can be removed while editing by a user holding the
delete-loyaltypermission, and a program must still keep at least one reward. (Verified:upvendo-backofficesrc/views/marketing/loyalty/LoyaltyOverview.vue:53-113;src/store/modules/loyalty.ts:336-339-- the store posts to/pausedand never calls a delete endpoint;upvendo-backendapp/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php:151--rewardsisrequired|array.) - Pausing stops earning and redemption everywhere at once, and keeps the balances. A paused program reports itself as not running to every consumer — earning, redemption, birthday and day/time bonuses, the storefront, the kiosk, the device payloads and the receipt settings — so there is no surface where a customer can still earn or spend while it is paused. Points already held are untouched and become spendable again on resume.
- "Paused" and "not supported by this POS" are different states. The program reports them separately, so a merchant is told which of the two is stopping their program rather than seeing an undifferentiated "off".
- Loyalty is free of charge — there is no Stripe or reseller subscription for the feature. Whether loyalty is running for a location (published on the wire as
has_loyalty_program, with the olderis_loyalty_subscribedkept as a deprecated alias of the same value) istrueonly when a program is configured for the location, the merchant has not paused it, and, in production, the location's connected POS is on the supported allowlist (MplusKassa, or Kassanet —Hendrickx/Vanhoutte). When any of those is false, all loyalty features are disabled for that location. Test/staging environments allow loyalty regardless of connected POS.
FAQs
"Can a customer use both amount-based and visit-based programs?" No, each loyalty program is configured as either amount-based or visit-based, not both simultaneously.
"What happens to points when an order is refunded?" Points are awarded only on completed orders; however, the system does not automatically reverse points if an order is later refunded -- manual adjustment may be needed.
"How does the sign-up bonus work?" When a new customer-vendor record is created (i.e., the customer has no prior relationship with this vendor), the system automatically awards the configured sign-up bonus points, respecting the same expiry settings as regular points.
"Can rewards be combined in a single order?" Yes, multiple reward IDs can be submitted in a single order, but the system validates that the customer has enough total available points to cover all selected rewards combined.
"What triggers the suspicious activity alert?" If a customer has more than 3 completed orders at the same location within a single calendar day (in the location's timezone), the customer is flagged and the merchant receives an alert email.
"How do I turn my loyalty program off?" Use the switch at the top of Marketing → Loyalty. It pauses the program for that location: customers stop earning on new orders and cannot redeem rewards or bonuses, but every point balance they already have is kept and becomes available again the moment you turn it back on. You are asked to confirm before it pauses; turning it back on is immediate.
"If I pause loyalty, do my customers lose their points?" No. Pausing only stops earning and redemption. Existing balances are kept exactly as they are and come back when you resume.
"Why is the loyalty on/off switch greyed out?" The location's connected POS integration does not support loyalty. That is a different thing from having paused the program — it means the program cannot run on that POS at all, so there is nothing to switch.
"How do I set up a loyalty program — what are the steps?" Go to Marketing → Loyalty and follow the 5-step wizard: Customize (choose your reward unit), Choose Program (Amount Based or Visit Based and earn rate), Rewards, Personalize (sign-up/birthday/bonus-time options), then Review & Create.
"What's the difference between an Amount Based and a Visit Based program?" Amount Based awards points based on how much is spent (points per €1), while Visit Based awards a fixed number of points for every completed visit regardless of spend. You pick one type per program — not both.
"Can I require customers to spend a minimum before they earn points?" Yes, but only on Visit Based programs. Enable "minimum spend" and set the amount; the order total must meet or exceed it for any points to be awarded.
"What can I name the points — do they have to be called 'points'?" No. In step 1 you choose a reward unit: Points, Stars, or a Custom unit. If you pick Custom, you enter your own singular and plural label (e.g. "Bean"/"Beans").
"How long until points expire, and what options do I have?" Expiry is optional. If you turn on "Do points expire," you choose a fixed period of 3, 6, 12, 18, or 24 months — those are the only options. If expiry is off, points never expire. If your location is connected to a MplusKassa POS you will not see this setting at all, and your customers' points do not expire — the till holds the balance as a plain counter with no expiry dates, so the setting cannot be applied to it.
"I can't find the point-expiry setting." If the location is connected to a MplusKassa POS, the setting is intentionally not shown: the till owns the points balance and has no concept of expiry, so no points expire. This is not something that can be switched on for these merchants. On any other setup, expiry lives in step 2 of the loyalty wizard.
"How do I switch the loyalty program off / set it to inactive / deactivate it?" Use the switch at the top of Marketing → Loyalty, on the same line as the page title. The switch carries no text of its own -- the words beside it tell you the state: Running or Paused (NL: Actief / Gepauzeerd). Turn it off and a confirmation appears, "Pause the loyalty program?" (Loyaliteitsprogramma pauzeren?); confirm with Pause program (Programma pauzeren). Turning it back on happens immediately, with no confirmation. It is set per location, so switch locations first if you have more than one.
"I want to delete / remove my loyalty program." You can pause it, which stops everything, but there is no delete control -- a configured program cannot be removed from a location in the back office. Pausing is the supported way to stop it, and it keeps every customer's points, so nothing is lost if you turn it back on. If you genuinely need the program gone, raise it with Upvendo.
"I can't find the button / the switch is greyed out." Two different things. If you see no switch at all, check you are on Marketing → Loyalty with the right location selected -- the switch is unlabelled, sitting next to the Running/Paused text in the page header, which is why it is easy to miss. If the switch is there but greyed out, either your role lacks the edit-loyalty permission, or the location's connected POS does not support loyalty; that is not a pause, it means the program cannot run on that POS at all.
"My Upvendo points don't match the till exactly — one shows a fraction." On a MplusKassa-linked merchant points are rounded to whole numbers as they are earned, precisely so the two agree. If you are seeing a fractional balance, it was earned before that rounding applied; existing balances were left as they were rather than being rewritten. New earnings will be whole.
"How do customers redeem points for a reward?" You define rewards, each with a points (or visits) threshold. A reward unlocks once the customer's balance reaches that threshold, and redeeming it deducts the required points. Rewards can be a Discount (percentage or fixed amount) or a Free Item with selected eligible items.
"Can a customer redeem more than one reward in the same order?" Yes. Multiple rewards can be applied to one order, but the system checks the customer has enough total available points to cover them all combined.
"How do I offer double or triple points on certain days?" In Personalize, turn on the day/time bonus and choose Double or Triple Points, then pick the days (and optionally a start/end time). Points earned in those windows are automatically multiplied.
"What do the sign-up and birthday bonuses do, and how do customers join?" You can grant a one-time sign-up bonus (fixed points when a customer first joins) and a birthday bonus (a Discount or Free Item gift). (There is a persisted
how_customer_sign_upenum — Email/Phone/Both — but the current setup wizard does not surface it.)"Why isn't a customer earning points?" Points are only awarded on orders that reach "Complete" status, and only when the location's loyalty program is active. Loyalty is free, but in production it's only active for locations connected to a supported POS (
MplusKassa, or Kassanet —Hendrickx/Vanhoutte); other POS integrations can't turn it on. For Visit Based programs with minimum spend, the order must also meet the minimum."Do I have to pay to turn on loyalty, and is it per location?" No. Loyalty is free of charge — there's no subscription or payment step; completing the setup wizard's final "Create" step saves and activates the program directly. It is configured per location, but availability depends on the location's POS integration: in production it's limited to
MplusKassaand Kassanet (Hendrickx/Vanhoutte) integrations, which get a "Loyalty is not yet available for this POS integration" error otherwise. Test/staging environments aren't restricted by POS."Can customers redeem their points during checkout, not just from their account?" Yes. On the Order & Pay checkout page, an inline "Use your points" section (in the order summary) shows any unlocked rewards — and today's birthday bonus, if available — that the customer can redeem or remove right there, without leaving checkout. If they still have unused redeemable rewards when they tap to pay, a one-time prompt (per checkout session) asks whether to redeem points first or continue without them.
"I use Mplus (MplusKassa) POS — will loyalty points stay in sync?" Yes, and it works in both directions. Earned points and redemptions are pushed to the Mplus relation, and the online balance is read from the POS. Points a customer earns or spends at the till also come back into Upvendo: on demand with the Pull from POS button on the customer's Loyalty Points tab, which always works, and automatically overnight for integrations that have been set up for it. A MplusKassa connection made through the back office is set up for the overnight sync when it is connected; a longer-standing connection may need Upvendo to enable it once before the overnight sync starts running, and until then Pull from POS is the way to bring a customer up to date. The sync is non-fatal: if Mplus is unavailable it falls back to the local balance rather than blocking the order. Because the till owns the balance, points do not expire on these merchants and are earned as whole numbers.
"A customer earned points at the till but Upvendo hasn't caught up." Open Customers → (the customer) → Loyalty Points and click Pull from POS to fetch the balance immediately instead of waiting for the nightly sync. The imported change appears in the ledger as a POS row. If it reports that the POS has no relation for that customer, the link to the till is stale or the record was deleted there — re-link the customer rather than retrying the pull.
Troubleshooting
Problem: Customer not earning points
Causes:
- The program is paused — the Loyalty overview shows Paused and a notice on the page
- No loyalty program configured for the location, or (in production) the location's POS isn't on the supported allowlist (
MplusKassa, or Kassanet —Hendrickx/Vanhoutte) - Customer not identified / not logged in
- Visit-based program with minimum spend not met
- Order not in "Complete" status
Solutions:
- Open Marketing → Loyalty and check the switch at the top. If it reads Paused, turn it back on — earning resumes immediately and no points were lost while it was off
- Confirm a loyalty program is set up for the location and, in production, that its POS integration is
MplusKassaor Kassanet (Hendrickx/Vanhoutte) — loyalty is free, so this POS check is the only activation gate. If the switch is greyed out, the POS integration is the reason, not a pause - Ensure the customer is identified (email/phone per sign-up method)
- Check the minimum spend setting and the order total
- Verify order status is Complete
Problem: Can't redeem a reward
Causes:
- Balance below the reward's
points_needed/visits_neededthreshold - Multiple rewards selected exceed total available points
- Points expired (records marked is_expired)
- Mplus balance stale / POS unavailable
Solutions:
- Check the reward's threshold versus the customer's balance
- Reduce the number of rewards applied to one order
- Check expiry settings and earned dates
- For Mplus-linked customers, the balance is refreshed before redemption; verify the POS integration
Problem: Points balance incorrect
Causes:
- Points expired (not possible on a MplusKassa-linked merchant — points there never expire)
- Redemption not recorded
- Order cancelled/refunded
- Mplus sync issue (POS balance overrides local)
- Points were earned or redeemed at the till and Upvendo has not synced yet
Solutions:
- Check expiry settings and dates
- Review the customer's Loyalty Points ledger — expanding a row shows which points paid for which redemption
- Check for refunded orders (points are not auto-reversed)
- Verify the Mplus integration / cached balance
- Click Pull from POS on the customer's Loyalty Points tab to fetch the till's balance immediately
Problem: Points expiring too fast
Causes:
- Short expiry period configured (3 or 6 months)
- Customer inactive
Solutions:
- Increase the expiry period (up to 24 months) or turn expiry off
- Send reminder communications before expiry
Assistant Guidance
When answering questions about this feature:
- Loyalty is free of charge for merchants — never say it requires a subscription, a Stripe charge, or a "subscribe/unsubscribe" step. Completing the 5-step wizard's "Review & Create" step activates it directly for that location.
- Availability is gated by POS support, not billing: in production, a location can only activate loyalty if its connected POS is
MplusKassaor Kassanet (Hendrickx/Vanhoutte) — other POS integrations are blocked. Test/staging environments are not restricted by POS. - Redemption is threshold-based only — never invent a point-to-currency conversion rate or a maximum-redemption percentage; the product doesn't have either.
- Customers can redeem loyalty rewards from two places in Online Ordering: the account/loyalty view and an inline "Use your points" section on the checkout page itself (with a one-time nudge prompt before paying). Mention both when asked how customers use their points.
- If a merchant asks why loyalty isn't showing or working for a location, check in this order: is a program configured for that location, is the location's POS on the supported allowlist (production only), and is the customer identified/logged in.
- Ask whether the merchant is on MplusKassa before answering anything about point expiry. For them the expiry setting is deliberately absent and points never expire — do not walk them through turning it on or tell them to look for a control they cannot see. For every other merchant, expiry is a normal step-2 setting. The same split applies to fractional points: MplusKassa merchants earn whole points, everyone else earns fractions.
- A configured program CAN be turned off -- it is a pause switch, not a delete. Point the merchant at the switch at the top of Marketing → Loyalty. The words beside it read Running / Paused (NL Actief / Gepauzeerd); the switch itself carries no label, which is why merchants report they cannot find "a button". Say plainly that pausing keeps every point balance, and that there is still no way to delete a program.
- "Niet actief zetten" on the Loyalty page can mean three things -- ask which. The program (the pause switch above); the location (Settings → Locations → open the location → Status, owner-only, and it cancels that location's subscriptions — see Locations); or a single reward (removed while editing step 3, and a program must keep at least one).
- For "the till and Upvendo disagree" on a MplusKassa merchant, point to Pull from POS on the customer's Loyalty Points tab — it syncs that customer immediately instead of waiting for the nightly sync. If the result says the POS has no relation for that customer, that is a broken link to re-establish, not something to retry.
Examples
JSON below reflects the real
StoreLoyaltyRequestpayload shape.
Simple Amount-Based Program
json
{
"reward_unit": { "label": "Points", "singular": "Point", "plural": "Points" },
"program": "Amount Based",
"points_earned_each_dollar": 1,
"do_points_expire": false,
"rewards": [
{
"type": "Discount",
"name": "€1 off",
"discount_unit": "percent",
"discount_value": 10,
"points_needed": 100
}
],
"name": "Rewards Club",
"description": "Earn points on every order",
"how_customer_sign_up": "Email",
"sign_up_bonus": true,
"sign_up_bonus_points": 50,
"birthday_bonus": false,
"specific_day_time_bonus": false,
"program_alert": true
}How it works:
- Earn 1 point per currency unit of the order total
- A reward unlocks at 100 points (a 10% discount)
- Points never expire
- 50-point sign-up bonus
Visit-Based Program with Minimum Spend
json
{
"reward_unit": { "label": "Stars", "singular": "Star", "plural": "Stars" },
"program": "Visit Based",
"points_earned_each_visit": 1,
"using_minimum_spend": true,
"minimum_spend_to_earn_points": 10,
"do_points_expire": true,
"months_to_expire_points": 12,
"rewards": [
{
"type": "Free Item",
"name": "Free Coffee",
"eligible_items": [{ "id": "<item_id>" }],
"visits_needed": 10
}
],
"name": "Coffee Card",
"description": "Collect a star each visit",
"how_customer_sign_up": "Both",
"sign_up_bonus": false,
"birthday_bonus": true,
"birthday_bonus_details": {
"type": "Free Item",
"eligible_items": [{ "id": "<item_id>" }]
},
"specific_day_time_bonus": false,
"program_alert": true
}How it works:
- Earn 1 star per visit, but only when the order total is at least 10
- 10 stars unlocks a free coffee
- Stars expire after 12 months
- Birthday treat is a free item
Amount-Based with Double-Points Days
json
{
"reward_unit": { "label": "Custom", "singular": "Bean", "plural": "Beans" },
"program": "Amount Based",
"points_earned_each_dollar": 2,
"do_points_expire": true,
"months_to_expire_points": 6,
"rewards": [
{
"type": "Discount",
"name": "€5 off",
"discount_unit": "amount",
"discount_value": 5,
"points_needed": 200
}
],
"name": "Bean Club",
"description": "Earn beans, double on weekends",
"how_customer_sign_up": "Phone",
"sign_up_bonus": true,
"sign_up_bonus_points": 100,
"birthday_bonus": false,
"specific_day_time_bonus": true,
"specific_day_time_bonus_amount": "Double Points",
"specific_day_time_bonus_days": ["Saturday", "Sunday"],
"specific_day_time_bonus_time": false,
"program_alert": true
}How it works:
- Earn 2 beans per currency unit; doubled to 4 on Saturdays and Sundays
- 200 beans unlocks a €5-off discount
- Beans expire after 6 months
- Custom "Bean"/"Beans" labelling, 100-bean sign-up bonus