Points and rewards
Build the rewards screen in your app from the program's settings, the guest's balance and tier progress, their points history, the catalog and their wallet. The last sections show how your support tools adjust points.
Available at launch
Read the program#
The program tells you what points are called, how guests earn them and what the tiers are. It changes rarely; cache it for an hour or on app start.
curl https://api.unplugdining.com/v1/program \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/program", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/program",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"brand_id": "e5a1c7d9-2f4b-4c8e-a3d6-8b0f1e9c2a57",
"name": "Rewards",
"point_name": { "singular": "Bean", "plural": "Beans" },
"earning": { "type": "spend", "points_per_dollar": 10, "rounding": "down", "earn_on": "subtotal" },
"tiers": [
{ "id": "2b9e4f7a-1c3d-4e6b-8a5f-9d0c2e7b4a13", "name": "Green", "min_lifetime_points": 0 },
{ "id": "6f1a8c3d-9b2e-4d7a-b5c6-3e0f8a1d2b47", "name": "Gold", "min_lifetime_points": 2000, "upgrade_bonus_points": 200 }
],
"default_time_zone": "America/Chicago",
"currency": "USD"
}- Use
point_nameeverywhere you show points. "1 Bean", "420 Beans". Brands rename their points, and your app should follow without a release. earningsays how points are earned.spendprograms givepoints_per_dollaron the subtotal after discounts, rounded down.visitprograms givepoints_per_visitinstead. Use it for copy like "Earn 10 Beans per $1".- Tiers are based on lifetime points, not the current balance. Spending points never drops a guest's tier.
Show the balance and tier progress#
GET /v1/members/me has everything for the top of the screen.
curl https://api.unplugdining.com/v1/members/me \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/members/me", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/members/me",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"external_id": "usr_10482",
"first_name": "Maya",
"points_balance": 420,
"lifetime_points": 1880,
"tier": { "id": "2b9e4f7a-1c3d-4e6b-8a5f-9d0c2e7b4a13", "name": "Green" },
"next_tier": { "id": "6f1a8c3d-9b2e-4d7a-b5c6-3e0f8a1d2b47", "name": "Gold", "points_needed": 120 }
}The response is trimmed to the fields this screen uses. For Maya, show "420 Beans" and "120 Beans to Gold". For the progress bar, take lifetime_points against the next tier's min_lifetime_points from the program: 1,880 of 2,000 is 94%. When next_tier is null, the guest is in the top tier; hide the bar.
The balance changes when you're not looking: register visits, refunds and adjustments from support all move it. Read it again each time the screen opens rather than caching it in the app.
Points history#
Every change to the balance is an activity entry, newest first. points is signed.
curl "https://api.unplugdining.com/v1/members/me/activity?limit=4" \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/members/me/activity?limit=4", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/members/me/activity?limit=4",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"data": [
{
"id": "c2e8a4f1-6b3d-4e9a-8c7f-1d5b0e3a9c26",
"type": "earn",
"points": 62,
"balance_after": 420,
"description": "Visit at South Congress",
"order_id": "5d9b3e7f-1a4c-4f8e-b2d6-0c7a9e1f3b58",
"reward_id": null,
"adjustment_reason": null,
"created_at": "2026-11-01T16:12:40Z"
},
{
"id": "f7a3c9e2-4d1b-4b6f-9e8a-2c0d5f7b1e43",
"type": "adjust",
"points": 50,
"balance_after": 358,
"description": "Courtesy points",
"order_id": null,
"reward_id": null,
"adjustment_reason": "goodwill",
"created_at": "2026-10-27T13:05:18Z"
},
{
"id": "1b6d8f2a-9c4e-4a7d-b3f1-6e0c2a8d5f97",
"type": "earn",
"points": 43,
"balance_after": 308,
"description": "Visit at South Congress",
"order_id": "a3f7d1c9-5e2b-4c8a-9f6d-4b1e7c3a0d82",
"reward_id": null,
"adjustment_reason": null,
"created_at": "2026-10-20T14:31:05Z"
},
{
"id": "6e0a2c8d-3f7b-4d1e-a5c9-8b4f1d6e2a30",
"type": "redeem",
"points": -500,
"balance_after": 265,
"description": "Free drip coffee",
"order_id": "a3f7d1c9-5e2b-4c8a-9f6d-4b1e7c3a0d82",
"reward_id": "9a6e3d21-4c7b-4f8a-b2e5-0d1c6f3a7b98",
"adjustment_reason": null,
"created_at": "2026-10-20T14:31:02Z"
}
],
"has_more": true,
"next_cursor": "eyJjIjoiMjAyNi0xMC0yMFQxNDozMTowMloifQ"
}Entry types: earn (orders), bonus (promotions, birthday, tier upgrade), redeem (points spent on a reward), adjust (support changes), reverse (refunds), and expire once points expiry ships. Filter with ?type=, ?from= and ?to=. Show description as the row title, and the signed points on the right.
Points from register visits show up here too, so the guest sees one history no matter where they ordered.
Rewards catalog and wallet#
Guests get rewards in two ways, and your screen should show both:
| Catalog | Wallet | |
|---|---|---|
| Endpoint | GET /v1/rewards | GET /v1/members/me/wallet |
| What's in it | Rewards bought with points (kind: "points") | Rewards already given to this guest: tier, offer, challenge, birthday, support |
| Cost to the guest | points_cost, taken at checkout | Nothing; it's theirs |
| When it's used | Bought and applied in one step at checkout | Applied at checkout, then marked used |
| Expiry | ends_at on the reward | expires_at on each item |
Points rewards are never in the wallet. A guest doesn't "buy" Free drip coffee ahead of time; they pick it at checkout and the points come off when the order is posted.
The catalog#
curl https://api.unplugdining.com/v1/rewards \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/rewards", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/rewards",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"data": [
{
"id": "9a6e3d21-4c7b-4f8a-b2e5-0d1c6f3a7b98",
"name": "Free drip coffee",
"description": "One 12 oz drip coffee.",
"image_url": "https://cdn.unplugdining.com/rewards/9a6e3d21.jpg",
"kind": "points",
"points_cost": 500,
"discount": {
"type": "free_item",
"items": [ { "pos_item_id": "3NZQ7XKJ2Y5PLVKCW6RQ4G2T", "name": "Drip coffee" } ],
"max_items": 1
},
"rules": { "min_subtotal_cents": 0, "channels": [], "location_ids": [], "fulfillment": [], "available_times": [], "tier_ids": [] },
"starts_at": null,
"ends_at": null
},
{
"id": "b3d7a9e2-6f1c-4a8d-9e5b-2c4f7a1d3e86",
"name": "$5 off",
"description": "On orders of $5 or more.",
"image_url": "https://cdn.unplugdining.com/rewards/b3d7a9e2.jpg",
"kind": "points",
"points_cost": 900,
"discount": { "type": "amount", "amount_cents": 500 },
"rules": { "min_subtotal_cents": 500, "channels": ["app", "web"], "location_ids": [], "fulfillment": [], "available_times": [], "tier_ids": [] },
"starts_at": null,
"ends_at": null
}
],
"has_more": false,
"next_cursor": null
}The catalog also has Free pastry for 700 Beans, left out here. Maya has 420 Beans, so she can't afford any of these yet. Show them anyway, with how many Beans she still needs: 80 for Free drip coffee.
The wallet#
curl https://api.unplugdining.com/v1/members/me/wallet \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/members/me/wallet", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/members/me/wallet",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"data": [
{
"id": "7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28",
"reward": {
"id": "d4c2e8a6-7b3f-4e1d-9a5c-1f8b6d3e0a92",
"name": "Fall pastry on us",
"description": "Any pastry, one per order.",
"image_url": "https://cdn.unplugdining.com/rewards/d4c2e8a6.jpg",
"kind": "wallet",
"discount": {
"type": "free_item",
"categories": [ { "pos_category_id": "P9MT2XQ6KZR4WLN8HCB3J7VD", "name": "Pastries" } ],
"max_items": 1
},
"rules": { "min_subtotal_cents": 0, "channels": ["app", "web"], "location_ids": [], "fulfillment": [], "available_times": [], "tier_ids": [] },
"starts_at": null,
"ends_at": null
},
"source": "offer",
"offer_id": "a8f2d6b4-3e9c-4a7f-8d1e-6c5b2a0f9e37",
"status": "available",
"issued_at": "2026-10-30T15:00:00Z",
"expires_at": "2026-11-13T05:59:59Z",
"used_at": null,
"order_id": null
}
],
"has_more": false,
"next_cursor": null
}The wallet returns available items by default. Pass ?status=used or ?status=expired for a "past rewards" list. Show expires_at in the guest's time zone: Maya's pastry expires at the end of November 12 in Austin. A reward.issued webhook fires when an item lands in a wallet, and reward.expiring fires 3 days before it expires.
Reward rules#
Each reward has rules that decide where and when it works. An empty array means no limit.
| Rule | Example value | Show the guest |
|---|---|---|
min_subtotal_cents | 500 | On orders of $5 or more |
available_times | [{ "days": ["mon","tue","wed","thu","fri"], "start": "07:00", "end": "11:00" }] | Weekdays 7 to 11 am |
channels | ["app"] | In the app only |
location_ids | ["1c7f9b3e-5a2d-4e8c-b9f0-6d3a2e1c8b74"] | At Riverside only |
fulfillment | ["pickup", "curbside"] | Pickup and curbside orders |
tier_ids | ["6f1a8c3d-9b2e-4d7a-b5c6-3e0f8a1d2b47"] | Gold members |
- Times are local to the location.
available_timesuses each location'stime_zonefromGET /v1/locations, not the phone's. - Turn ids into names. Look up location and tier ids from
/v1/locationsand/v1/program. - Use the rules for display, not for decisions. At checkout, the preview tells you exactly what applies to the cart and why the rest doesn't. See Checkout and orders.
Adjust points from support tools#
When your support team makes good on a cold latte or fixes a missing order, they add or remove points in the unPLUG Dashboard. Your app doesn't need to do anything: the next balance it loads includes the change, and a points.changed webhook fires. If your own support tool should make the change instead, use Create a points adjustment in the Management API.
In-store identification codes#
Coming soon
Your app will be able to show a short-lived code so guests earn and redeem when they order at the counter. POST /v1/members/me/identification_codes returns a 6-digit code and a qr_payload that last 5 minutes. Show both on a "Scan in store" screen and request a new one when expires_at passes. It replaces the static QR code guests use today.
Points expiry#
Coming soon
Points don't expire at launch. When expiry ships, the program gets points_expiry, for example { "days_inactive": 365 }, expired points appear in activity as expire entries, and a points.expiring webhook warns you ahead of time. If your program has no points_expiry, don't show any expiry copy.
A rewards screen that works#
A layout most guests understand without explanation, top to bottom:
- Balance. "420 Beans" in large type, with the tier name next to it.
- Tier progress. A bar from lifetime points to the next tier, with "120 Beans to Gold" and what Gold gets them.
- Your rewards. Wallet items first, because they're free to use. Each card shows the name, the expiry date, and a "Use in your next order" button that opens the menu.
- Rewards with Beans. The catalog sorted by
points_cost. Affordable ones look tappable; the rest show "80 more Beans" and a thin progress bar. - How it works. One line from
earning: "Earn 10 Beans per $1". - History. A link to the activity list, loaded a page at a time with
next_cursor.
Load the member, program, catalog and wallet in parallel; they don't depend on each other.
Checklist#
- Points use
point_name, singular and plural. - Tier progress uses
lifetime_points, and hides whennext_tierisnull. - The wallet and the catalog are shown separately, wallet first.
- Reward times are shown in the location's time zone.
- Support adjustments send an
Idempotency-Keyand areason.