Supercommerce API Docs
Store API

Order Module — Storefront

HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow…

HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow (eligibility, photo upload, request, list, detail, cancel).

Source: api-modules/order/src/controllers/store-orders.controller.ts, store-returns.controller.ts.

The module orchestrates the cart → order handoff: it consumes CartService (cart resolution + checkout commit), PaymentRegistry (provider/method dispatch), InventoryService (reservation lifecycle), and the shipping registry. Webhooks (payment, courier) live in their own provider modules.


Conventions

Authentication

Endpoint groupAuth
GET /store/checkout/payment-providersrequired (customer)
POST /store/checkout/place-orderrequired (customer)
GET /store/orders, GET /store/orders/:id, POST /store/orders/:id/cancelrequired (customer)
/store/orders/:id/returns/**, /store/returns/photosrequired (customer)

Customer order detail and return detail enforce a no-leak rule: ids that belong to a different customer return 404 Not Found, never 403.

Headers

POST /store/checkout/place-order requires:

HeaderRequiredNotes
x-cart-tokenyesCart handle issued by the cart endpoints. Identifies the active cart even for a logged-in customer (handles the guest→customer adoption window)
x-platformnoWEB or APP (case-insensitive). Defaults to WEB. Used to pick a platform-specific enabled payment provider list

GET /store/checkout/payment-providers accepts x-platform only.

Response envelope

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, PAYMENT_PROVIDER_NOT_ENABLED, PAYMENT_METHOD_INVALID
401UNAUTHORIZED
403FORBIDDEN (payment provider not enabled, or cart not yours)
404NOT_FOUND
409CONFLICT, INVALID_TRANSITION, PARENT_NOT_CANCELLABLE
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Currency

All money fields (subtotal, discountTotal, shippingTotal, taxTotal, grandTotal, unitPrice, lineSubtotal, lineTotal, discountAllocated, netAmount, refundAmount, tax components) are integer subunits (paise / cents / eurocents).

Lifecycle (customer-visible)

Parent — order.status

StatusNotes
pending_paymentSDK-driven providers (Razorpay etc.); inventory reserved but not committed
confirmedSynchronous payment (COD/manual) at place-order, or payment webhook
cancelledCustomer/admin cancel, or all sub-orders cancelled. Terminal.

Parent payment — order.payment_status

StatusNotes
pendingInitial value
paidProvider success, webhook, admin mark-paid, or admin mark-paid for COD cash collection
failedWebhook failure — order stays pending_payment for retry
refundedAdmin bookkeeping flag

Sub-order — order_vendor.fulfillment_status

pendingfulfilleddelivered, with cancelled reachable from pending or fulfilled. Customers don't drive these — they only observe them via vendorBreakdowns[].fulfillmentStatus.


Domain types

OrderResponse

type OrderStatus     = "pending_payment" | "confirmed" | "cancelled";
type PaymentStatus   = "pending" | "paid" | "failed" | "refunded";
type Platform        = "APP" | "WEB" | "BOTH";
type OrderLineType   = "PRODUCT" | "GIFT";

type AddressBlock = {
  firstName: string;
  lastName: string;
  fullAddress: string;
  city: string;
  pincode: string;
  state: string;
  phone: string;
  country: string;
};

type OrderResponse = {
  id: string;
  orderNumber: string;
  customer: { id: string; name: string; email: string } | null; // The customer account that placed the order; null for legacy/guest orders.
  status: OrderStatus;
  paymentStatus: PaymentStatus;
  paymentProvider: string;          // e.g. "manual", "razorpay", "phonepe"
  paymentMethod: string;            // e.g. "cod", "upi", "card"
  platform: Platform;

  shippingAddress: AddressBlock;
  billingAddress: AddressBlock;
  customerNote: string | null;      // shopper's checkout instruction, snapshotted at placement

  subtotal: number;                 // subunits
  discountTotal: number;
  shippingTotal: number;
  taxTotal: number;
  grandTotal: number;

  vendorBreakdowns: OrderVendorResponse[];
  events: OrderEventResponse[];     // tail of audit-log rows (most recent first)

  /** Bootstrap data the storefront/SDK uses to complete a client-driven
   *  payment flow. Absent for synchronous providers like manual COD.
   *  `payload` is provider- and platform-specific — see below. */
  pendingClientAction: {
    provider: string;
    payload: Record<string, unknown>;
  } | null;

  placedAt: string;                 // ISO
  confirmedAt: string | null;
  paidAt: string | null;
  cancelledAt: string | null;
  cancellationReason: string | null;
};

OrderVendorResponse

type OrderVendorResponse = {
  id: string;
  vendorId: string;
  vendorNameAtOrder: string;        // snapshot
  fulfillmentStatus: "pending" | "fulfilled" | "delivered" | "cancelled";
  subtotal: number;
  discountAllocated: number;
  shippingCost: number;
  taxAmount: number;
  total: number;
  shippingProviderId: string | null;
  shippingMethod: string | null;
  trackingCode: string | null;
  awbNumber: string | null;
  taxBreakdown: TaxComponent[];     // aggregated by tax type across this vendor's lines + shipping
  shippingNetAmount: number | null;
  shippingTaxBreakdown: TaxComponent[];
  fulfilledAt: string | null;
  deliveredAt: string | null;
  cancelledAt: string | null;
  cancellationReason: string | null;
  lines: OrderLineResponse[];
};

OrderLineResponse

type OrderLineResponse = {
  id: string;
  vendorId: string;
  variantId: string | null;
  productId: string | null;
  productSlug: string | null;       // CURRENT store slug, resolved live for `/product/{slug}` links; null if not store-visible
  sku: string;
  productNameAtOrder: string;       // snapshot
  variantNameAtOrder: string | null;
  imageAtOrder: string | null;
  hsnCodeAtOrder: string | null;    // GST classification snapshot from variant
  type: OrderLineType;
  quantity: number;
  unitPrice: number;
  lineSubtotal: number;             // tax-inclusive amount displayed
  discountAllocated: number;
  lineTotal: number;
  netAmount: number | null;         // pre-tax portion
  taxBreakdown: TaxComponent[];
};

OrderEventResponse

type OrderEventResponse = {
  id: string;
  orderVendorId: string | null;
  eventType: string;
  actorType: "user" | "vendor" | "admin" | "system" | "webhook";
  actorId: string | null;
  actor: { id: string; name: string; email: string } | null; // Always null on the store surface (operator-only; staff PII not exposed to customers).
  source: string;
  changes: Record<string, unknown>;
  metadata: Record<string, unknown>;
  createdAt: string;
};

ReturnResponse

type ReturnResponse = {
  id: string;
  returnNumber: string;
  orderId: string;
  orderVendorId: string;
  customerId: string | null;
  vendorId: string;
  type: string;
  status: string;                   // e.g. requested / approved / picked_up / received / qc_passed / qc_failed / refunded / rejected / cancelled
  reasonCode: string | null;
  reasonNotes: string | null;
  refundAmount: number;             // subunits
  refundedAmount: number;
  externalRefundReference: string | null;
  shippingProvider: string | null;
  awbNumber: string | null;
  trackingCode: string | null;
  rejectionReason: string | null;
  qcFailureReason: string | null;
  requestedAt: string;
  approvedAt: string | null;
  rejectedAt: string | null;
  pickedUpAt: string | null;
  receivedAt: string | null;
  qcPassedAt: string | null;
  qcFailedAt: string | null;
  refundedAt: string | null;
  cancelledAt: string | null;
  lines: ReturnLineResponse[];
  photos: ReturnPhotoResponse[];
};

type ReturnLineResponse = {
  id: string;
  orderLineId: string;
  variantId: string | null;
  quantity: number;
  unitPrice: number;
  taxPortion: number;
  lineRefundAmount: number;
  reasonCode: string | null;
  reasonNotes: string | null;
  restocked: boolean;
};

Checkout

GET /store/checkout/payment-providers — Payment options for this platform

Returns the payment rows to render, for the caller's platform. Defaults to WEB when x-platform is missing.

options is render-ready: the operator's checkout-appearance settings (store.checkout_payment) merged over each gateway's own defaults, ordered as configured, with exactly one entry flagged isDefault. Clients should render these rows and send the chosen one's providerId + method to place-order — a storefront holding its own gateway labels or logos will silently fail to show a newly enabled gateway.

providers is the same enabled set before appearance is applied, kept for clients that dispatch on provider id.

Headers

HeaderNotes
x-platformWEB or APP

Response 200

{
  "data": {
    "providers": [
      { "id": "manual", "methods": ["cod", "bank-transfer"] },
      { "id": "phonepe", "methods": ["phonepe"] }
    ],
    "options": [
      {
        "key": "phonepe",
        "providerId": "phonepe",
        "method": "phonepe",
        "label": "PhonePe",
        "description": "UPI, cards, net banking and wallets via PhonePe.",
        "logoUrl": "https://cdn.example.com/settings/phonepe.png",
        "badge": "Fastest",
        "isDefault": true
      },
      {
        "key": "manual.cod",
        "providerId": "manual",
        "method": "cod",
        "label": "Cash on Delivery",
        "description": "Pay with cash when your order arrives.",
        "logoUrl": null,
        "badge": null,
        "isDefault": false
      }
    ]
  },
  "message": "Success",
  "statusCode": 200
}
FieldNotes
keyStable option key. A single-method provider whose method equals its id collapses to the bare id (phonepe); multi-method providers qualify each one (manual.cod).
label / descriptionOperator override from provider_display, else the provider's displayName / checkoutDescription, else a prettified key.
logoUrlAlways absolute. The operator stores either a storage key or a pasted URL; the API resolves both, and it is null when unset.
badgeShort pill copy, e.g. "Fastest". null when unset.
isDefaultPreselect this row. The operator sets a primary per platform (default_option.web / .app), since the app and the web storefront rarely want the same one. A platform left empty, or pointed at an option not enabled there, falls back to its own first option — so no platform is ever left with nothing selected.

Ordering. provider_display row order is the checkout order. Enabled options with no row are appended after the configured ones in registry order rather than dropped, so switching a gateway on is enough to make it appear.

Exclusions. Providers flagged hiddenAtCheckout never appear (razorpay-magic runs its own cart-side flow). Anything the platform can't serve is filtered out too, so an APP call omits WEB-only gateways.

Which gateways are available at all is set in admin — see Payment Gateways.


POST /store/checkout/place-order — Convert active cart into an order

Resolves the cart via x-cart-token (and the session's customerId), validates the chosen provider+method against the platform's enabled list, then drives OrderService.createFromCart. For client-driven providers (Razorpay, PhonePe) the response carries pendingClientAction with the bootstrap data needed to complete payment. For synchronous providers (COD, manual) the order is confirmed and paymentStatus may already be paid on return.

The pendingClientAction.payload shape depends on the provider and on x-platform, because a mobile SDK needs different data than a browser:

// provider "razorpay" — Checkout SDK
{ "razorpayOrderId": "order_…", "keyId": "rzp_…", "amount": 125000, "currency": "INR", "prefill": { } }

// provider "phonepe", x-platform: WEB — hosted redirect
{ "phonepeOrderId": "OMO…", "redirectUrl": "https://…", "expireAt": 1703756259307, "amount": 125000 }

// provider "phonepe", x-platform: APP — mobile SDK
{ "phonepeOrderId": "OMO…", "token": "…", "merchantId": "…",
  "environment": "SANDBOX", "flowId": "…", "expireAt": 1703756259307, "amount": 125000 }

For PhonePe the client must always follow up with POST /store/orders/:id/phonepe/verify — neither its web redirect nor its SDK callback reports whether the payment succeeded. See payment-phonepe.md.

Headers

HeaderRequiredNotes
x-cart-tokenyesCart handle
x-platformnoWEB / APP, default WEB

Body

{
  "paymentProvider": "razorpay",   // trimmed, min 1
  "paymentMethod": "upi",          // trimmed, min 1
  "billingAddress": {              // optional — when omitted, billing copies shipping
    "firstName": "Ada",
    "lastName": "Lovelace",
    "fullAddress": "221B Baker Street",
    "city": "London",
    "pincode": "110001",
    "state": "Delhi",
    "phone": "+919876543210",
    "country": "IN"
  },
  "customerNote": "Leave with the security desk" // optional, trimmed, max 500 chars
}

billingAddress field constraints reuse the address-book validators (Indian pincode regex /^[1-9]\d{5}$/, phone /^(\+91)?[6-9]\d{9}$/).

customerNote is stored verbatim on the order and never edited afterwards. It is returned on the store, admin, and vendor order reads so the fulfilling side sees the instruction.

Response 201OrderResponse.

Errors

StatusCodeWhen
400BAD_REQUESTx-cart-token header missing
400PAYMENT_PROVIDER_NOT_ENABLED / PAYMENT_METHOD_INVALIDProvider/method rejected by registry
400PINCODE_NOT_SERVICEABLEShipping address is on the delivery deny-list in block mode. message is the operator's own copy; details.pincode carries the pincode. See Serviceability
403FORBIDDENCart belongs to another customer
404NOT_FOUNDCart cannot be resolved
409CART_EMPTY, INSUFFICIENT_INVENTORYCart no longer placeable

Side effects — emits order.placed; for synchronous-paid providers also order.paid. The cart transitions to converted.


POST /store/checkout/buy-now/place-order — Convert a Buy Now cart into an order

Same body, response, and side effects as POST /store/checkout/place-order. The only difference is cart resolution: x-cart-token must be a buy-now cart handle from POST /store/cart/buy-now (see cart.md) — it's resolved strictly by token and ownership, never the caller's regular active cart. Returns 404 (NOT_FOUND) if the token doesn't resolve to a buy_now cart owned by the caller.


Orders

GET /store/orders — Paginated list of my orders

Most recent first.

Query

NameTypeDefaultNotes
pageint1>= 1
limitint(module default)1..MAX_ORDER_PAGE_SIZE
statusOrderStatus?Filter by pending_payment / confirmed / cancelled
startDateTimeISO-8601?Inclusive lower bound on order placed-at
endDateTimeISO-8601?Inclusive upper bound on order placed-at; must be >= startDateTime

Response 200 — paginated OrderResponse[].


GET /store/orders/:id — Order detail

Response 200 — full OrderResponse with embedded vendorBreakdowns and recent events.

Errors

StatusCodeWhen
404NOT_FOUNDOrder does not exist or belongs to another customer

POST /store/orders/:id/cancel — Cancel my order

Allowed only when no sub-order has yet been fulfilled or delivered. The cancel cascades to all sub-orders, releases their reservations, and emits order.cancelled.

Body

{ "reason": "Changed my mind" }  // optional, 1..500 chars

Response 200 — cancelled OrderResponse.

Errors

StatusCodeWhen
404NOT_FOUNDOrder not yours / does not exist
409PARENT_NOT_CANCELLABLEAt least one sub-order is past pending

Returns

Base path: /store/orders/:id/returns (with the photo-upload helper at /store/returns/photos).

GET /store/orders/:id/returns/eligibility — Per-sub-order eligibility

For each sub-order of the given order, returns whether it's currently returnable, the window expiry, the eligible reason codes, and the vendor's return policy text. Use to gate the "Request return" CTA.

Response 200

{
  "data": {
    "vendors": [
      {
        "orderVendorId": "01J9...",
        "vendorId": "01J9...",
        "returnable": true,
        "reason": null,
        "windowExpiresAt": "2026-05-20T00:00:00.000Z",
        "eligibleReasons": ["DAMAGED", "WRONG_ITEM", "NOT_AS_DESCRIBED"],
        "policyText": "Returns within 7 days of delivery..."
      }
    ]
  }
}

When returnable is false, reason carries a stable code (e.g. WINDOW_EXPIRED, ALREADY_RETURNED, NOT_DELIVERED) and windowExpiresAt may still be populated.


POST /store/returns/photos — Presigned upload URL for return evidence

Returns a presigned PUT URL the client uses to upload evidence (one photo per call). Unused keys age out via S3 lifecycle. Pass the returned storageKey in photoKeys[] of the create-return body.

Body

{
  "contentType": "image/jpeg",
  "fileSizeBytes": 524288         // max 20 MiB (20 * 1024 * 1024)
}

Response 200

{
  "data": {
    "storageKey": "returns/2026-05/abc.jpg",
    "uploadUrl": "https://s3.../signed-put-url",
    "expiresAt": "2026-05-13T11:45:00.000Z"
  }
}

POST /store/orders/:id/returns — Create a return request

Open a return against one sub-order. Service enforces:

  • caller owns the order;
  • orderVendorId belongs to that order;
  • per-line quantity <= delivered quantity;
  • return is within the vendor's window and reason is in eligibleReasons.

Body

{
  "orderVendorId": "01J9...",                // sub-order being returned
  "reasonCode": "DAMAGED",                   // 1..64 chars
  "reasonNotes": "Box arrived crushed",      // optional, max 2000 chars
  "lines": [
    {
      "orderLineId": "01J9...",
      "quantity": 1,                          // integer >= 1
      "reasonCode": "DAMAGED",                // optional per-line override
      "reasonNotes": "Top half dented"        // optional, max 2000 chars
    }
  ],
  "photoKeys": ["returns/2026-05/abc.jpg"]   // optional, max 20 keys, from POST /store/returns/photos
}

lines must be 1..100 entries. photoKeys must be 0..20 keys, each 1..500 chars.

Response 201ReturnResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
404NOT_FOUNDOrder or sub-order not visible to caller
409CONFLICTSub-order not currently returnable, or per-line quantity exceeds delivered

GET /store/orders/:id/returns — List returns on an order

Query

NameTypeDefaultNotes
pageint1>= 1
limitint(module default)1..MAX_ORDER_PAGE_SIZE
statusstring?1..32 chars; filter by return lifecycle status

Response 200 — paginated ReturnResponse[].


GET /store/orders/:id/returns/:returnId — Return detail

Response 200ReturnResponse.

Errors

StatusCodeWhen
404NOT_FOUNDReturn not yours / does not exist

POST /store/orders/:id/returns/:returnId/cancel — Withdraw a return

Allowed only before the courier confirms pickup (i.e. while the return is still in the customer's hands). Once pickedUpAt is stamped the customer can't withdraw — vendor/admin paths handle reversals after that.

Response 200 — cancelled ReturnResponse.

Errors

StatusCodeWhen
404NOT_FOUNDReturn not yours / does not exist
409CONFLICTAlready past requested/approved (courier picked up)

  • cartprepare-checkout runs before place-order to reserve inventory. See cart.md.
  • payment-razorpay / payment-phonepe / payment-manual — concrete providers behind paymentProvider/paymentMethod. See payment-razorpay.md and payment-phonepe.md for their storefront verify endpoints.
  • shipping — vendor-side shipping provider assignment; customer-side tracking is in shipping.md.
  • storage — backs the presigned upload for return photos.
  • customerbillingAddress shape mirrors the address-book validators. See customer.md.

On this page