Challenges
Give guests a goal, such as "visit 3 times this week", and pay a reward when they reach it. Here is how challenges count progress and how to show them in your app.
Coming soon
Coming soon Challenges are designed and on our build list. The objects and endpoints here are what we plan to ship, and they can still change before release.
What a challenge is#
A challenge is a goal measured on qualifying orders inside a time window. When the member reaches the goal, unPLUG pays the reward in the same moment the order is credited. Your app shows the challenges, a progress bar and, for some challenges, a join button. You don't count anything yourself: progress comes from the orders you already send to POST /v1/orders.
Challenges are created in the unPLUG dashboard, drafted by unPLUG for a segment of guests, or created from your own tools through the Management API. Your app only shows them and lets guests join. Most brands start from a template.
Challenge types#
| Type | Goal | Coffee example | Progress unit |
|---|---|---|---|
visits | N qualifying visits in the window | "Visit 3 times this week, get 50 bonus Beans" | visits |
streak | At least one qualifying visit in each of N days or weeks in a row | "Coffee every week for 4 weeks, get a free drink" | periods |
spend | Spend $X on qualifying orders in the window | "Spend $40 this month, get a free bag of beans" | cents |
menu_discovery | Buy N different items from a set or a category | "Try 3 of our 4 fall drinks" | items |
Streaks can count days or weeks (goal.period). Weekly is the default for coffee: a daily streak breaks on the first weekend away. A streak can allow grace periods (goal.grace_periods, usually 1), so one missed week doesn't end it.
What counts as a qualifying order#
Each challenge has a qualifying block with the same rules rewards use: minimum subtotal, channels, locations, fulfillment and available times. Empty lists mean no limit. Two guards stop guests from farming a challenge:
- Minimum subtotal. A visit counts only if the subtotal reaches
min_subtotal_cents. The coffee templates use 300 ($3), so splitting one order into three small ones doesn't work. - One counted visit per day.
max_counted_per_dayis 1 by default. A second order on the same day still earns points but doesn't add a visit.
Only orders with a member count, and each order counts once. The day is the store's local day, from the location's time_zone, so an order at 11:40 pm and another at 12:10 am are two days.
Windows#
- Fixed. The same dates for everyone:
{ "kind": "fixed", "starts_at": "2026-11-02T06:00:00Z", "ends_at": "2026-11-09T06:00:00Z" }is Monday to Sunday in Austin. - Rolling. Each member gets their own window that starts when they join:
{ "kind": "rolling", "days": 7 }. The member challenge'swindow_ends_attells you when theirs ends.
Joining#
- Automatic. Every member in the audience is enrolled when the challenge goes live. Short visit challenges use this.
- Opt-in. The member taps a join button in your app. Streaks and longer challenges use this: a guest who chose the goal is more likely to keep going.
Rewards#
A challenge pays one of two rewards when it's completed:
- Bonus points:
{ "kind": "points", "points": 50 }. They show in activity as abonusentry and firepoints.changed. - A catalog reward:
{ "kind": "reward", "reward_id": "..." }. It lands in the wallet withsource: "challenge"and firesreward.issued. The member challenge'sreward_wallet_item_idpoints to it.
A member can complete a challenge once (max_completions_per_member: 1). A challenge can also have a total budget, budget.max_completions, after which nobody else completes it.
How progress is counted#
- You credit an order.
POST /v1/orders, the same call you already make. Points earned at the register also count. - unPLUG checks the member's challenges. For each one the order qualifies for, it adds progress. For each one it doesn't, it records the reason.
- The goal is reached. The reward is paid with the order, and the member challenge moves to
completed.
Refunds roll progress back. If Maya's third visit is refunded in full, her Weekly regular goes back to 2 of 3. If that undoes a completion and the reward is unused, the reward is taken back: a wallet item is removed and bonus points are reversed. A balance never goes below zero.
Build the challenges screen#
Maya Lopez is in two challenges: Weekly regular (automatic, 2 of 3 visits done) and the 4-week streak (opt-in, not joined yet).
- Load the challenges.
GET /v1/challengeswith the member token returns the live challenges this member can see: name, image, goal and reward. - Load the member's progress.
GET /v1/members/me/challengesreturns status and progress for each one. Match them onchallenge_id. - Draw a card per challenge. Goal, progress bar, time left from
window_ends_at, and the reward. Put the challenge closest to completion on your home screen. - Show a join button when the status is
eligible. Call join on tap. - Refresh after checkout. Reload progress when an order is credited, so the bar moves while the guest is still looking.
List challenges#
curl https://api.unplugdining.com/v1/challenges \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/challenges", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/challenges",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"data": [
{
"id": "4f9a2d61-3e8b-4c15-a7d0-b26e81f5c93a",
"name": "Weekly regular",
"description": "Visit 3 times this week and get 50 bonus Beans.",
"image_url": "https://cdn.unplugdining.com/demo/challenges/weekly-regular.jpg",
"type": "visits",
"goal": { "target": 3, "unit": "visits", "period": null, "grace_periods": null, "item_set": [] },
"qualifying": { "min_subtotal_cents": 300, "channels": [], "location_ids": [], "fulfillment": [], "available_times": [], "max_counted_per_day": 1 },
"window": { "kind": "fixed", "starts_at": "2026-11-02T06:00:00Z", "ends_at": "2026-11-09T06:00:00Z" },
"enrollment": "automatic",
"reward": { "kind": "points", "points": 50 },
"max_completions_per_member": 1,
"budget": { "max_completions": 2000 },
"status": "live",
"version": 1
},
{
"id": "d1e83b5c-7f02-4a69-9c4e-5b1a07d8e236",
"name": "4-week streak",
"description": "Visit once a week for 4 weeks and get a free drip coffee. You can miss one week.",
"image_url": "https://cdn.unplugdining.com/demo/challenges/streak.jpg",
"type": "streak",
"goal": { "target": 4, "unit": "periods", "period": "week", "grace_periods": 1, "item_set": [] },
"qualifying": { "min_subtotal_cents": 300, "channels": [], "location_ids": [], "fulfillment": [], "available_times": [], "max_counted_per_day": 1 },
"window": { "kind": "fixed", "starts_at": "2026-11-02T06:00:00Z", "ends_at": "2026-12-07T06:00:00Z" },
"enrollment": "opt_in",
"reward": { "kind": "reward", "reward_id": "9a6e3d21-4c7b-4f8a-b2e5-0d1c6f3a7b98" },
"max_completions_per_member": 1,
"budget": { "max_completions": 800 },
"status": "live",
"version": 1
}
],
"has_more": false,
"next_cursor": null
}Load progress#
curl https://api.unplugdining.com/v1/members/me/challenges \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/members/me/challenges", {
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.get(
"https://api.unplugdining.com/v1/members/me/challenges",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"data": [
{
"challenge_id": "4f9a2d61-3e8b-4c15-a7d0-b26e81f5c93a",
"status": "enrolled",
"progress": { "current": 2, "target": 3, "unit": "visits", "periods": [] },
"window_ends_at": "2026-11-09T06:00:00Z",
"completed_at": null,
"reward_wallet_item_id": null
},
{
"challenge_id": "d1e83b5c-7f02-4a69-9c4e-5b1a07d8e236",
"status": "eligible",
"progress": { "current": 0, "target": 4, "unit": "periods", "periods": [] },
"window_ends_at": "2026-12-07T06:00:00Z",
"completed_at": null,
"reward_wallet_item_id": null
}
],
"has_more": false,
"next_cursor": null
}Draw the bar from current and target. For cents, format both as money ("$23.50 of $40"). For a streak, periods lists each day or week with met, so you can draw one dot per period instead of a bar.
Join an opt-in challenge#
curl -X POST https://api.unplugdining.com/v1/members/me/challenges/d1e83b5c-7f02-4a69-9c4e-5b1a07d8e236/join \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"const response = await fetch("https://api.unplugdining.com/v1/members/me/challenges/d1e83b5c-7f02-4a69-9c4e-5b1a07d8e236/join", {
method: "POST",
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
});
const data = await response.json();import requests
response = requests.post(
"https://api.unplugdining.com/v1/members/me/challenges/d1e83b5c-7f02-4a69-9c4e-5b1a07d8e236/join",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
)
data = response.json(){
"challenge_id": "d1e83b5c-7f02-4a69-9c4e-5b1a07d8e236",
"status": "enrolled",
"progress": {
"current": 0,
"target": 4,
"unit": "periods",
"periods": [
{ "starts_at": "2026-11-02T06:00:00Z", "ends_at": "2026-11-09T06:00:00Z", "met": false }
]
},
"window_ends_at": "2026-12-07T06:00:00Z",
"completed_at": null,
"reward_wallet_item_id": null
}Join returns 422 challenge_not_joinable when the challenge is automatic, has ended, has used its budget, or isn't open to this member. Hide the button and reload the list.
Show why an order didn't count#
When a visit doesn't count, guests want to know why. The member's activity carries a reason for each challenge an order didn't count toward. The reasons are the checkout reason codes plus already_counted_today. Turn them into one plain line:
| Reason | What to show |
|---|---|
below_minimum_subtotal | Orders under $3 don't count toward this challenge. |
already_counted_today | We already counted a visit today. Come back tomorrow. |
wrong_channel | Only app orders count toward this challenge. |
wrong_location | This challenge is for the Riverside store only. |
outside_available_times | Only orders after 2 pm count. |
item_not_in_cart | This order didn't have one of the fall drinks. |
not_started, expired | The order was outside the challenge dates. |
Store staff see the same reasons in the unPLUG dashboard, so your support team and the store give the same answer.
Coffee template pack#
Every template is a starting point: every field can be changed before publishing. GET /v1/challenge_templates returns them.
| Template | Goal | Default reward | Joining |
|---|---|---|---|
| Weekly regular | 3 visits, Monday to Sunday | Bonus points worth about one add-on | Automatic |
| 4-week streak | A visit every week for 4 weeks, 1 grace week | Free drink | Opt-in |
| Seasonal discovery | Try 3 of the seasonal drinks before the menu ends | Free seasonal drink | Automatic |
| Afternoon pick-me-up | 3 visits after 2 pm in 2 weeks | Bonus points | Automatic |
| Breakfast pairing | 3 orders with a drink and a food item | Free pastry | Automatic |
| Order ahead | 3 mobile orders in 2 weeks | Bonus points | Automatic |
| Win-back | 2 visits in 14 days, for guests who stopped coming | Free drink | Automatic |
| Bean bag | Spend $40 in a month | Free bag of beans | Opt-in |
All of them count a visit only from $3 and at most once a day.
Messages unPLUG can send#
unPLUG can message the guest at each step of a challenge, by push and full-screen in-app message. The brand writes the copy per challenge, and unPLUG's frequency caps and quiet hours apply. If your app already handles push and in-app messages, these need no extra code. Give each challenge a deep link (for example yourapp://challenges/4f9a2d61-3e8b-4c15-a7d0-b26e81f5c93a) so the buttons land on the right card.
| Moment | When | Example |
|---|---|---|
| Launch | The challenge goes live, or the member becomes eligible | "New this week: visit 3 times, get 50 Beans." |
| First progress | The first qualifying order | "1 down, 2 to go." |
| One more to go | Progress reaches the target minus one | "One more visit by Sunday for 50 Beans." |
| Completed | The goal is reached and the reward paid | "Done. 50 Beans are in your account." |
| Expiring | 24 hours before the window ends, if not completed | "Last day to finish Weekly regular." |
| Streak at risk | The current week or day has no visit yet and is about to end | "Keep your streak: stop by before Sunday." |
Webhooks#
Your server can follow challenges with three events, all Coming soon: challenge.joined, challenge.progressed and challenge.completed. The reward itself fires the events you already handle: points.changed for bonus points, reward.issued for a wallet reward. See Webhook event types.
Before you go live#
- Your challenges screen handles every status:
eligible,enrolled,completedandexpired. - The join button handles
422 challenge_not_joinableby reloading the list. - Each progress unit has its own formatting: visits, weeks, dollars and items.
- The guest can see why an order didn't count, in plain words.
- You reload progress after an order is credited and after a refund.
- Challenge deep links open the right card.