Supercommerce API Docs
Store API

Rewards Module — Storefront

HTTP surface for the customer-facing rewards/loyalty plugin. Customers earn points on three actions (account registration, product purchase, product review) and redeem them at the…

HTTP surface for the customer-facing rewards/loyalty plugin. Customers earn points on three actions (account registration, product purchase, product review) and redeem them at the cart as a pre-tax discount. The plugin is optional — removing RewardsModule.forRoot() from the API's app.module.ts collapses the redemption port to a no-op and silences the listeners; existing storefront callers see exactly the same shapes as before, minus the redemption fields on the cart response.

Source: api-modules/rewards/src/controllers/store-rewards.controller.ts (balance + history), and api-modules/cart/src/controllers/store-cart-redemption.controller.ts (cart-side set/clear) for the redemption surface.

Redemption is documented here because the rewards plugin owns the semantics, but the routes live under /store/cart/* so they fit the cart module's existing cartToken/x-platform header flow.


Conventions

Authentication

Endpoint groupAuth
GET /store/rewards/**required (customer session)
POST/DELETE /store/cart/redemptionoptional auth + cartToken (anonymous carts can carry the intent, but redemption evaluation rejects with USER_REQUIRED when there's no customer)

Every balance/history read is scoped to session.user.id. Setting redemption on an anonymous cart is allowed (the intent is persisted on cart.redemption_points) but the port returns ok: false, reason: "USER_REQUIRED" until the customer signs in.

Headers (cart redemption only)

The redemption routes follow the cart module's header contract:

HeaderDirectionNotes
x-cart-tokenrequest (optional) + responseCart handle. Mint by omitting — response sets it.
x-platformrequest (optional)WEB / APP (case-insensitive). Defaults to WEB.

Response envelope

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* on paginated lists */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
404NOT_FOUND (cart)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Redemption never errors on cap violations — the port returns ok: false with a typed reason. Callers render the rejection but the request succeeds with 200.

Currency + points

FieldUnit
points (everywhere)whole integers, no fractions
discountAmount, discountAmountSubunitsinteger subunits (paise / cents)
expiresAt, createdAtISO 8601

The conversion rate (rewards.point_value_subunits) is an admin setting — e.g. 10 means 1 point = 10 paise (₹0.10). discountAmount on the cart arrives pre-multiplied, so rendering an applied redemption needs no arithmetic; the rate is only needed to preview or bound a redemption before it is applied — read it from GET /store/settings/rewards.


Configuration

Every rewards setting except the two cron overrides (expiry_cron, pending_promote_cron) is registry-public, so the storefront reads the whole operator configuration anonymously from GET /store/settings/rewards — one flat { key: value } map, no auth, cacheable alongside the rest of the bootstrap fetch. Fold it into GET /store/settings on app boot rather than fetching per cart render.

Keys that gate the cart redeem box

KeyTypeDefaultStorefront use
enabledboolfalseMaster gate — hide every rewards surface when false
redemption_enabledboolfalseHide the cart redeem box but keep balance/history (earn-only mode)
point_value_subunitsint ≥ 110Conversion display and the redeemable ceiling
max_redeem_points_per_orderint ≥ 00 = unboundedAbsolute input ceiling
max_redeem_pct_of_subtotalint 0–100100Percentage ceiling + "up to N% of your order" copy
min_cart_total_subunitsint ≥ 00 = no floor"Spend ₹X more to use points"

Render the box only when the customer is signed in and enabled and redemption_enabled and point_value_subunits > 0 and available > 0 and the cart clears min_cart_total_subunits.

Computing the redeemable ceiling

Mirror the port's clamp order client-side, or the input will accept amounts the server silently reduces:

const maxByBalance = balance.available;
const maxByOrder =
  max_redeem_points_per_order > 0 ? max_redeem_points_per_order : Infinity;
const maxByPct = Math.floor(
  Math.floor((subtotalAfterCoupons * max_redeem_pct_of_subtotal) / 100) /
    point_value_subunits,
);
const maxRedeemable = Math.min(maxByBalance, maxByOrder, maxByPct);

subtotalAfterCoupons is the sum of PRODUCT lines only, post-coupon and pre-tax/shipping. The cart response has no field for it — derive it as cartTotals.subtotal − Σ appliedCoupons[].discountAmount. Do not use cartTotals.discountTotal: it already includes the redemption, so feeding it back in shrinks the ceiling on every re-render.

Keys for the account + earn-promo surfaces

KeyDefaultStorefront use
expiry_enabled / expiry_daysfalse / 365Show or hide expiry columns and the expiring-soon callout
pending_max_days30Explain when pending points become spendable
registration_enabled / registration_reward_pointsfalse / 0Signup bonus banner
purchase_enabled / purchase_reward_type / purchase_reward_amountfalse / FIXED / 0PDP "earn N points" badge
purchase_first_enabled / purchase_first_reward_type / purchase_first_reward_amountfalse / FIXED / 0First-order bonus (additive on the regular grant)
review_enabled / review_reward_pointsfalse / 0Review-for-points prompt
review_award_conditionON_APPROVALPost-submit copy: credited now vs on approval
review_one_per_producttrueSuppress the prompt on an already-reviewed product
review_purchased_users_onlytrueOnly promise points to verified purchasers

PERCENTAGE reward amounts are basis points (0–10000 = 0.00%–100.00%), not percent.


Domain types

BalanceResponse

type BalanceResponse = {
  /** Spendable balance. May be negative immediately after a refund
   *  reversal exceeded the redeemed amount; the storefront should
   *  surface that explicitly to avoid confusion. */
  available: number;
  /** Earned-but-not-yet-promoted points (e.g. credited at vendor
   *  fulfillment, waiting for delivery to become spendable). */
  pending: number;
  /** Points across `available` lots whose `expires_at` falls within
   *  the next 30 days. 0 when no lot is expiring soon. */
  expiringSoonPoints: number;
  /** Earliest expiry across available lots inside the soon-window.
   *  null when no lot is expiring soon. */
  expiringSoonAt: string | null; // ISO
};

HistoryItem

type HistoryItem = {
  id: string;
  /** 'earn' / 'redeem' / 'reverse' / 'expire' / 'restore' /
   *  'manual_credit' / 'manual_debit'. */
  entryType: RewardLedgerEntryType;
  /** Signed: positive for earn / restore / manual_credit;
   *  negative for redeem / reverse / expire / manual_debit. */
  points: number;
  /** Origin category — drives the row's icon + label on the
   *  storefront history list. */
  sourceType:
    | "order_vendor"
    | "first_purchase"
    | "customer_registration"
    | "review"
    | "redemption"
    | "reversal"
    | "restoration"
    | "expiry"
    | "manual";
  /** Free-text note (set on manual admin actions + reversal events). */
  reason: string | null;
  /** Set on `earn` rows. null on every other entry type, and on
   *  earn rows created when `expiry_enabled` was off. */
  expiresAt: string | null;
  createdAt: string;
};

CartAppliedRedemption (on CartResponse.appliedRedemption)

type CartAppliedRedemption = {
  /** Customer's most recent requested amount (cart.redemption_points). */
  requestedPoints: number;
  /** What the port accepted after clamping against balance, the
   *  per-order absolute cap, and the % of cart cap. May be less
   *  than `requestedPoints` (the cart UI should re-display so the
   *  customer can see the clamp), never more. */
  acceptedPoints: number;
  /** acceptedPoints × point_value_subunits, in integer subunits. */
  discountAmount: number;
  /** Pro-rata split across vendor sub-bags; sum equals discountAmount. */
  allocations: Array<{ vendorId: string; amount: number }>;
};

appliedRedemption is null when:

  • the rewards plugin is disabled (rewards.enabled = false) or not wired in at all,
  • the cart has redemption_points = 0 (customer hasn't asked to redeem),
  • the port rejected the request — CartResponse.redemptionRejection then carries the reason.

Because POST /store/cart/redemption refuses anything the port will not honour in full, requestedPoints equals acceptedPoints right after a successful set. They diverge on later reads when the cart moves under the customer — a line removed, a coupon applied — and the same points no longer all fit.

CartResponse.redemptionRejection

RewardRedemptionRejectReason | null on every cart read. Non-null when the cart holds points that bought nothing on this pass, so the storefront can say why a discount it was showing has gone: the cart dropped under min_cart_total_subunits, the operator tightened a cap, the balance moved. null when there is no intent or it applied in full.

RewardRedemptionRejectReason

MODULE_DISABLED       — rewards.enabled or redemption_enabled is off
USER_REQUIRED         — anonymous cart, customer hasn't signed in
INSUFFICIENT_BALANCE  — available balance is 0
BALANCE_NEGATIVE      — available balance is below 0 (post-reversal debt)
BELOW_MIN_CART        — cart subtotal after coupons < min_cart_total_subunits
EXCEEDS_PER_ORDER_CAP — request > max_redeem_points_per_order
EXCEEDS_PCT_CAP       — requested discount > max_redeem_pct_of_subtotal × subtotal
RATE_NOT_CONFIGURED   — point_value_subunits is 0 (mis-configuration)

These reach the storefront two ways: as errorCode on the 400 from POST /store/cart/redemption (alongside ABOVE_MAX_REDEEMABLE_POINTS, which the port itself never returns — the cart raises it when only part of a request could apply), and as redemptionRejection on any cart read whose stored intent bought nothing.


Endpoints

GET /store/rewards/balance — Read the customer's balance

Auth: customer session required.

Response 200BalanceResponse.

{
  "data": {
    "available": 1240,
    "pending": 100,
    "expiringSoonPoints": 50,
    "expiringSoonAt": "2026-06-12T17:10:07.651Z"
  },
  "message": "Success",
  "statusCode": 200
}

A customer who has never earned/redeemed points gets { available: 0, pending: 0, expiringSoonPoints: 0, expiringSoonAt: null } — the state row is lazy-materialized on first earn, not on first read.


GET /store/rewards/history — Paginated ledger history (newest first)

Auth: customer session required.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50

Response 200 — paginated HistoryItem[] with metadata: { total, limit, offset, hasMore }.

{
  "data": [
    {
      "id": "79721f68-ce09-461a-ba97-3f6fe501d834",
      "entryType": "earn",
      "points": 50,
      "sourceType": "customer_registration",
      "reason": null,
      "expiresAt": "2027-05-14T17:10:07.651Z",
      "createdAt": "2026-05-14T17:10:07.649Z"
    },
    {
      "id": "5d2c63ee-1b1e-4f57-9c44-...",
      "entryType": "redeem",
      "points": -1000,
      "sourceType": "redemption",
      "reason": "order:07001304-6fa4-4c26-bfac-0cc6905e7c1e",
      "expiresAt": null,
      "createdAt": "2026-05-14T17:13:55.121Z"
    }
  ],
  "message": "Success",
  "statusCode": 200,
  "metadata": { "total": 2, "limit": 20, "offset": 0, "hasMore": false }
}

Cart redemption surface

Lives in the cart module's controller for header consistency but is part of the rewards story. The intent is evaluated against the port before it is persisted, so the cart never holds a number the pricing pass would not honour.

POST /store/cart/redemption — Set redemption points on the cart

Auth: optional. Anonymous carts can carry the intent; redemption evaluation only succeeds once the customer signs in.

Body

{ "points": 500 }
FieldTypeConstraints
pointsint0..1_000_000. Pass 0 to clear.

The request is evaluated against the caps, the customer's balance and the minimum cart total before it is stored. Anything the port will not honour in full is refused rather than quietly reduced:

  • fully allowed — stored and applied as asked;
  • partly allowed (e.g. 2000 points against a max_redeem_points_per_order of 1000) — refused with ABOVE_MAX_REDEEMABLE_POINTS; details.maxPoints is the most this cart can spend right now (the strictest of balance, per-order cap and % cap), so the UI can show the limit and offer to redeem that instead;
  • worth nothing — refused with the port's reason as errorCode.

A refused request never becomes the cart's intent. Whatever the cart held before is kept only while it still applies in full; an older intent the caps have since outgrown is dropped rather than left to price as a silently reduced discount, so a refusal can lower the cart's redemption to zero but never to a clamped amount.

Response 200 — the re-priced CartResponse, appliedRedemption reporting what the cart is actually priced against.

{
  "data": {
    "cartId": "cafe5664-...",
    "bags": [ /* ... */ ],
    "cartTotals": {
      "subtotal": 125800,
      "discountTotal": 5000,
      "shippingTotal": 0,
      "total": 120800
    },
    "appliedCoupons": [],
    "appliedRedemption": {
      "requestedPoints": 500,
      "acceptedPoints": 500,
      "discountAmount": 5000,
      "allocations": [
        { "vendorId": "XzkY5vtg...", "amount": 5000 }
      ]
    }
  },
  "message": "Success",
  "statusCode": 200
}

Bag discountAllocated includes the redemption share; if you also have an active coupon, the line's allocatedDiscount is the sum of the coupon + redemption allocations against that line.

Errors

StatusCodeWhen
400VALIDATION_ERRORpoints outside [0, 1_000_000]
400ABOVE_MAX_REDEEMABLE_POINTSOnly part of the request could apply — details.maxPoints is the ceiling
400MODULE_DISABLEDRewards or redemption is switched off, or the plugin is not mounted
400USER_REQUIREDAnonymous cart — the customer must sign in to redeem
400INSUFFICIENT_BALANCENo points available to spend
400BALANCE_NEGATIVEBalance is below 0 (post-reversal debt)
400BELOW_MIN_CARTCart subtotal after coupons is under min_cart_total_subunits
400EXCEEDS_PER_ORDER_CAPThe caps leave no points at all for this order
400EXCEEDS_PCT_CAPmax_redeem_pct_of_subtotal leaves no whole point to spend
400RATE_NOT_CONFIGUREDpoint_value_subunits is 0 (mis-configuration)
404NOT_FOUNDCart not found (bad / expired token)

Every 400 carries details.requestedPoints — what was asked for and refused; ABOVE_MAX_REDEEMABLE_POINTS adds details.maxPoints:

{
  "data": null,
  "message": "You can redeem at most 1000 points on this order",
  "statusCode": 400,
  "errorCode": "ABOVE_MAX_REDEEMABLE_POINTS",
  "details": { "requestedPoints": 2000, "maxPoints": 1000 }
}

DELETE /store/cart/redemption — Clear redemption from the cart

Equivalent to POST { points: 0 }. Response 200 — re-priced cart with appliedRedemption: null.


Lifecycle (what affects the balance, and when)

These are the only events that move a customer's balance. They fire automatically when the underlying actions happen elsewhere in the system — the storefront doesn't trigger them.

EventCustomer-visible effect
Signup (customer.registered)Earn registration_reward_points to available, immediately.
Sub-order vendor-fulfilled (order.vendor.fulfilled)Earn purchase points (regular + first-purchase bonus when applicable) into pending. Customer sees them in pending, can't spend yet.
Sub-order delivered (order.vendor.delivered)Pending → available for that sub-order.
Stuck-pending sweep (cron, default 30d after fulfillment)If a sub-order is fulfilled but never reaches delivered, the daily sweep auto-promotes its pending lot to available so the customer is never stranded.
Sub-order cancelled / RTOPending lot voided (no balance change); available portion of the sub-order's earned points reversed. Balance may go negative if the customer had already redeemed against the original earn.
Return refundedProportional reverse of the sub-order's points based on refundedAmount / sub-order subtotal.
Order cancelled / refundedRedemption points are restored to the customer's balance as a new restore lot inheriting the original earn lot's expires_at (no expiry reset, no double-up).
Review approved (or submitted, depending on admin setting)Earn review_reward_points, gated by purchased-users-only + one-review-per-product.
Daily expiry sweep (cron, default 03:30)Available lots past expires_at are written off; balance drops. The history row's entryType will be expire.
Admin credit / debitAn ops user manually adjusts the balance with a reason. Shows in history as manual_credit (positive) or manual_debit (negative). Admin debit cannot push balance negative; refund-induced reversal can.

Negative-balance UX

A customer can land in negative balance if they:

  1. earn 1000 pts on an order,
  2. redeem those 1000 pts on a later order,
  3. return the first order — its earn is reversed but the points have already been spent.

The storefront should surface negative balances explicitly (e.g. "You owe 200 points — they'll be repaid from your next earning"). Subsequent earns settle the debt first; redemption is blocked while the balance is below zero (port returns BALANCE_NEGATIVE).

On this page