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 its services (OrderPlacementService, OrderPaymentStateService, OrderFulfillmentService, OrderDeliveryService, …) — OrderModule is registered as global: true so those modules can inject them 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, SUB_ORDER_CHANGED_CONCURRENTLY, 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_paymentOrderPlacementService.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 admin mark-paid for COD cash collectionstamps paid_at
failedpayment webhookparent stays pending_payment and keeps its reservation, so the customer can retry. The stale-pending sweep cancels it once the retry window closes
refundedadmin mark-refundedgateway-side refund is out-of-band; this flag is bookkeeping only

Failed payments and retries

A gateway reporting a failed payment soft-fails the order: payment_status becomes failed, but status stays pending_payment, sub-orders stay pending, and the inventory reservation is held. The order remains payable, and the customer starts another attempt through POST /store/orders/:id/payment/retry.

Only the stale-pending sweep hard-cancels: once the order is past max(admin.payment.pending_timeout_hours, admin.payment.retry_window_hours) it cascades the cancel to every sub-order, releases the reservation, and emits order.cancelled. cancelled stays terminal — nothing moves an order back out of it.

Stock is held for a shorter clock than the retry window. Four settings, deliberately not the same length:

SettingDefaultMeaning
checkout_hold_minutes15How long checkout holds stock before the order is placed; placement extends it to reservation_hold_hours
reservation_hold_hours24How long an unpaid order keeps its inventory reservation
retry_window_hours240 (10 days)How long the customer may still pay
pending_timeout_hours240When the sweep cancels

So a declined card ties up stock for a day, not for the whole window. Once the hold lapses the goods return to the pool and the order keeps sitting in pending_payment holding nothing. A retry made after that point re-acquires the stock before any money is taken — one batch per vendor, gift lines excluded, all-or-nothing — and is refused with 409 ORDER_ITEMS_UNAVAILABLE if any line has sold out. The customer is told before they pay, never after.

Attempts are tracked on the order and on each payment row:

ColumnNotes
order.payment_attempt_countGateway dispatches so far, counting the one at placement. Allocated under the order's row lock, so two concurrent retries cannot claim the same number
order.payment_last_attempt_atDrives the short per-order throttle
order_payment.attemptWhich attempt a payment row belongs to. Null on rows written before retries existed

For PhonePe and CCAvenue the attempt is folded into the merchant reference (ORD-…-R2), so each attempt is a distinct transaction at the gateway. Razorpay mints a fresh gateway order per attempt already.

Audit rows on the order timeline:

order_event.event_typeWritten when
payment.initiatedA gateway session was opened (place or retry); metadata carries attempt
payment.placedPayment settled synchronously
payment.failedAn attempt failed. metadata.retryable is true on a soft fail, and metadata.cancellationReason is set on the sweep's hard cancel
payment.retry_requestedA customer claimed a new attempt; changes records a provider switch
payment.capturedMoney arrived that no live attempt accounts for — see below

Because a retry can leave more than one live session at a gateway, a capture that lands after the order was already settled is checked against the reference that actually paid. A different reference is recorded via recordUnexpectedCapture with unexpected: "duplicate_attempt" ("cancelled_order" when it settled after cancellation) and raises an ops alert. Nothing is auto-refunded either way — a human reconciles.

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. COD stays payment_status=pending until admin mark-paid (cash collected)
cancelledPOST /vendor/orders/:id/cancel, parent cancel cascadenoreleases this vendor's reservation, and restocks it if the bag was still pending and already paid for; 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.placedOrderPlacementService.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;
  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"
  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;
  customer: { id: string; name: string; email: string } | null; // The customer account that placed the order; null for legacy/guest orders.

  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;
  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; // 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;
  actor: { id: string; name: string; email: string } | null; // Resolved actor account (operator-only: admin/vendor surfaces; always null on the store surface and for system events).
  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
  },
  "expectedTotal": 125000          // optional — grand total in subunits the shopper agreed to
}
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
expectedTotalint?Non-negative subunits. Placement re-prices the cart from live data; if the fresh total differs the call fails 409 CART_TOTAL_CHANGED and no order is created (see the errors table for what the rejected attempt does still write). Omitting it opts out of the guard, so a changed price is charged silently — optional only for existing mobile clients, expected to become required

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.

For hosted-redirect providers (PhonePe, CCAvenue) the order likewise returns pending_payment, but the payload carries a redirectUrl to navigate to rather than SDK bootstrap data:

{
  "pendingClientAction": {
    "provider": "ccavenue",
    "payload": {
      "redirectUrl": "https://api.example.com/store/orders/{id}/ccavenue/redirect",
      "orderId": "ORD-2026-00000123",
      "amount": 125000
    }
  }
}

Clients should branch on the presence of redirectUrl, not on the provider id — that keeps a new hosted gateway from needing a client change. Per-provider and per-platform payload shapes are tabulated in store/order.md.

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
409CONFLICTCart is no longer in an orderable status (already converted)
409CART_EMPTY, CART_NO_PRODUCT_LINESActive cart has no product lines
409CART_LINE_PRICE_UNAVAILABLEA line has no determinable price; details.variantIds lists the offenders
409INSUFFICIENT_INVENTORYReservation step couldn't hold stock for one or more lines
409CART_TOTAL_CHANGEDFresh pricing disagrees with expectedTotal. details.expectedTotal / details.currentTotal carry both figures, and details.removedCoupons the { code, reason } of any coupon dropped while re-pricing. No order, sub-orders, lines, payment row, coupon usage, or cart→converted transition — but the check runs after checkout preparation, so the cart-stage inventory reservation (idempotent per cart version) and one cart.checkout.prepared analytics row per attempt — plus a Klaviyo "Started Checkout" event where that integration is enabled — do happen

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, unwinds per-vendor inventory, and writes audit rows for the parent + each affected sub-order.

Inventory unwind (see inventory.mdCancellation restock): an unpaid bag's reservation is simply released. A bag whose reservation was already committed at payment is restocked — cancelling before it shipped means the goods never left, so on-hand is credited back with an order_cancelled movement per variant. A bag that already shipped is never auto-restocked; those goods return through return → QC pass.

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
409SUB_ORDER_CHANGED_CONCURRENTLYA sub-order advanced between the eligibility check and the write. The cancel is rolled back whole — nothing was cancelled, no stock released, no courier booking voided. Re-read the order and retry.

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 once it has been committed).
  • Restocks the bag when it was cancelled out of pending and its reservation had already been committed at payment — nothing shipped, so an order_cancelled movement puts the units back on hand. A fulfilled cancel is not restocked; those goods return through return → QC pass.
  • 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
409SUB_ORDER_CHANGED_CONCURRENTLYThe bag was fulfilled between the eligibility check and the write. Nothing was cancelled — reload and retry

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 payment: delivery does not mark paid. Admin mark-paid records cash collected; vendor sale earnings are recorded only after payment is paid.

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 and moves no stock.

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

Reservation windows are admin settings, not env vars — see Failed payments and retries.


  • cart — provides CartService.resolve and the cart→order checkout commit consumed by OrderPlacementService.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