Headless API
Redeem in Cart

Redeem in Cart

<JoyRedeemCart/> is the headless counterpart of the Liquid Joy: Redeem in Cart block. Mounted in your cart drawer or cart page, it shows the customer's points balance, the rewards they can redeem (fixed and dynamic), applies the issued code to the cart, and lists applied rewards with a revoke/remove action. It also shows combinability and best-value hints, an "Earn X points" line, and a guest "earn / sign in" state. It is styled and configured from the Joy admin: On-site content > Cart drawer > Redeem in cart.

Behavior settings honored by the block: Enable customer self-revoke coupon (shows the revoke/remove action; off → hidden), Automatically remove failed discount codes (a code that fails to apply is removed and its points refunded), Show points input for dynamic rewards (typed amount vs slider only).

Requires @joy-loyalty/hydrogen and @joy-loyalty/sdk 0.3.0 or later, plus the setup from the Overview and Authentication (root-loader initial data and the /api/joy-token route).

npm install @joy-loyalty/hydrogen@^0.3.0 @joy-loyalty/sdk@^0.3.0

Mount the block

import {JoyRedeemCart} from '@joy-loyalty/hydrogen';
import {useFetcher, useRevalidator, useRouteLoaderData} from 'react-router';
 
export function CartRedeem({cart}) {
  const data = useRouteLoaderData('root');
  // Dedicated key — don't share the merchant's cart form fetcher.
  const cartFetcher = useFetcher({key: 'joy-redeem-cart'});
  const {revalidate} = useRevalidator();
 
  if (!data?.joyApiUrl || !data.joyShopDomain) return null;
 
  return (
    <JoyRedeemCart
      apiUrl={data.joyApiUrl}
      shopDomain={data.joyShopDomain}
      initialData={data.joy ?? null}
      cart={cart}
      cartFetcher={cartFetcher}
      cartRevalidate={revalidate}
      cartRoute="/cart" // locale-aware if you use markets, e.g. /fr-ca/cart
    />
  );
}

Props

PropTypeRequiredNotes
apiUrlstringyesHeadless API base URL (JOY_API_URL).
shopDomainstringyesThe shop's *.myshopify.com domain.
initialDataJoyInitialDataResult | nullyesThe root loader's getJoyInitialData() result — the same value <JoyWidget/> takes.
cartStorefrontCart | () => StorefrontCartnoThe storefront cart, with the fragment fields below. Falls back to <JoyProvider cart>. With no cart, or an empty one, the block renders nothing (as the Liquid block does).
cartFetcheruseFetcher() resultnoWith cartRevalidate, lets the block apply codes to the cart. Falls back to the <JoyProvider> handlers.
cartRevalidate() => voidnouseRevalidator().revalidate.
cartRoutestringnoCart action route. Default /cart.
onCartUpdated(cart) => voidnoCalled with the cart returned by each of the block's cart mutations.
tokenRoutestringnoSame-origin token route. Default /api/joy-token.
loginUrlstringnoOverrides initialData.login_url for the guest "Sign in" link.
onNavigate(url) => voidnoClient-side navigation (useNavigate()). Omitted: full page load.
assetUrlstringnoOverrides the widget-core CDN URL (JOY_WIDGET_ASSET_URL).
classNamestringnoClass for the wrapper element.

Cart action requirement

The cart route's action must return the updated cart in its response. The Hydrogen skeleton's default {cart, errors, warnings} from CartForm actions already does.

The block trusts only the cart returned by its own mutation. Without it, the block cannot confirm that the discount was written, so every apply is rolled back: the shopper's own applicable codes are restored (as in Liquid), and the reward is refunded when the merchant enabled that.

Copy-code mode (no cart adapter)

Without cartFetcher + cartRevalidate, the block still redeems. The issued code is shown with a Copy button and a "copy this code and apply it at checkout" hint. "Copied" appears only after a confirmed clipboard write; where the Clipboard API is unavailable or rejected, the code is selected for manual copy instead. The block also falls back to this mode if its optional cart-port chunk fails to load.

Issued codes are remembered per customer for the browser session only, and are never shown to a guest or another customer.

Cart fragment

Merge these fields into your CART_QUERY_FRAGMENT. Pick the variant that matches your Storefront API version.

Default — Storefront API 2025-10 to 2026-10 (uses the deprecated but still valid Cart.discountAllocations):

fragment JoyRedeemCartDefault on Cart {
  discountCodes { code applicable }
  cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
  discountAllocations { targetType discountedAmount { amount currencyCode } ... on CartCodeDiscountAllocation { code } }
  lines(first: $numCartLines) { nodes {
    id quantity
    cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
    discountAllocations { targetType discountedAmount { amount currencyCode } ... on CartCodeDiscountAllocation { code } }
    merchandise { ... on ProductVariant { id product { id collections(first: 20) { nodes { id } pageInfo { hasNextPage } } } } }
  } }
}

Modern — Storefront API 2026-07 and later only (invalid on 2026-04 and earlier):

fragment JoyRedeemCartModern on Cart {
  discountCodes { code applicable }
  cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
  lines(first: $numCartLines) { nodes {
    id quantity
    cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
    discountAllocations { targetType discountedAmount { amount currencyCode } ... on CartCodeDiscountAllocation { code } }
    joyAllDiscounts: discountAllocations(lineLevelOnly: false) { targetType discountedAmount { amount currencyCode } ... on CartCodeDiscountAllocation { code } }
    merchandise { ... on ProductVariant { id product { id collections(first: 20) { nodes { id } pageInfo { hasNextPage } } } } }
  } }
  deliveryGroups(first: 10) { nodes { discountAllocations { targetType discountedAmount { amount currencyCode } ... on CartCodeDiscountAllocation { code } } } }
}
  • Cart totals. Merchandise totals are rebuilt from line costs and discount allocations (shipping excluded), never from cost.totalAmount, which can include tax, duties and delivery. If the merged fragment lacks the allocation fields while a code is applied, the block treats the cart as unknown and disables redemption.
  • Collections. collections(first: 20) is optional. Absent or truncated collection data (pageInfo.hasNextPage: true) hides collection-scoped rewards.

Revoking a reward

The revoke icon on an applied reward calls POST /customer/rewards/{rewardId}/cancel. The block calls the same endpoint to refund automatically when an issued code never sticks to the cart. The endpoint:

  • Cancels only the customer's own unused reward. It deactivates the Shopify discount first, then credits the refund exactly once; the response carries refundedPoints and the new balance in customer.point.
  • Does not apply the shop's self-revoke limits — those belong to POST /customer/rewards/{rewardId}/revoke (Loyalty Page self-revoke).
  • Is safe against double-clicks and second tabs: a concurrent cancel of the same reward answers 409 REQUEST_IN_PROGRESS (30-second lock), and a reward already used on an order or already cancelled answers 409 REWARD_ALREADY_USED. Neither refunds anything, so treat both as "re-fetch the rewards list", not as errors to retry.
  • Answers 404 REWARD_NOT_FOUND for a reward that does not exist or belongs to another customer, and 400 CANCEL_FAILED when the discount could not be deactivated or the reward is a gift card.

Limitations

⚠️

Shop currency only. v1 has no market-rate conversion. When the cart's currency differs from the shop's base currency, the block renders nothing, so no amount is ever shown in the wrong currency. Carts in the shop currency are unaffected. Conversion is planned for v1.1.

  • Earn-back estimate on rule-engine shops. The cart fragment carries no product tags, vendor, type or collection details, so a place-order rule scoped to a collection, tag or vendor is not matched and the "Earn X points" line can be lower than the Liquid block shows. It is an estimate only; points are awarded on the order.
  • Guests see the earn-back line and a "Sign in" link (login_url). The "hide for guest" setting is honoured.
  • Widget core. The block needs a widget core that provides loadRedeemCartCore. Against an older core it renders nothing and logs one [joy] JoyRedeemCart: … warning. It never throws into your drawer: any load or mount failure collapses the block and warns once.
  • Not yet supported: Replace applied reward when a new one saves more (planned for v1.1). A non-combinable new reward is rejected, as if the setting were off.

Building a custom cart UI with the SDK

If you build your own cart UI instead of using the block, use @joy-loyalty/sdk/client:

import {createJoyClient} from '@joy-loyalty/sdk/client';
import {JoyApiError} from '@joy-loyalty/sdk';
 
const client = createJoyClient({apiUrl, shopDomain, token, onTokenExpired: fetchFreshToken});
 
// Redeem — send an idempotencyKey so a retried request cannot double-spend.
const reward = await client.redeem(programId, {idempotencyKey: crypto.randomUUID()});
// Apply reward.couponCode to the cart with a CartForm DiscountCodesUpdate action.
 
// Revoke from the cart — refunds the points and deactivates the code.
try {
  const {refundedPoints, customer} = await client.cancelRedeem(reward.id);
} catch (error) {
  if (error instanceof JoyApiError && error.statusCode === 409) {
    // REWARD_ALREADY_USED or REQUEST_IN_PROGRESS — refresh with client.getRewards()
  }
}
MethodEndpointUse
client.getRewards(params?)GET /customer/rewardsRedeemable rewards and issued codes.
client.redeem(programId, options?)POST /customer/redeemSpend points; options takes points (dynamic rewards), variantId, recipient, idempotencyKey.
client.cancelRedeem(rewardId)POST /customer/rewards/{rewardId}/cancelCart revoke — returns {rewardId, refundedPoints, status, customer: {point}}.
client.revokeReward(rewardId)POST /customer/rewards/{rewardId}/revokeLoyalty Page self-revoke (subject to the shop's revoke limits). Not the cart flow.

Remove the code from the Shopify cart yourself after a successful cancelRedeem — the endpoint deactivates the discount but does not edit the cart.