unPLUGDevelopers

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#

  1. 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.
  2. Hold. When the guest taps Pay, your server calls POST /v1/redemptions with the one reward, wallet item or code they chose. Points are held, not spent. The hold lasts 15 minutes.
  3. Capture payment with your payment processor, for the total after the discount.
  4. Post the order. Your server calls POST /v1/orders with the redemption id. The redemption is captured and the member earns points on the subtotal after discounts.
Checkout sequence The app previews the cart with unPLUG and gets the applicable rewards. When the guest pays, the app sends the cart and chosen reward to your server. Your server creates a redemption, which holds the reward, then captures payment, then posts the order to unPLUG, which captures the redemption and credits points. Your server confirms the order to the app. Your app Your server unPLUG API 1. POST /v1/checkouts/preview 2. applicable, not_applicable 3. Pay, with chosen reward 4. POST /v1/redemptions 5. reserved, discount_cents 6. Capture payment 7. POST /v1/orders 8. credited, points_earned 9. Order confirmed
The hold from step 4 expires after 15 minutes. Only your server creates redemptions and orders, so the discount you charge always comes from unPLUG.

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 in affected_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, and modifiers with their own pos_modifier_id and price_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#

POSTRequest
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": []
  }'
Response200
{
  "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:

POSTRequest
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"
  }'
Response201
{
  "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#

POSTRequest
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"]
  }'
Response201
{
  "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:

reasonExample message
not_enough_pointsYou need 80 more Beans for this reward.
below_minimum_subtotalAdd $1.75 more to use this reward.
outside_available_timesThis code works 7 to 11 am.
item_not_in_cartAdd a drip coffee to use this reward.
wrong_locationOnly at Riverside.
per_member_limit_reachedYou'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.

POSTRequest
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"
  }'
Response201
{
  "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_ids so 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:

POSTRequest
curl -X POST https://api.unplugdining.com/v1/redemptions/4b8d2f6a-9c1e-4a3d-b7f5-0e6c8a2d1b59/cancel \
  -H "Authorization: Bearer $UNPLUG_KEY"

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_id is your order id. It's unique per brand forever: a second order with the same id returns 409 order_already_exists with 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_at keeps 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 happenedWhat to do
POST /v1/orders timed out or the connection droppedRetry with the same key and body. If the first request went through, you get the original response with Idempotent-Replayed: true.
409 request_in_progressThe first request is still running. Wait a second and retry with the same key.
429 or 5xxRetry with backoff and the same key. Honor Retry-After on 429.
409 order_already_existsThe 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_reusedSame key, different body. Your code changed the order between retries; fix that rather than retrying.
Any other 4xxDon'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 reverse activity 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 credited to partially_refunded or refunded, and an order.refunded webhook 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.

POSTRequest
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"
  }'
Response201
{
  "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:

POSTRequest
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"
  }'
Response201
{
  "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 applicable entries are tappable; the rest show their message.
  • Your server creates the redemption at Pay and charges using the returned discount_cents.
  • Abandoned and failed payments cancel the redemption.
  • You handle redemption_expired after slow payments.
  • Orders are posted after capture, with your external_order_id and an Idempotency-Key stored with the order.
  • Timeouts retry with the same key; order_already_exists is treated as success.
  • Partial and full refunds call POST /v1/orders/{id}/refunds, and you handle points_shortfall.