Supercommerce API Docs
Admin API

Rewards Module — Admin

HTTP surface for platform staff managing the rewards/loyalty plugin: per-customer search, balance + history inspection, and manual credit/debit adjustments. All endpoints sit…

HTTP surface for platform staff managing the rewards/loyalty plugin: per-customer search, balance + history inspection, and manual credit/debit adjustments. All endpoints sit under a single new RBAC resource (rewards) with one action (manage) — granted by default to the superAdmin and admin roles.

Source: api-modules/rewards/src/controllers/admin-rewards.controller.ts.

Plugin configuration (rates, caps, gates, cron schedules) is not managed here — those are platform settings under group rewards, served by the generic /admin/settings/admin surface (settings.md). This controller is the per-customer ops surface only.


Conventions

Authentication

All endpoints require a Better-Auth admin session and the rewards: manage permission.

Endpoint groupPermission
GET /admin/rewards/**rewards: manage
POST /admin/rewards/customers/:id/creditrewards: manage
POST /admin/rewards/customers/:id/debitrewards: manage

Single-permission gate is deliberate — the standalone admin Rewards section is meant for customer-support/ops users; finer-grained view vs manage splits aren't worth the configuration overhead for this feature.

Response envelope

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR (incl. EXCEEDS_AVAILABLE for over-debit — see debit endpoint below)
401UNAUTHORIZED
403FORBIDDEN (missing rewards:manage)
404NOT_FOUND (customer or ledger row)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Currency + points

All money is integer subunits. Points are whole integers. The conversion rate (rewards.point_value_subunits) lives in admin settings under group rewards; this surface never returns the rate, only point amounts. Use the settings endpoint to read it for display purposes.


Domain types

CustomerSearchItem

type CustomerSearchItem = {
  customerId: string;
  email: string | null;
  name: string | null;
  /** Spendable balance. May be negative after a reversal exceeded
   *  the redeemed amount. */
  availableBalance: number;
  /** Earned-but-not-yet-promoted points (waiting on delivery /
   *  stuck-pending sweep). */
  pendingBalance: number;
  /** Set once when the customer crosses the first-purchase gate.
   *  null = first-purchase bonus is still unclaimed. */
  firstPurchaseAwardedAt: string | null;
};

Customers who have never had any rewards activity still appear in the search results with availableBalance: 0, pendingBalance: 0, firstPurchaseAwardedAt: null. The state row is lazy-materialized on first ledger insert, not on listing.

CustomerSummaryResponse

type CustomerSummaryResponse = {
  customerId: string;
  email: string | null;
  name: string | null;
  availableBalance: number;
  pendingBalance: number;
  firstPurchaseAwardedAt: string | null;
  /** Most recent reward_ledger.created_at for this customer.
   *  null when the customer has had zero rewards activity. */
  lastActivityAt: string | null;
};

LedgerListItem

type LedgerListItem = {
  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;
  /** Lot state — only set on `earn` rows; null elsewhere.
   *  Values: pending / available / consumed / expired / reversed / void. */
  state: RewardLotState | null;
  /** Set on `earn` rows; null elsewhere. */
  earnedAt: string | null;
  /** Set on `earn` rows when the lot was created with expiry enabled. */
  expiresAt: string | null;
  /** Origin category. */
  sourceType:
    | "order_vendor"
    | "first_purchase"
    | "customer_registration"
    | "review"
    | "redemption"
    | "reversal"
    | "restoration"
    | "expiry"
    | "manual";
  /** Interpretation depends on sourceType:
   *    order_vendor / first_purchase → orderVendor.id
   *    customer_registration → user.id (the customer themselves)
   *    review → reviewId (or `${productId}:${customerId}` when
   *             one_per_product is on — the dedupe key)
   *    redemption → orderId (or cartId if no order yet)
   *    reversal / restoration → parent_ledger_id is also set
   *    expiry → parent_ledger_id of the expired lot
   *    manual → admin user.id who performed the credit/debit */
  sourceId: string | null;
  /** For reverse/restore/expire rows, points back at the original
   *  earn/redeem ledger row this entry compensates. */
  parentLedgerId: string | null;
  /** Human / system note. Required on manual_credit + manual_debit. */
  reason: string | null;
  createdAt: string;
};

ManualAdjustResponse

type ManualAdjustResponse = {
  ledgerId: string;
  /** Customer's new balance after the adjustment. */
  availableBalance: number;
  pendingBalance: number;
};

Endpoints

Base path: /admin/rewards. Permission on every endpoint: rewards: manage.

Substring ILIKE across user.email + user.name. Customers with no rewards state row default to zero balances. Use this to populate the standalone Rewards admin section's customer-search UI.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..100
searchstring1..200 chars, optional

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

{
  "data": [
    {
      "customerId": "lWpZoPgXt8Oux2kWd1fntGQzKfO6MTx8",
      "email": "alice@test.com",
      "name": "Alice",
      "availableBalance": 19250,
      "pendingBalance": 0,
      "firstPurchaseAwardedAt": null
    }
  ],
  "message": "Success",
  "statusCode": 200,
  "metadata": { "total": 1, "limit": 20, "offset": 0, "hasMore": false }
}

GET /admin/rewards/customers/:customerId/summary — Single-customer detail

Powers the customer-detail page in the admin Rewards section: balance card, first-purchase status, last-activity timestamp.

Path params

NameNotes
customerIduser.id of the target customer

Response 200CustomerSummaryResponse.

Errors

StatusCodeWhen
404NOT_FOUNDNo user row with this id

GET /admin/rewards/customers/:customerId/ledger — Paginated ledger history

Newest first. Full audit view including non-earn rows (redeem / reverse / expire / restore / manual_credit / manual_debit) and parent-pointer links so support can trace "why did this reversal happen — what was the parent earn row?".

Path params

NameNotes
customerIduser.id of the target customer

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..100

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

{
  "data": [
    {
      "id": "fb6b014e-b1f3-4f2b-a2dd-c8d6d28d8c8b",
      "entryType": "earn",
      "points": 500,
      "state": "available",
      "earnedAt": "2026-05-14T17:12:50.000Z",
      "expiresAt": null,
      "sourceType": "manual",
      "sourceId": "<admin user id>",
      "parentLedgerId": null,
      "reason": "Verification credit",
      "createdAt": "2026-05-14T17:12:50.000Z"
    },
    {
      "id": "5d2c63ee-...",
      "entryType": "restore",
      "points": 1000,
      "state": null,
      "earnedAt": null,
      "expiresAt": null,
      "sourceType": "restoration",
      "sourceId": "07001304-...",
      "parentLedgerId": "9a48...redemption-row-id",
      "reason": "order.cancelled: Changed my mind",
      "createdAt": "2026-05-14T17:15:21.000Z"
    }
  ],
  "message": "Success",
  "statusCode": 200,
  "metadata": { "total": 2, "limit": 20, "offset": 0, "hasMore": false }
}

POST /admin/rewards/customers/:customerId/credit — Manual credit

Grant points to a customer with a required reason (audit). Creates an immediately-available earn lot — no pending hold for admin grants. Expiry defaults to the global rewards.expiry_days setting; pass neverExpire: true to grant a permanent lot.

Path params

NameNotes
customerIduser.id of the target customer

Body

{
  "points": 500,
  "reason": "Loyalty appreciation for being a long-time customer",
  "neverExpire": true
}
FieldTypeConstraints
pointsint1..1_000_000
reasonstringtrimmed 1..500 chars, required
neverExpirebooloptional, defaults to false

Response 201ManualAdjustResponse.

{
  "data": {
    "ledgerId": "fb6b014e-b1f3-4f2b-a2dd-c8d6d28d8c8b",
    "availableBalance": 19550,
    "pendingBalance": 0
  },
  "message": "Success",
  "statusCode": 201
}

Errors

StatusCodeWhen
400VALIDATION_ERRORpoints outside [1, 1_000_000] or reason empty
404NOT_FOUNDNo user row with this id

POST /admin/rewards/customers/:customerId/debit — Manual debit (capped at zero)

Remove points from a customer with a required reason. Consumes available lots FIFO (oldest expiry first, NULL-expiry lots last). Cannot push balance below zero — if the requested magnitude exceeds the customer's current available balance, returns 400 BAD_REQUEST with details.maxAllowed so the admin UI can re-render the modal with the right ceiling.

This is asymmetric with refund-induced reversal (which can push balance negative). The asymmetry is deliberate: admin manual actions need a typo guard; system-driven reversals are accurate by construction.

Path params

NameNotes
customerIduser.id of the target customer

Body

{
  "points": 300,
  "reason": "Correction — customer accidentally got 2× registration bonus"
}
FieldTypeConstraints
pointsint1..1_000_000 — absolute magnitude (the ledger row stores -points)
reasonstringtrimmed 1..500 chars, required

Response 201ManualAdjustResponse.

{
  "data": {
    "ledgerId": "699e6c81-f38c-43d1-9119-0a9f294d1979",
    "availableBalance": 19250,
    "pendingBalance": 0
  },
  "message": "Success",
  "statusCode": 201
}

Errors

StatusCodeWhen
400EXCEEDS_AVAILABLEpoints > current available_balance. Body carries details.maxAllowed (the largest amount the admin can debit right now).
400VALIDATION_ERRORpoints outside [1, 1_000_000] or reason empty
404NOT_FOUNDNo user row with this id

EXCEEDS_AVAILABLE example:

{
  "data": null,
  "message": "Cannot debit 100000 pts; max allowed is 19250.",
  "statusCode": 400,
  "errorCode": "BAD_REQUEST",
  "details": { "maxAllowed": 19250 }
}

Settings + crons (read-only here; configured elsewhere)

Plugin behavior is controlled by the rewards settings group, edited via /admin/settings/admin (settings.md). 24 keys, all admin-scope:

SectionKeys
Masterenabled, point_value_subunits
Redemptionredemption_enabled, max_redeem_points_per_order, max_redeem_pct_of_subtotal, min_cart_total_subunits
Product purchasepurchase_enabled, purchase_reward_type, purchase_reward_amount, purchase_first_enabled, purchase_first_reward_type, purchase_first_reward_amount
User registrationregistration_enabled, registration_reward_points
Product reviewreview_enabled, review_reward_points, review_award_condition, review_one_per_product, review_purchased_users_only
Expiryexpiry_enabled, expiry_days
Pending holdpending_max_days
Cron schedulesexpiry_cron, pending_promote_cron

Two BullMQ crons run inside the API process. They register on worker boot from the settings above and re-read the schedule values at startup — change-then-restart workers.

JobDefault scheduleBehavior
rewards-expiry30 3 * * * (03:30 daily)Scans earn lots in state='available' with expires_at < now() and writes negative expire ledger rows.
rewards-pending-promote0 3 * * * (03:00 daily)Promotes earn lots stuck in pending past pending_max_days (default 30) to available. Catches the edge case where a sub-order reached fulfilled but never delivered — the customer's points aren't stranded indefinitely.

Optionality

The whole module is opt-in. Removing RewardsModule.forRoot() from apps/api/src/app.module.ts makes the plugin disappear:

  • All /admin/rewards/** + /store/rewards/** routes drop off the router.
  • The cart-side @Optional() @Inject(REWARD_REDEMPTION_PORT) falls through to a no-op — POST /store/cart/redemption still accepts and persists the customer's intent on cart.redemption_points, but pricing returns appliedRedemption: null and there's no discount.
  • The two BullMQ crons stop registering.
  • No event listeners are wired, so order/customer/review events fire normally and the rewards machinery is silent.

cart.redemption_points (column added in migration 0034) and order.redemption_points_consumed (column added in 0035) stay in the schema either way — they're cheap zero-default integers and removing them would require a destructive migration. Treat them as harmless residue when the plugin is off.

On this page