unPLUGDevelopers

Offers and promo codes

Accept promo codes on your checkout and show the targeted offers a guest has waiting in their wallet.

Available at launch Using codes and wallet offers at checkout.

How guests get an offer#

An offer is a discount with rules: what it takes off, which carts it works on, and when. It reaches a guest in one of three ways, set by the offer's distribution.

DistributionWhat the guest hasExample
shared_codeOne code that anyone can type. You can limit how many times each member uses it.WELCOME10: 10% off one order, once per member.
unique_codesA batch of single-use codes, one per guest. Each code works once, for whoever types it first.HOLIDAY-7K2P9Q printed on a gift card sleeve.
grantedNothing to type. The offer lands in the member's wallet as a wallet item with source: "offer"."Fall pastry on us": any pastry free, for members a campaign picked.

Your checkout handles all three the same way: preview the cart, redeem one, then create the order.

Offers from unPLUG campaigns#

unPLUG campaigns put offers in front of guests as part of its campaigns. They do this in two ways, and neither needs extra work on your side beyond the checkout flow below and the push and in-app setup.

  • A code in a message. A push or full-screen in-app message shows a shared or unique code. The in-app message carries it in offer.code with a line of text in offer.text. Show it with a copy button, and prefill it on your checkout if you can.
  • A wallet offer granted to a segment. The campaign grants the offer to every member in a segment, so it is already in their wallet when the message arrives. The message carries offer.wallet_item_id. Link the button to that item in your rewards screen.

Offers that a campaign creates have created_by: "campaign". They use the same budgets and checkout rules as any other offer.

Accept a code at checkout#

This is the order from the checkout guide, with one different choice. Maya Lopez (usr_10482, 420 Beans) is ordering pickup at South Congress on Thursday, November 5, at 8:06 am: an oat latte ($5.75), a drip coffee ($3.25) and a butter croissant ($3.50), so the subtotal is $12.50. She types WELCOME10. This time she uses the code and keeps her Fall pastry wallet item for another day.

  1. Preview the cart with the code. Send it in codes. Preview has no side effects, so call it every time the cart or the code changes.
  2. Show what applies. A code in applicable can be used. A code in not_applicable comes with a message you can show the guest as is.
  3. Redeem it when the guest pays. POST /v1/redemptions with code holds the discount for 15 minutes.
  4. Create the order once payment is captured. Pass the redemption id in redemption_ids. Points are earned on the subtotal after the discount.

1. Preview#

The app can call preview with the member token, or your server with a secret key that has checkout:write.

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": []
}

The code takes $1.25 off (10% of $12.50), and the wallet item would take $3.50 off the croissant. An order takes one of them, so let the guest pick. points_to_earn is for the cart as sent, before any discount. Once Maya has used WELCOME10, the same code comes back like this:

"not_applicable": [
  {
    "type": "code",
    "code": "WELCOME10",
    "name": "10% off your order",
    "reason": "per_member_limit_reached",
    "message": "You've already used this code."
  }
]

2. Redeem#

Send the same cart with exactly one of code, reward_id or wallet_item_id. Use a new Idempotency-Key for each checkout attempt and reuse it on retries.

POSTRequest
curl -X POST https://api.unplugdining.com/v1/redemptions \
  -H "Authorization: Bearer $UNPLUG_KEY" \
  -H "Idempotency-Key: 0c8f6b52-3a1e-4d97-b2f4-9e5a7c1d3b86" \
  -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": [ ...same three lines as the preview... ],
    "code": "WELCOME10"
  }'
Response201
{
  "id": "6a2f8c41-d97e-4b35-a0c8-3e71b5d924fa",
  "status": "reserved",
  "discount_cents": 125,
  "points_cost": 0,
  "affected_line_ids": ["1", "2", "3"],
  "expires_at": "2026-11-05T14:22:02Z"
}

Charge the guest $12.50 minus $1.25, plus tax and tip. For a unique code, the code is now reserved: nobody else can use it while the hold lasts.

3. Create the order#

From your server, once payment is captured:

POSTRequest
curl -X POST https://api.unplugdining.com/v1/orders \
  -H "Authorization: Bearer $UNPLUG_KEY" \
  -H "Idempotency-Key: 4d2e9a17-8b6c-4f03-a5d1-c7e2b9f40a68" \
  -H "Content-Type: application/json" \
  -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": 125,
    "tax_cents": 93,
    "tip_cents": 150,
    "total_cents": 1368,
    "items": [ ...same three lines... ],
    "redemption_ids": ["6a2f8c41-d97e-4b35-a0c8-3e71b5d924fa"]
  }'
Response201
{
  "id": "b38e1f06-9a24-4c7d-8e5b-07f6d2a1c983",
  "external_order_id": "app-30417",
  "status": "credited",
  "points_earned": 112,
  "bonus_points": 0,
  "redemptions": [
    { "id": "6a2f8c41-d97e-4b35-a0c8-3e71b5d924fa", "status": "captured" }
  ],
  "member": {
    "points_balance": 532,
    "tier": { "id": "2b9e4f7a-1c3d-4e6b-8a5f-9d0c2e7b4a13", "name": "Green" }
  },
  "created_at": "2026-11-05T14:08:52Z"
}

The subtotal after the discount is $11.25, which earns 112 Beans at 10 Beans per $1 (112.5, rounded down). Maya's balance goes from 420 to 532, and the Fall pastry is still available in her wallet.

Guest checkout#

Shared codes like WELCOME10 work without a member. Leave out member_id in preview and redemption, then create the order without it. The discount applies; the order earns no points.

Targeted offers in the wallet#

A granted offer is a wallet item. List the wallet to show it on your rewards screen, and preview with the cart to see if it applies.

GETRequest
curl https://api.unplugdining.com/v1/members/me/wallet \
  -H "Authorization: Bearer mt_4kQ9xV2mR7tB8nW3pL6yZ1cH5fJ0dS"
Response200
{
  "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
}

To use it, redeem with wallet_item_id instead of code. The rest of the flow is the same. Show the expiry on the card ("until November 12"): guests act on a deadline they can see.

When a code doesn't work#

Preview tells you why in not_applicable[].reason, with a guest-safe message. The reasons you'll see most for codes:

ReasonWhat happened
below_minimum_subtotalThe cart is under the offer's minimum.
outside_available_timesThe code only works at certain hours or days.
wrong_channel, wrong_location, wrong_fulfillmentThe offer is limited to other channels, stores or order types.
item_not_in_cartThe offer needs an item the cart doesn't have.
per_member_limit_reachedThis member already used the offer as many times as it allows.
budget_exhaustedThe offer hit its budget. Nobody can use it any more.
not_started, expiredOutside the offer's dates.
already_usedA unique code that was already used.

A code that doesn't exist at all fails the redemption with 422 code_invalid. Redemption also returns 422 code_expired and 422 code_limit_reached when the code changed state between preview and payment. Show the message and let the guest pay without it, or remove the code. See Errors for the full list.

Holds, retries and refunds#

  • The hold lasts 15 minutes. If payment takes longer, the redemption expires and POST /v1/orders fails with 422 redemption_expired. Nothing is written. Redeem again and retry the order.
  • The guest backs out. Call POST /v1/redemptions/{id}/cancel. A unique code goes back to available right away instead of waiting out the hold.
  • Timeouts. Retry with the same Idempotency-Key. You get the original redemption back, not a second hold.
  • Full refund. An unexpired wallet offer or unique code goes back to the member, so they can use it again.
  • Partial refund. The redemption stays. Points are reversed in proportion to the refunded subtotal.

Warning Don't apply a discount to the guest's payment until POST /v1/redemptions returns reserved. A discount you charge without a redemption doesn't count against the offer's budget or limits, and unPLUG can't reverse it on refund.

Where offers come from#

Offers are created in the unPLUG Dashboard or by unPLUG campaigns, so your app only uses them: codes at checkout and wallet items on the rewards screen. If your own tools should create, grant or budget offers, that is the Management API. See Run offers from your server.

Stacking#

At launch an order takes one redemption: one code, one points reward or one wallet item. An order with more than one id in redemption_ids fails with 422 one_redemption_per_order. When the preview lists several, let the guest pick one. Stacking more than one is Coming soon.

Checklist#

  • Your checkout has a code field, and it calls preview every time the code or cart changes.
  • You show the message from not_applicable instead of your own generic error.
  • You charge the discount only after the redemption is reserved, and you cancel the redemption when the guest backs out.
  • Your rewards screen shows wallet items with source: "offer" and their expiry date.
  • Push and in-app messages with offer.code or offer.wallet_item_id take the guest to the code or the wallet item in one tap.
  • You've tested an expired hold, a used unique code and a full refund in the sandbox.