Supercommerce API Docs
Full Module Docs

Order Module

HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).

HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).

Source: api-modules/order (registered via OrderModule.forRoot() in apps/api/src/app.module.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 (provider assignment at the pending → fulfilled transition). Webhooks (payment, courier) live in their own provider modules and call back into OrderServiceOrderModule is registered as global: true so those modules can inject OrderService without re-importing.


Conventions

Authentication

Endpoint groupAuthPermission
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)
GET /vendor/orders, GET /vendor/orders/:idrequired (vendor session)active vendor in session
POST /vendor/orders/:id/cancel, POST /vendor/orders/:id/fulfilled, POST /vendor/orders/:id/delivered, POST /vendor/orders/bulk-fulfillrequired (vendor session)active vendor in session
GET /admin/orders, GET /admin/orders/:idrequiredorder: view
POST /admin/orders/:id/cancelrequiredorder: cancel
POST /admin/orders/:id/mark-paid, POST /admin/orders/:id/mark-refundedrequiredorder: update

Vendor endpoints resolve the active vendor via resolveActiveVendorId(session) — sessions missing an active vendor are rejected with 403 Forbidden (FORBIDDEN). Vendor sub-order ids that belong to a different vendor return 404 Not Found, not 403 (no leak of foreign ids). Customer order detail follows the same no-leak rule.

Headers

POST /store/checkout/place-order requires both:

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

Successful responses are wrapped by ResponseInterceptor:

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

Error envelope

Failures are normalized by HttpExceptionFilter:

{
  "data": null,
  "message": "Human-readable summary",
  "statusCode": 400,
  "errorCode": "BAD_REQUEST",
  "errors": [ /* zod issues, when validation fails */ ],
  "debug": { /* dev-only: error + stack */ }
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, PAYMENT_PROVIDER_NOT_ENABLED, PAYMENT_METHOD_INVALID
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, INVALID_TRANSITION, PARENT_NOT_CANCELLABLE, SUB_ORDER_NOT_CANCELLABLE, ORDER_ALREADY_PAID, ORDER_ALREADY_REFUNDED
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Currency

All monetary fields (subtotal, discountTotal, shippingTotal, taxTotal, grandTotal, unitPrice, lineSubtotal, lineTotal, discountAllocated, netAmount, vendor breakdowns, tax components) are integer subunits (paise / cents / eurocents — single currency per tenant). Clients should send and receive integers; never decimal currency strings.

Lifecycle

Parent order — order.status

StatusSet byEditableNotes
pending_paymentOrderService.createFromCart for SDK-driven providers (Razorpay etc.)inventory reserved but not committed; awaiting client-side action / webhook confirmation
confirmedSynchronous COD/manual at place-order, or payment webhook / mark-paidinventory committed; sub-orders are now actionable by vendors
cancelledcancelByCustomer, cancelByAdmin, or all sub-orders cancellednoterminal

Parent payment — order.payment_status

StatusSet byNotes
pendingcreated with the orderinitial value for all providers
paidsynchronous provider success, payment webhook, mark-paid, or last COD sub-order deliveredstamps paid_at
failedpayment webhookparent stays pending_payment for retry until reservation TTL expires (or it auto-cancels)
refundedadmin mark-refundedgateway-side refund is out-of-band; this flag is bookkeeping only

Sub-order — order_vendor.fulfillment_status

Each vendor on the order has its own row with its own lifecycle.

StatusSet byEditableNotes
pendingcreated at place-orderyesawaiting vendor to assign a shipping provider
fulfilledPOST /vendor/orders/:id/fulfilled (or bulk-fulfill)partialprovider/method/tracking persisted; stamps fulfilled_at. cancel still allowed (courier rejected, RTO)
deliveredPOST /vendor/orders/:id/delivered (or courier webhook)nostamps delivered_at. For COD, the last delivered sub-order auto-flips parent payment_status → paid
cancelledPOST /vendor/orders/:id/cancel, parent cancel cascadenoreleases this vendor's reservation; if all siblings cancelled, parent auto-cancels

Allowed transitions are enforced server-side — invalid moves (e.g. cancelling a delivered sub-order, fulfilling a cancelled one) return 409 INVALID_TRANSITION.

Audit log

Every status mutation writes one or more rows to order_events (one per affected order/sub-order, in the same transaction as the change). The row carries actor_type (user / vendor / admin / system / webhook), actor_id, source, structured changes, and optional metadata. The most recent 50 rows ship inline on order detail (events[]); older rows stay in the table.

Domain events

Emitted via EventEmitter2 after a successful write — consumed by the notifications module (email + push) and the search reindexer.

EventFired whenPayload (high-level)
order.placedOrderService.createFromCart commitsorder id, customer id, vendor ids, totals, platform
order.paidparent payment_status → paidorder id, payment provider, external ref
order.refundedparent payment_status → refundedorder id, actor, reason
order.cancelledparent status → cancelled (any actor)order id, actor, reason
order.vendor.fulfilledsub-order pending → fulfilledorder vendor id, provider id, method, tracking
order.vendor.deliveredsub-order fulfilled → deliveredorder vendor id, delivered at
order.vendor.cancelledsub-order * → cancelledorder vendor id, actor, reason

Domain types

OrderResponse

Customer + admin response. Embeds vendor sub-orders, their lines, and recent events so the detail page is one round trip.

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

type OrderResponse = {
  id: string;
  orderNumber: string;
  status: OrderStatus;
  paymentStatus: PaymentStatus;
  paymentProvider: string;       // e.g. "manual", "razorpay"
  paymentMethod: string;         // e.g. "cod", "upi", "card"
  platform: Platform;

  shippingAddress: AddressBlock;
  billingAddress: AddressBlock;

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

  vendorBreakdowns: OrderVendorResponse[];
  events: OrderEventResponse[];  // most recent 50

  pendingClientAction: {
    provider: string;            // e.g. "razorpay"
    payload: Record<string, unknown>; // SDK bootstrap data
  } | null;

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

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

OrderVendorResponse

type OrderVendorStatus = "pending" | "fulfilled" | "delivered" | "cancelled";

type OrderVendorResponse = {
  id: string;
  vendorId: string;
  vendorNameAtOrder: string;     // snapshot
  fulfillmentStatus: OrderVendorStatus;

  subtotal: number;
  discountAllocated: number;
  shippingCost: number;
  taxAmount: number;
  total: number;

  shippingProviderId: string | null;  // set at pendingfulfilled
  shippingMethod: string | null;
  trackingCode: string | null;
  awbNumber: string | null;

  // Tax breakdown aggregated across this vendor's lines + their shipping.
  // Empty when the vendor has no taxes configured.
  taxBreakdown: TaxComponent[];
  shippingNetAmount: number | null;
  shippingTaxBreakdown: TaxComponent[];

  fulfilledAt: string | null;
  deliveredAt: string | null;
  cancelledAt: string | null;
  cancellationReason: string | null;

  lines: OrderLineResponse[];
};

OrderLineResponse

type OrderLineType = "PRODUCT" | "GIFT";

type OrderLineResponse = {
  id: string;
  vendorId: string;
  variantId: string | null;
  productId: string | null;
  sku: string;
  productNameAtOrder: string;        // snapshot
  variantNameAtOrder: string | null; // snapshot
  imageAtOrder: string | null;       // snapshot
  hsnCodeAtOrder: string | null;     // India GST classification, snapshot from variant
  type: OrderLineType;
  quantity: number;
  unitPrice: number;
  lineSubtotal: number;              // tax-inclusive
  discountAllocated: number;
  lineTotal: number;
  netAmount: number | null;          // pre-tax portion; null when no taxes
  taxBreakdown: TaxComponent[];
};

OrderEventResponse

type OrderEventActor = "user" | "vendor" | "admin" | "system" | "webhook";

type OrderEventResponse = {
  id: string;
  orderVendorId: string | null;       // null = parent-order event
  eventType: string;                  // e.g. "order.placed", "vendor.fulfilled"
  actorType: OrderEventActor;
  actorId: string | null;
  source: string;                     // e.g. "storefront", "vendor-panel", "razorpay-webhook"
  changes: Record<string, unknown>;   // beforeafter fields
  metadata: Record<string, unknown>;
  createdAt: string;
};

Storefront

Base prefix: /store. All endpoints require a Better-Auth session.

GET /store/checkout/payment-providers — List enabled providers

Returns the admin-enabled payment providers for the caller's platform (x-platform), each with its supported methods. The storefront uses this to render the payment selector at checkout.

Headers

NameRequiredNotes
x-platformnoWEB or APP; defaults to WEB

Response 200

{
  "data": [
    {
      "provider": "manual",
      "label": "Cash on Delivery",
      "methods": [{ "id": "cod", "label": "Cash on Delivery" }]
    },
    {
      "provider": "razorpay",
      "label": "Razorpay",
      "methods": [
        { "id": "upi",  "label": "UPI" },
        { "id": "card", "label": "Card" },
        { "id": "netbanking", "label": "Net Banking" }
      ]
    }
  ],
  "message": "Success",
  "statusCode": 200
}

POST /store/checkout/place-order — Place an order

Converts the caller's active cart into an order. Body picks the registered payment provider; for SDK-driven providers (Razorpay etc.) the response carries pendingClientAction with the bootstrap data the client SDK needs.

Body

{
  "paymentProvider": "razorpay",
  "paymentMethod": "upi",
  "billingAddress": {              // optional — defaults to shipping snapshot
    "firstName": "Ada",
    "lastName": "Lovelace",
    "fullAddress": "221B Baker Street",
    "city": "London",
    "pincode": "NW1 6XE",
    "state": "Greater London",
    "phone": "+44-20-7224-3688",
    "country": "GB"               // optional
  }
}
FieldTypeConstraints
paymentProviderstringRequired. Must be registered AND enabled for the cart's platform
paymentMethodstringRequired. Must belong to paymentProvider's method list
billingAddressobject?Validated against the same schemas as the address book. When omitted the order's billing block copies the shipping block

Response 201OrderResponse

For synchronous providers (manual COD, manual bank-transfer) the order returns with status="confirmed" and paymentStatus reflecting the provider behavior (COD stays pending until delivery; manual-paid flips to paid). pendingClientAction is null.

For SDK-driven providers (Razorpay) the order returns with status="pending_payment", paymentStatus="pending", and pendingClientAction populated:

{
  "pendingClientAction": {
    "provider": "razorpay",
    "payload": {
      "razorpayOrderId": "order_NWxYz...",
      "razorpayKeyId": "rzp_test_...",
      "amount": 125000,
      "currency": "INR"
    }
  }
}

The client SDK completes the payment, then either calls POST /store/payments/razorpay/verify (see payment-razorpay.md) or the Razorpay webhook flips the order. Inventory is reserved (not committed) for the entire pending-payment window; reservation TTL expiry auto-cancels the order if payment never lands.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod; missing x-cart-token
403FORBIDDENCart does not belong to caller
403PAYMENT_PROVIDER_NOT_ENABLEDProvider not in the enabled list for the platform
400PAYMENT_METHOD_INVALIDMethod not registered under the chosen provider
404NOT_FOUNDCart not found
409CART_EMPTYActive cart has no product lines
409INSUFFICIENT_INVENTORYReservation step couldn't hold stock for one or more lines

Side effects

  • Reserves inventory for every product line (committed at pending_payment → confirmed).
  • Marks the cart converted (no further mutations).
  • Writes order.placed to order_events and emits the domain event.

GET /store/orders — List my orders

Paginated, newest first. Customer scope is taken from the session.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..100
statusOrderStatusoptional filter
startDateTimeISO-8601optional; inclusive lower bound on order placed-at
endDateTimeISO-8601optional; inclusive upper bound on order placed-at (>= startDateTime)

Response 200 — paginated envelope of OrderResponse.


GET /store/orders/:id — Order detail

Returns the full OrderResponse (including vendor breakdowns, lines, and recent events).

Errors

StatusCodeWhen
404NOT_FOUNDOrder does not exist or belongs to another customer (no leak)

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

Customer-initiated cancel. Allowed only when no sub-order has been fulfilled or delivered yet. Cascades sub-orders to cancelled, releases per-vendor inventory reservations (no-op if already committed), and writes audit rows for the parent + each affected sub-order.

Body

{ "reason": "Changed my mind" }
FieldTypeConstraints
reasonstring?1..500 chars

Response 200 — updated OrderResponse.

Errors

StatusCodeWhen
404NOT_FOUNDOrder not owned by caller
409PARENT_NOT_CANCELLABLEAt least one sub-order is fulfilled or delivered

Vendor

Base path: /vendor/orders. All endpoints require a Better-Auth session with an active vendor. :id everywhere is order_vendor.id (a sub-order). Cross-vendor ids return 404 Not Found.

GET /vendor/orders — List my sub-orders

Paginated, newest first.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..100
statusOrderVendorStatusoptional filter

Response 200 — paginated envelope of OrderVendorResponse.


GET /vendor/orders/:id — Sub-order detail

Returns a OrderVendorResponse for the active vendor's sub-order.

Errors

StatusCodeWhen
404NOT_FOUNDSub-order does not belong to active vendor

POST /vendor/orders/:id/cancel — Cancel sub-order

Allowed from pending (declining fulfillment) or fulfilled (courier rejected, package returned, etc.). delivered and cancelled reject. For fulfilled cancels a non-empty reason is required.

Body

{ "reason": "Out of stock at warehouse" }

Response 200 — updated OrderVendorResponse.

Side effects

  • Releases this vendor's inventory reservation (no-op when already committed at place-order).
  • Fires the shipping registry's cancelShipment() hook (best-effort courier void).
  • Writes an audit row + emits order.vendor.cancelled.
  • If every sibling sub-order is now cancelled, the parent order auto-cancels (cascading audit + order.cancelled).

Errors

StatusCodeWhen
404NOT_FOUNDSub-order not owned by vendor
409SUB_ORDER_NOT_CANCELLABLEStatus is delivered or already cancelled
400VALIDATION_ERRORCancelling a fulfilled sub-order without a reason

POST /vendor/orders/:id/fulfilled — Mark fulfilled

Assigns a shipping provider, dispatches provider.createShipment(), and transitions pending → fulfilled. Only allowed when the sub-order is pending.

Body

{
  "providerId": "clickpost",
  "method": "express",
  "trackingCode": "CP123456",   // optional
  "awbNumber": "AWB987654"      // optional
}
FieldTypeConstraints
providerIdstringRequired. Must be enabled for the active vendor
methodstringRequired. Must belong to that provider
trackingCodestring?1..200 chars. Some providers fill this asynchronously via webhook
awbNumberstring?1..200 chars

Response 200 — updated OrderVendorResponse with fulfillmentStatus="fulfilled", fulfilledAt stamped, provider fields populated.

Side effects

  • Validates (providerId, method) against the vendor's enabled providers.
  • Dispatches provider.createShipment() — provider failure rolls back the transition (sub-order stays pending).
  • Writes audit row + emits order.vendor.fulfilled.

Errors

StatusCodeWhen
404NOT_FOUNDSub-order not owned by vendor
409INVALID_TRANSITIONSub-order is not pending
400VALIDATION_ERRORProvider/method invalid for this vendor

POST /vendor/orders/bulk-fulfill — Bulk fulfill

Apply the same provider/method/(tracking)/(awb) across up to 200 sub-orders in one call. Each sub-order runs in its own transaction so one failure does not roll back the rest. Vendors needing per-order tracking codes should call the single-order endpoint.

Body

{
  "orderVendorIds": ["01J9...", "01J9..."],
  "providerId": "clickpost",
  "method": "express",
  "trackingCode": "CP123456",   // optional, applies to every id
  "awbNumber": "AWB987654"      // optional, applies to every id
}
FieldConstraints
orderVendorIds1..200 ids

Response 200

{
  "data": {
    "successful": ["01J9...", "01J9..."],
    "errors": [
      { "orderVendorId": "01J9...", "reason": "Sub-order not in pending status" }
    ]
  },
  "message": "Success",
  "statusCode": 200
}

POST /vendor/orders/:id/delivered — Mark delivered

Allowed only when the sub-order is fulfilled. Stamps deliveredAt and transitions to delivered.

Body — empty.

Response 200 — updated OrderVendorResponse.

Side effects

  • Audit row + order.vendor.delivered event.
  • COD auto-paid: when every non-cancelled sub-order on the parent is delivered, the parent's payment_status flips to paid and paid_at is stamped — the customer has paid every vendor on delivery. An order.paid event fires.

Errors

StatusCodeWhen
404NOT_FOUNDSub-order not owned by vendor
409INVALID_TRANSITIONSub-order is not fulfilled

Admin

Base path: /admin/orders. Requires order:* permissions (view / cancel / update).

GET /admin/orders — List all orders

Same query shape as the storefront list, but unscoped (every customer, every vendor).

Required permission: order:view.


GET /admin/orders/:id — Order detail

Same shape as GET /store/orders/:id, no customer-scope filter.

Required permission: order:view.


POST /admin/orders/:id/cancel — Cancel on behalf of customer

Same semantics as the customer cancel, but bypasses the customer-ownership check. Still blocked when any sub-order is delivered.

Required permission: order:cancel.

Body

{ "reason": "Customer requested via support" }

Errors — same as customer cancel, plus 403 FORBIDDEN when caller lacks order:cancel.


POST /admin/orders/:id/mark-paid — Manually mark as paid

For bank-transfer settlements, stuck COD edge cases (vendor confirmed delivery offline), and support overrides.

Required permission: order:update.

Body

{
  "externalReference": "BANK-TXN-2026-04-1234",  // optional, ≤200 chars
  "reason": "Bank transfer settled on 2026-04-12" // optional, ≤500 chars
}

Response 200 — updated OrderResponse.

Side effects

  • If the order was at pending_payment, transitions it to confirmed and commits the inventory reservation.
  • Stamps paid_at; emits order.paid.

Errors

StatusCodeWhen
409ORDER_ALREADY_PAIDpayment_status is already paid
409INVALID_TRANSITIONOrder is cancelled
404NOT_FOUNDOrder not found

POST /admin/orders/:id/mark-refunded — Mark as refunded

v1 refund flow is admin-driven: the admin issues the refund out-of-band in the gateway's dashboard, then calls this to flip our payment_status to refunded. Order status (confirmed/cancelled) is unchanged — refund is a money-only operation.

Required permission: order:update.

Body

{
  "externalReference": "rfnd_NX...",  // optional gateway refund id
  "reason": "Customer return processed"
}

Response 200 — updated OrderResponse.

Side effects — emits order.refunded.

Errors

StatusCodeWhen
409ORDER_ALREADY_REFUNDEDpayment_status is already refunded
409CONFLICTOrder has not been paid yet
404NOT_FOUNDOrder not found

Configuration

Env varDefaultPurpose
ORDER_REQUEST_RESERVATION_TTL_MINUTESinherited from inventoryWindow between place-order and payment confirmation; expiry auto-cancels the order

  • cart — provides CartService.resolve and the cart→order checkout commit consumed by OrderService.createFromCart. See cart.md.
  • payment-razorpay — webhook + verify endpoints that flip parent payment_status. See payment-razorpay.md.
  • shipping + shipping-clickpost — registry that powers vendor provider assignment and courier webhooks that flip sub-orders to delivered. See shipping.md.
  • inventory — reservation lifecycle behind every order transition. See inventory.md.

On this page