☰ Menu · Venue rules and limits
Venue rules and limits
How a venue controls what connected registers may do: cashback levels, product categories, holds, check numbering, late checks, limits and scan checks.
Everything on this page is set by the venue owner in Developers → Register rules. The rules apply to every API key of the venue; a single key (one register, one kiosk) can have its own values for any of them. Your integration reads the rules it works under from GET /v1/account, in settings.
"settings": {
"redemption": "hold",
"hold_minutes": 30,
"check_numbering": "per_register",
"offline_max_hours": 72,
"redeem_max_per_check": 50000,
"redeem_max_per_day": null,
"require_scan": true,
"scan_window_minutes": 30,
"allow_adjustments": false,
"adjustment_max": 50000
}Cashback levels
A cashback program can have levels: for example Base 5%, Silver 7% from the second visit, Gold 10% from 1000.00 spent. Levels are counted by visits, by total spent, or by whichever comes first. The owner sets them in the loyalty program. Nothing is needed on the register side: every check earns at the guest's level after that check, and every member object tells you where the guest is.
"cashback": {
"balance": 10200,
"held": 0,
"available": 10200,
"currency": "MDL",
"earn_rate": 7,
"level": "Silver",
"level_basis": "any",
"levels": [
{ "name": "Base", "earn_rate": 5, "min_visits": null, "min_spent": null },
{ "name": "Silver", "earn_rate": 7, "min_visits": 2, "min_spent": null },
{ "name": "Gold", "earn_rate": 10, "min_visits": null, "min_spent": 100000 }
],
"next_level": { "name": "Gold", "earn_rate": 10, "visits_left": null, "spent_left": 80000 },
...
}next_level.visits_left and next_level.spent_left are ready to show the guest ("2 more visits to Silver"). display.receipt_lines already has that line in the guest's language.
Product categories
Upload your products with their category once, then send changes. Categories appear in the owner's dashboard, where each one can be set to: not earn cashback (tobacco, alcohol), not be payable with cashback (items already on promotion), or earn its own percent instead of the guest's level (coffee 10%). The API can create products and categories and rename them, but only the owner changes these rules.
curl https://loyaltyfy.io/api/v1/catalog/products/bulk \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"products": [
{ "sku": "4840001", "name": "Marlboro Gold", "category": "TOB", "category_name": "Tobacco", "price": 6500 },
{ "sku": "1001", "name": "Latte", "category": "COF", "category_name": "Coffee", "price": 4500 },
{ "sku": "2040", "name": "Zeama", "category": "KIT", "category_name": "Kitchen", "price": 6500 }
]
}'
# { "object": "catalog_sync", "upserted": 3 }Then send line_items with each check. A line can carry category, or only sku: we look the category up in the catalogue. Line totals are scaled to the check amount, so discounts made on the register side are spread fairly. Lines in categories without rules earn at the guest's level.
curl https://loyaltyfy.io/api/v1/transactions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"external_id": "CHK-2026-000130",
"member": "2000111703779",
"amount": 17500,
"line_items": [
{ "sku": "4840001", "quantity": 1, "unit_price": 6500 },
{ "sku": "1001", "quantity": 1, "unit_price": 4500 },
{ "sku": "2040", "quantity": 1, "unit_price": 6500 }
]
}'
# Tobacco earns nothing, Coffee has its own 10%, Kitchen earns at the guest's level (5%):
# 0 + 450 + 325 = "cashback_earned": 775Paying with cashback through a hold
With In the check (the default) the register sends redeem_amount with the closed check. With Through a hold the register sets the amount aside when the guest asks to pay with cashback, and the closed check confirms it. If the check never arrives (the network dropped, the sale was cancelled), the hold is released after the time the owner set, and the guest keeps the money. No discount is ever given without being taken from the balance.
- 1Hold
POST /v1/holdswith the member, the amount and the check total. The same caps as for a check apply. The response has the holdid. - 2Capture
POST /v1/transactionswithhold_id. Leave outredeem_amountto use the whole hold, or send a smaller one. - 3Or release
POST /v1/holds/{id}/releasewhen the sale is cancelled. Releasing twice is harmless.
curl https://loyaltyfy.io/api/v1/holds \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hold-R3-000131" \
-d '{ "member": "2000111703779", "amount": 3000, "check_amount": 20000, "register_id": "R3" }'
# at payment: the check captures it
curl https://loyaltyfy.io/api/v1/transactions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "external_id": "000131", "register_id": "R3", "member": "2000111703779",
"amount": 20000, "hold_id": "5b0d6c1e-8f3a-4d7e-9a61-2c4b8e0f7d19" }'cashback.available of the member, in the limits, and in the venue's own cashier app, so the same money can't be spent twice.Check numbers per register
If each register counts checks from 1, choose Each register counts its own. Then register_id is required and we store the check as <register_id>:<external_id>, for example R3:15. Use that combined id to read, void or refund the check.
Checks sent late
After an outage the register sends its queue. With a limit of, say, 72 hours, checks whose closed_at is older are refused with check_too_old, so a register restored from an old backup can't flood the venue with stale checks. A closed_at more than 10 minutes in the future is always refused.
Redeem limits
On top of the program's share of a check, the owner can set the most cashback per check and per guest per day (all registers together, by the venue's time zone). A check asking for more redeems what is allowed and the response shows the real redeem_amount; a hold asking for more is refused with limited_by. Use calculate to show the guest the exact number first.
Card must be scanned
With this rule a check, visit, hold or reward for a card is accepted only if the same key sent that card to POST /v1/scans shortly before. A card number typed by hand on the register is refused with scan_required. Send the scanner's raw output to /v1/scans, then go on as usual.
Manual adjustments
Off by default. When the owner allows it, a key with the members:adjust scope can add or take cashback with a reason, up to the limit the owner set per adjustment. Each one is in the member's history and sends balance.adjusted.
curl https://loyaltyfy.io/api/v1/members/2000111703779/adjustments \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "external_id": "ADJ-2026-0009", "amount": 2000, "reason": "Goodwill after a complaint" }'
# a negative amount takes cashback away