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). The storefront never sees this rate directly; it gets pre-multiplied discount amounts.


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 (any of the rejection reasons below).

RewardRedemptionRejectReason (informational — never returned directly to the storefront; reflected as appliedRedemption: null after caps clamp)

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)

The cart UI does not see these strings — it sees the clamped result. Use them as guidance when explaining "why my points aren't applying" in support flows.


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 actual port evaluation runs inside cart-pricing.service.ts on the next priced-cart read — these endpoints only persist the customer's intent.

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.

Response 200 — the re-priced CartResponse with appliedRedemption populated (or null if the port rejected the request — see CartAppliedRedemption above).

{
  "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]
404NOT_FOUNDCart not found (bad / expired token)

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