Checkout and orders
Show guests the rewards that apply to their cart, hold the one they pick while they pay, and credit points once payment is captured. The worked example below uses real numbers; the sections after it cover what goes wrong in production, such as expired holds, timeouts, retries and refunds.
Available at launch
The flow#
- Preview. Send the cart to
POST /v1/checkouts/preview. It returns what applies and what doesn't, with reasons. No side effects; call it as often as the cart changes. - Hold. When the guest taps Pay, your server calls
POST /v1/redemptionswith the one reward, wallet item or code they chose. Points are held, not spent. The hold lasts 15 minutes. - Capture payment with your payment processor, for the total after the discount.
- Post the order. Your server calls
POST /v1/orderswith the redemption id. The redemption is captured and the member earns points on the subtotal after discounts.
The app can call preview with its member token, so the cart screen stays fast. The redemption and the order should come from your server: the server needs the confirmed discount_cents to charge the right amount, and only the secret key can create orders.
Send the cart#
Preview, redemptions and orders all take the same cart. Send every line with:
line_id: your id for the line, unique within the cart. Responses point back to lines with it inaffected_line_ids.pos_item_id: the Square catalog item variation id, not the item id. A 12 oz and a 16 oz drip coffee are different variations.pos_category_ids: the item's Square category ids.quantity,unit_price_cents, andmodifierswith their ownpos_modifier_idandprice_cents.
Item rewards match on these ids. Free drip coffee applies only when a line has pos_item_id 3NZQ7XKJ2Y5PLVKCW6RQ4G2T; a pastry reward matches any line in the Pastries category. If your menu doesn't carry Square ids, item rewards come back as item_not_in_cart even when the guest can see the item in their bag. Map your menu to Square variation ids once, and keep the map per environment: the sandbox menu has its own ids.
channel is app or web. fulfillment is pickup, delivery, curbside or dine_in. Both feed reward rules, so send the real values.
Worked example#
Maya has 420 Beans. It's Thursday, November 5, 8:06 am in Austin, and she's ordering pickup at South Congress in the brand's app: an oat latte ($5.75), a drip coffee ($3.25) and a butter croissant ($3.50). Subtotal $12.50. She typed WELCOME10 in the promo code field.
1. Preview#
curl -X POST https://api.unplugdining.com/v1/checkouts/preview \
-H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS" \
-H "Content-Type: application/json" \
-d '{
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"location_id": "8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53",
"channel": "app",
"fulfillment": "pickup",
"currency": "USD",
"subtotal_cents": 1250,
"items": [
{ "line_id": "1", "pos_item_id": "HV4MZ2TQ7LW3KXRB5P8NJ6DC", "name": "Oat latte", "quantity": 1, "unit_price_cents": 575, "pos_category_ids": ["C4RZ8NQ2MXK7WTP3LHB6V5JD"], "modifiers": [] },
{ "line_id": "2", "pos_item_id": "3NZQ7XKJ2Y5PLVKCW6RQ4G2T", "name": "Drip coffee", "quantity": 1, "unit_price_cents": 325, "pos_category_ids": ["C4RZ8NQ2MXK7WTP3LHB6V5JD"], "modifiers": [] },
{ "line_id": "3", "pos_item_id": "7PKD3RXN2WQ6ZLHM4TB5JV8C", "name": "Butter croissant", "quantity": 1, "unit_price_cents": 350, "pos_category_ids": ["P9MT2XQ6KZR4WLN8HCB3J7VD"], "modifiers": [] }
],
"codes": ["WELCOME10"],
"reward_ids": [],
"wallet_item_ids": []
}'const response = await fetch("https://api.unplugdining.com/v1/checkouts/preview", {
method: "POST",
headers: {
Authorization: "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
"Content-Type": "application/json",
},
body: JSON.stringify({
member_id: "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
location_id: "8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53",
channel: "app",
fulfillment: "pickup",
currency: "USD",
subtotal_cents: 1250,
items: [
{
line_id: "1",
pos_item_id: "HV4MZ2TQ7LW3KXRB5P8NJ6DC",
name: "Oat latte",
quantity: 1,
unit_price_cents: 575,
pos_category_ids: ["C4RZ8NQ2MXK7WTP3LHB6V5JD"],
modifiers: [],
},
{
line_id: "2",
pos_item_id: "3NZQ7XKJ2Y5PLVKCW6RQ4G2T",
name: "Drip coffee",
quantity: 1,
unit_price_cents: 325,
pos_category_ids: ["C4RZ8NQ2MXK7WTP3LHB6V5JD"],
modifiers: [],
},
{
line_id: "3",
pos_item_id: "7PKD3RXN2WQ6ZLHM4TB5JV8C",
name: "Butter croissant",
quantity: 1,
unit_price_cents: 350,
pos_category_ids: ["P9MT2XQ6KZR4WLN8HCB3J7VD"],
modifiers: [],
},
],
codes: ["WELCOME10"],
reward_ids: [],
wallet_item_ids: [],
}),
});
const data = await response.json();import requests
response = requests.post(
"https://api.unplugdining.com/v1/checkouts/preview",
headers={
"Authorization": "Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS",
},
json={
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"location_id": "8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53",
"channel": "app",
"fulfillment": "pickup",
"currency": "USD",
"subtotal_cents": 1250,
"items": [
{
"line_id": "1",
"pos_item_id": "HV4MZ2TQ7LW3KXRB5P8NJ6DC",
"name": "Oat latte",
"quantity": 1,
"unit_price_cents": 575,
"pos_category_ids": ["C4RZ8NQ2MXK7WTP3LHB6V5JD"],
"modifiers": [],
},
{
"line_id": "2",
"pos_item_id": "3NZQ7XKJ2Y5PLVKCW6RQ4G2T",
"name": "Drip coffee",
"quantity": 1,
"unit_price_cents": 325,
"pos_category_ids": ["C4RZ8NQ2MXK7WTP3LHB6V5JD"],
"modifiers": [],
},
{
"line_id": "3",
"pos_item_id": "7PKD3RXN2WQ6ZLHM4TB5JV8C",
"name": "Butter croissant",
"quantity": 1,
"unit_price_cents": 350,
"pos_category_ids": ["P9MT2XQ6KZR4WLN8HCB3J7VD"],
"modifiers": [],
},
],
"codes": ["WELCOME10"],
"reward_ids": [],
"wallet_item_ids": [],
},
)
data = response.json(){
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"points_balance": 420,
"points_to_earn": 125,
"applicable": [
{
"type": "wallet_item",
"wallet_item_id": "7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28",
"name": "Fall pastry on us",
"discount_cents": 350,
"affected_line_ids": ["3"]
},
{
"type": "code",
"code": "WELCOME10",
"name": "10% off your order",
"discount_cents": 125,
"affected_line_ids": ["1", "2", "3"]
}
],
"not_applicable": [
{ "type": "reward", "reward_id": "9a6e3d21-4c7b-4f8a-b2e5-0d1c6f3a7b98", "name": "Free drip coffee", "points_cost": 500, "reason": "not_enough_points", "message": "You need 80 more Beans for this reward." },
{ "type": "reward", "reward_id": "5e8b1f4c-2d9a-4b6e-8c3f-7a0d2e9b1c65", "name": "Free pastry", "points_cost": 700, "reason": "not_enough_points", "message": "You need 280 more Beans for this reward." },
{ "type": "reward", "reward_id": "b3d7a9e2-6f1c-4a8d-9e5b-2c4f7a1d3e86", "name": "$5 off", "points_cost": 900, "reason": "not_enough_points", "message": "You need 480 more Beans for this reward." }
],
"challenge_progress": []
}Free drip coffee costs 500 Beans and Maya has 420, so it's in not_applicable. She has two other options: the pastry in her wallet saves $3.50, and WELCOME10 saves $1.25. points_to_earn is for the cart as sent, before any discount.
2. Hold the reward#
Maya picks the pastry and taps Pay. The app sends the cart and wallet_item_id to your server, which creates the redemption with the same cart:
curl -X POST https://api.unplugdining.com/v1/redemptions \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5c9e1a7d-3f2b-4d8e-9a6c-0b4f8d2e7a15" \
-d '{
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"location_id": "8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53",
"channel": "app",
"fulfillment": "pickup",
"currency": "USD",
"subtotal_cents": 1250,
"items": [ ...same three lines as the preview... ],
"wallet_item_id": "7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28"
}'const response = await fetch("https://api.unplugdining.com/v1/redemptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "5c9e1a7d-3f2b-4d8e-9a6c-0b4f8d2e7a15",
},
body: "{\n \"member_id\": \"3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40\",\n \"location_id\": \"8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53\",\n \"channel\": \"app\",\n \"fulfillment\": \"pickup\",\n \"currency\": \"USD\",\n \"subtotal_cents\": 1250,\n \"items\": [ ...same three lines as the preview... ],\n \"wallet_item_id\": \"7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28\"\n }",
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/redemptions",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "5c9e1a7d-3f2b-4d8e-9a6c-0b4f8d2e7a15",
},
data="{\n \"member_id\": \"3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40\",\n \"location_id\": \"8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53\",\n \"channel\": \"app\",\n \"fulfillment\": \"pickup\",\n \"currency\": \"USD\",\n \"subtotal_cents\": 1250,\n \"items\": [ ...same three lines as the preview... ],\n \"wallet_item_id\": \"7e3a9c51-0b8d-4f2e-a6c4-5d1b9e7f3a28\"\n }",
)
data = response.json(){
"id": "4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59",
"status": "reserved",
"discount_cents": 350,
"points_cost": 0,
"affected_line_ids": ["3"],
"expires_at": "2026-11-05T14:22:02Z"
}The wallet item is now reserved, so Maya can't use it on a second order from another device. A points reward would hold its points_cost the same way: held, not spent, until the order captures it.
3. Capture payment#
Charge the subtotal minus discount_cents, plus tax and tip. For Maya: $12.50 minus $3.50 is $9.00, tax $0.74, tip $1.50, total $11.24.
4. Post the order#
curl -X POST https://api.unplugdining.com/v1/orders \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: e8a3f1c6-7b2d-4e9a-a5c0-3d6b9f1e4a72" \
-d '{
"external_order_id": "app-30417",
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"location_id": "8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53",
"channel": "app",
"fulfillment": "pickup",
"placed_at": "2026-11-05T14:08:49Z",
"currency": "USD",
"subtotal_cents": 1250,
"discount_cents": 350,
"tax_cents": 74,
"tip_cents": 150,
"total_cents": 1124,
"items": [ ...same three lines... ],
"redemption_ids": ["4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59"]
}'const response = await fetch("https://api.unplugdining.com/v1/orders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "e8a3f1c6-7b2d-4e9a-a5c0-3d6b9f1e4a72",
},
body: "{\n \"external_order_id\": \"app-30417\",\n \"member_id\": \"3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40\",\n \"location_id\": \"8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53\",\n \"channel\": \"app\",\n \"fulfillment\": \"pickup\",\n \"placed_at\": \"2026-11-05T14:08:49Z\",\n \"currency\": \"USD\",\n \"subtotal_cents\": 1250,\n \"discount_cents\": 350,\n \"tax_cents\": 74,\n \"tip_cents\": 150,\n \"total_cents\": 1124,\n \"items\": [ ...same three lines... ],\n \"redemption_ids\": [\"4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59\"]\n }",
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/orders",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "e8a3f1c6-7b2d-4e9a-a5c0-3d6b9f1e4a72",
},
data="{\n \"external_order_id\": \"app-30417\",\n \"member_id\": \"3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40\",\n \"location_id\": \"8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53\",\n \"channel\": \"app\",\n \"fulfillment\": \"pickup\",\n \"placed_at\": \"2026-11-05T14:08:49Z\",\n \"currency\": \"USD\",\n \"subtotal_cents\": 1250,\n \"discount_cents\": 350,\n \"tax_cents\": 74,\n \"tip_cents\": 150,\n \"total_cents\": 1124,\n \"items\": [ ...same three lines... ],\n \"redemption_ids\": [\"4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59\"]\n }",
)
data = response.json(){
"id": "c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24",
"external_order_id": "app-30417",
"status": "credited",
"points_earned": 90,
"bonus_points": 0,
"redemptions": [ { "id": "4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59", "status": "captured" } ],
"member": {
"points_balance": 510,
"tier": { "id": "2b9e4f7a-1c3d-4e6b-8a5f-9d0c2e7b4a13", "name": "Green" }
},
"created_at": "2026-11-05T14:08:52Z"
}The math: subtotal after discounts is $12.50 minus $3.50, or $9.00, and 10 Beans per $1 gives 90 Beans. Maya now has 420 + 90 = 510 Beans, enough for Free drip coffee next time, and 30 lifetime Beans to Gold. The wallet item is used. You receive reward.redeemed, points.changed and order.credited webhooks.
Had she picked WELCOME10 instead, the subtotal after discounts would be $11.25, which earns 112 Beans (112.5 rounded down), for a balance of 532. Offers and promo codes follows that version of the order.
Show only rewards that apply#
Make only the entries in applicable tappable. Everything in not_applicable either can't be used on this cart or would fail at redemption. Show those greyed out with their message, which is written for guests:
reason | Example message |
|---|---|
not_enough_points | You need 80 more Beans for this reward. |
below_minimum_subtotal | Add $1.75 more to use this reward. |
outside_available_times | This code works 7 to 11 am. |
item_not_in_cart | Add a drip coffee to use this reward. |
wrong_location | Only at Riverside. |
per_member_limit_reached | You've already used this code. |
The full list of reason codes is in Checkouts and redemptions. Preview again whenever the cart, location or fulfillment changes; a reward that applied to pickup may not apply to delivery.
When a code is in not_applicable, keep it in the promo field and show the message under it.
One redemption per order#
At launch an order can carry one redemption: one points reward, one wallet item or one code. Let the guest pick one, and swap it if they pick another. An order with more than one redemption id fails with one_redemption_per_order.
If the guest changes their mind after you created a redemption, cancel it before creating the new one.
Coming soon Stacking more than one reward or code on an order.
Guest checkout with a code#
Guests who aren't signed in can still use a promo code. Leave out member_id and send the code. Use your secret key, because there's no member token.
curl -X POST https://api.unplugdining.com/v1/redemptions \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0a7c3e9f-2d5b-4f1a-8e6c-9b3d1f7a5c48" \
-d '{
"location_id": "8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53",
"channel": "web",
"fulfillment": "pickup",
"currency": "USD",
"subtotal_cents": 1250,
"items": [ ...cart lines... ],
"code": "WELCOME10"
}'const response = await fetch("https://api.unplugdining.com/v1/redemptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "0a7c3e9f-2d5b-4f1a-8e6c-9b3d1f7a5c48",
},
body: "{\n \"location_id\": \"8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53\",\n \"channel\": \"web\",\n \"fulfillment\": \"pickup\",\n \"currency\": \"USD\",\n \"subtotal_cents\": 1250,\n \"items\": [ ...cart lines... ],\n \"code\": \"WELCOME10\"\n }",
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/redemptions",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "0a7c3e9f-2d5b-4f1a-8e6c-9b3d1f7a5c48",
},
data="{\n \"location_id\": \"8d4e2a17-3b9c-4f5e-a6d1-7c0b9e2f4a53\",\n \"channel\": \"web\",\n \"fulfillment\": \"pickup\",\n \"currency\": \"USD\",\n \"subtotal_cents\": 1250,\n \"items\": [ ...cart lines... ],\n \"code\": \"WELCOME10\"\n }",
)
data = response.json(){
"id": "9f1b5d3a-6e8c-4b2f-a7d4-2c0e6a8f1b37",
"status": "reserved",
"discount_cents": 125,
"points_cost": 0,
"affected_line_ids": ["1", "2", "3"],
"expires_at": "2026-11-05T19:47:30Z"
}Post the order without member_id. It's recorded and captures the redemption, but earns no points. Offer sign-up on the confirmation screen.
Coming soon Letting a guest claim points for an order they placed before signing up.
When the hold expires#
A redemption holds for 15 minutes from creation (expires_at). After that it's expired, the points or wallet item go back to the member, and an order that names it fails with 422 redemption_expired. Nothing is written when that happens: no order, no points.
- Create the redemption late. Do it when the guest taps Pay, not when they open the cart.
- Check before you capture. If less than a minute is left on
expires_at, cancel it and create a new redemption with a new Idempotency-Key before charging the card. - If payment runs past the hold anyway, for example during a 3-D Secure check, create a new redemption for the same choice and post the order with it. If the new redemption fails, for example because the wallet item was used on another device in the meantime, you've already charged the discounted price. Post the order without
redemption_idsso she still earns, and decide on your side whether to honor the discount.
Cancel abandoned checkouts#
When the guest leaves the payment screen, payment fails, or they switch rewards, release the hold:
curl -X POST https://api.unplugdining.com/v1/redemptions/4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59/cancel \
-H "Authorization: Bearer $UNPLUG_KEY"const response = await fetch("https://api.unplugdining.com/v1/redemptions/4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
},
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/redemptions/4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59/cancel",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
},
)
data = response.json()The redemption becomes canceled and the wallet item, points or unique code are available again at once. Holds expire on their own after 15 minutes, but until then the guest can't use that reward anywhere else, so cancel as soon as you know.
The app can also cancel with its member token, for redemptions that belong to its own member. That covers a guest who closes the payment screen before your server hears about it. A member token can read its own redemptions with GET /v1/redemptions/{id} too.
Post the order after payment#
Send POST /v1/orders once payment is captured, not when the order is placed or prepared. Points are earned at the rate and bonuses in force at placed_at, so set it to when the guest placed the order, even if you post it later.
external_order_idis your order id. It's unique per brand forever: a second order with the same id returns409 order_already_existswith the existing order's id, even years later.- Orders without a member are recorded but earn nothing.
- An unknown or expired redemption id fails the whole request with
422 redemption_expired. Nothing is written. - If the loyalty API is down, take payment without a reward, queue the order and post it when the API is back.
placed_atkeeps the earning correct.
Retries and timeouts#
Create the Idempotency-Key when you create the order in your own database, and store it with the order. Every retry, from a request handler or a background job, sends the same key and the same body.
| What happened | What to do |
|---|---|
POST /v1/orders timed out or the connection dropped | Retry with the same key and body. If the first request went through, you get the original response with Idempotent-Replayed: true. |
409 request_in_progress | The first request is still running. Wait a second and retry with the same key. |
429 or 5xx | Retry with backoff and the same key. Honor Retry-After on 429. |
409 order_already_exists | The order was credited earlier, possibly more than 24 hours ago. Read it with GET /v1/orders?external_order_id=app-30417 and treat it as done. |
422 idempotency_key_reused | Same key, different body. Your code changed the order between retries; fix that rather than retrying. |
Any other 4xx | Don't retry. Log the Request-Id and fix the request. |
Warning Never generate a new Idempotency-Key to retry the same order. The key is what makes the retry safe; external_order_id is the backstop.
The same rules apply to POST /v1/redemptions and refunds. Idempotency keys are kept for 24 hours; see Idempotency.
Refunds#
When you refund an order in your own system, tell unPLUG so points and rewards follow the money. Send an external_refund_id (your refund id) and an Idempotency-Key.
- Points are reversed in proportion to the refunded amount, measured against the order's subtotal after discounts. The member gets a
reverseactivity entry. - A partial refund keeps the redemption. The reward stays used.
- A full refund returns the reward: an unexpired wallet item or unique code goes back to the member, and points spent on a points reward are refunded.
- The balance never goes below zero. If the guest already spent the points, the part that couldn't be reversed is recorded and returned as
points_shortfall. - Refunds can't add up to more than the order (
refund_exceeds_order). - Order status moves from
creditedtopartially_refundedorrefunded, and anorder.refundedwebhook fires.
Partial refund#
Maya's latte was made with the wrong milk, and you refund $5.00 of order app-30417. The order's subtotal after discounts was $9.00 and it earned 90 Beans, so 90 × 5.00 ÷ 9.00 = 50 Beans come back off.
curl -X POST https://api.unplugdining.com/v1/orders/c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24/refunds \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d2a9f4c-1e6b-4c8d-b3a5-8f0e2c6d9a14" \
-d '{
"external_refund_id": "rf-30417-1",
"amount_cents": 500,
"reason": "requested_by_customer"
}'const response = await fetch("https://api.unplugdining.com/v1/orders/c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "7d2a9f4c-1e6b-4c8d-b3a5-8f0e2c6d9a14",
},
body: JSON.stringify({
external_refund_id: "rf-30417-1",
amount_cents: 500,
reason: "requested_by_customer",
}),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/orders/c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24/refunds",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
"Idempotency-Key": "7d2a9f4c-1e6b-4c8d-b3a5-8f0e2c6d9a14",
},
json={
"external_refund_id": "rf-30417-1",
"amount_cents": 500,
"reason": "requested_by_customer",
},
)
data = response.json(){
"id": "e2b6d8f0-4a1c-4e7b-9d3f-6c8a0b2e5d71",
"order_id": "c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24",
"external_refund_id": "rf-30417-1",
"amount_cents": 500,
"full": false,
"reason": "requested_by_customer",
"points_reversed": 50,
"points_shortfall": 0,
"order_status": "partially_refunded",
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"member": { "points_balance": 460 },
"created_at": "2026-11-05T15:02:17Z"
}Maya goes from 510 to 460 Beans. The pastry stays used.
Full refund after the points were spent#
Take the same order without the partial refund. The next morning Maya uses her 510 Beans on Free drip coffee, with nothing else in the cart, which leaves her 10 Beans and earns nothing. Later that day the Nov 5 order is refunded in full:
curl -X POST https://api.unplugdining.com/v1/orders/c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24/refunds \
-H "Authorization: Bearer $UNPLUG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f8c1a6e-9d4b-4a2f-8c7e-1b5d9a3f6e20" \
-d '{
"external_refund_id": "rf-30417-1",
"full": true,
"reason": "requested_by_customer"
}'const response = await fetch("https://api.unplugdining.com/v1/orders/c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UNPLUG_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "3f8c1a6e-9d4b-4a2f-8c7e-1b5d9a3f6e20",
},
body: JSON.stringify({
external_refund_id: "rf-30417-1",
full: true,
reason: "requested_by_customer",
}),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.unplugdining.com/v1/orders/c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24/refunds",
headers={
"Authorization": f"Bearer {os.environ['UNPLUG_KEY']}",
"Idempotency-Key": "3f8c1a6e-9d4b-4a2f-8c7e-1b5d9a3f6e20",
},
json={
"external_refund_id": "rf-30417-1",
"full": True,
"reason": "requested_by_customer",
},
)
data = response.json(){
"id": "b7f3a1d9-5c2e-4b8a-a6d0-9e4c2f7b1a63",
"order_id": "c9e5a3f1-7d2b-4e8c-a0f6-3b1d9e5c7a24",
"external_refund_id": "rf-30417-1",
"amount_cents": 900,
"full": true,
"reason": "requested_by_customer",
"points_reversed": 10,
"points_shortfall": 80,
"order_status": "refunded",
"member_id": "3f2b8c1e-6d0a-4e7b-9f3c-2a1d5e8b7c40",
"member": { "points_balance": 0 },
"created_at": "2026-11-06T19:40:05Z"
}The order earned 90 Beans, but Maya only had 10 left, so 10 are reversed and the other 80 are recorded as the shortfall. Her balance is 0. The Fall pastry wallet item is available again because it doesn't expire until November 12. The Free drip coffee she already redeemed on the other order is untouched. Decide in your own policy what to do about a large shortfall; unPLUG never takes a balance negative.
Checklist#
- Every cart line carries the Square variation id in
pos_item_id, with category ids. - Only
applicableentries are tappable; the rest show theirmessage. - Your server creates the redemption at Pay and charges using the returned
discount_cents. - Abandoned and failed payments cancel the redemption.
- You handle
redemption_expiredafter slow payments. - Orders are posted after capture, with your
external_order_idand an Idempotency-Key stored with the order. - Timeouts retry with the same key;
order_already_existsis treated as success. - Partial and full refunds call
POST /v1/orders/{id}/refunds, and you handlepoints_shortfall.