Supercommerce API Docs
Admin API

Order Module — Admin

HTTP surface for platform-admin oversight of orders, returns, and vendor payouts. Read every order on the platform; create, edit and clone orders; perform ops actions (cancel, restore a cancelled order, mark paid, mark refunded, correct the delivery address, advance fulfillment, re-run courier assignment); queue background order exports; browse vendor ledgers and disburse payouts.

HTTP surface for platform-admin oversight of orders, returns, and vendor payouts. Read every order on the platform; create orders on a customer's behalf, edit any detail of an order, and clone an order; perform ops actions (cancel on behalf of the customer, manually mark a stuck pending payment as paid, mark a paid order as refunded, correct the delivery address, advance fulfillment on a consolidated order, re-run automatic courier assignment, force-create or override returns); queue background order exports; browse vendor ledgers and disburse payouts.

Source: api-modules/order/src/controllers/admin-orders.controller.ts, api-modules/order/src/controllers/admin-order-editor.controller.ts, api-modules/order/src/controllers/admin-order-export.controller.ts, api-modules/order/src/controllers/admin-returns.controller.ts, api-modules/order/src/controllers/admin-payouts.controller.ts, api-modules/order/src/controllers/admin-vendor-payout-config.controller.ts, api-modules/vendor/src/controllers/admin-vendor-bank-account.controller.ts.

Orders are split per-vendor — a parent order row aggregates the customer-facing totals while each order_vendor row is the fulfilment unit. Inventory reservations, fulfilment status, and the vendor ledger all key off order_vendor.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching permission.

Endpoint groupPermission
GET /admin/orders, GET /admin/orders/:id, GET /admin/orders/:id/events, GET /admin/orders/payment-options, POST /admin/orders/quote, POST /admin/orders/free-giftsorder: view
POST /admin/ordersorder: create
GET /admin/orders/:id/edit-draft, POST /admin/orders/:id/quote, PATCH /admin/orders/:idorder: edit
GET /admin/orders/:id/clone-draft, POST /admin/orders/:id/cloneorder: clone
POST /admin/orders/:id/cancel, POST /admin/orders/cleanup-stale-pendingorder: cancel
POST /admin/orders/:id/mark-paid, POST /admin/orders/:id/mark-refundedorder: update
POST/GET /admin/orders/:id/refunds, POST /admin/orders/:id/refunds/:merchantRefundId/syncorder: refund
POST /admin/orders/reassign-courier, POST /admin/orders/:id/reassign-courier, PATCH /admin/orders/:id/shipping-address, POST /admin/orders/:id/fulfilled, POST /admin/orders/:id/deliveredorder: update
GET/POST /admin/orders/exports, GET /admin/orders/exports/:id, GET /admin/orders/exports/:id/downloadorderExport: view / orderExport: create
GET /admin/returns, GET /admin/returns/:idorder: view
POST /admin/orders/:id/returns, POST /admin/returns/:id/overrideorder: update
GET /admin/vendors/:id/balance, GET /admin/vendors/:id/ledger, GET /admin/vendors/:id/payouts, GET /admin/payouts, GET /admin/payouts/:id, GET /admin/vendors/:id/payout-config, GET /admin/vendors/:id/bank-accountpayout: view
POST /admin/vendors/:id/payouts, POST /admin/payouts/promotepayout: create
POST /admin/payouts/:id/mark-paidpayout: mark_paid
POST /admin/payouts/:id/cancelpayout: cancel
POST /admin/vendors/:id/ledger/adjustpayout: adjust
PUT /admin/vendors/:id/payout-configpayout: configure

Response envelope

Successful responses are wrapped by ResponseInterceptor:

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT (illegal state transition, e.g. cancelling a delivered order)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Money fields

Every amount field is an integer subunit (paise / cents). commissionRate is in basis points (10000 = 100.00%).


Domain types

OrderResponse

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;
  paymentMethod: string;
  platform: Platform;

  shippingAddress: AddressBlock;
  billingAddress: AddressBlock;
  customerNote: string | null;       // shopper's checkout instruction; null when none

  subtotal: number;                  // subunits
  discountTotal: number;
  shippingTotal: number;
  taxTotal: number;
  grandTotal: number;
  discountBreakdown?: {         // admin reads only; amounts sum to discountTotal
    coupons: Array<{ code: string; amount: number }>;          // from recorded coupon redemptions
    rewardPoints: { points: number; amount: number } | null;   // amount is derived: what coupons and the manual discount leave
    manual: { amount: number; reason: string | null } | null;  // entered on an admin-created or edited order
    other: number;                                             // anything unaccounted for (e.g. legacy imports)
  };

  vendorBreakdowns: OrderVendorResponse[];   // per-vendor sub-orders + lines
  events: OrderEventResponse[];              // recent audit tail (up to 50)

  // Admin surfaces only — absent (not null) on the storefront's own reads.
  cartId: string | null;
  redemptionPointsConsumed: number;          // rewards points spent on this order
  customerIp: string | null;                 // null on orders placed before capture existed, and on migrated orders
  updatedAt: string;
  canEditShippingAddress: boolean;           // false once a courier holds the address
  shipmentGrouping: "per_order" | "per_vendor";  // how this store packs an order
  customerType: "guest" | "new" | "returning";   // where the buyer stood when they placed this order
  guestContact: {                            // set on a guest's order; null otherwise
    email: string;
    name: string | null;
    phone: string | null;
  } | null;

  pendingClientAction: {
    provider: string;
    payload: Record<string, unknown>;
  } | null;

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

customerType

Where the buyer stood at the moment this order was placed, so a historical row keeps its badge as the same buyer keeps ordering:

ValueMeaning
guestNo account was linked to the order, or it was placed under a guest-checkout placeholder account (see guestContact). A property of the order, not of a person — we have no identity to tie earlier purchases to, however many there were.
newThe account's first purchase.
returningThe account had already bought before placing this one.

"Already bought" means an earlier order that was confirmed or paid, not merely one that exists: an abandoned checkout leaves a pending_payment row behind, and counting those would label most first-time buyers returning. An order that was paid and later cancelled still counts.

Present on admin reads only (list and detail), alongside cartId and customerIp.

guestContact

A guest checks out under a throwaway account whose customer.email is an unroutable placeholder. guestContact carries the email, name and phone the guest actually gave, for storefront guest checkouts and operator-placed guest orders alike. It is null once the guest signs up (their orders move to the real account) and when the guest-checkout plugin is not mounted. Admin reads only.

OrderVendorSummary (admin detail only)

On GET /admin/orders/:id, each vendorBreakdowns entry additionally carries a live vendor summary resolved at read time (unlike vendorNameAtOrder, which is the placement-time snapshot). The key is omitted on list responses and on the store surface; it is null when the vendor account no longer exists.

type OrderVendorSummary = {
  id: string;
  name: string;          // current business name (org name if no profile yet)
  slug: string;
  logo: string | null;
  email: string | null;  // vendor_profile.business_email
  phone: string | null;  // vendor_profile.business_phone
};

// vendorBreakdowns entries on the admin detail endpoint:
type OrderVendorResponse = /* shared shape */ & {
  vendor?: OrderVendorSummary | null;
};

OrderEventResponse

type OrderEventResponse = {
  id: string;
  orderVendorId: string | null;
  eventType: string;                 // e.g. "payment.captured", "order_vendor.delivered", "return.requested", "shipment.auto_assign_skipped"
  actorType: "customer" | "vendor" | "admin" | "system";
  actorId: string | null;
  actor: { id: string; name: string; email: string } | null; // Resolved actor account; null for system events.
  source: string;                    // free-form, e.g. "admin-panel", "webhook"
  changes: Record<string, unknown>;
  metadata: Record<string, unknown>;
  createdAt: string;                 // ISO
};

ReturnResponse

type ReturnResponse = {
  id: string;
  returnNumber: string;
  orderId: string;
  orderVendorId: string;
  customerId: string | null;
  vendorId: string;
  type: string;                      // service-defined ("refund", "exchange")
  status: string;                    // "requested" | "approved" | "rejected" | "picked_up" | "received" | "qc_passed" | "qc_failed" | "refunded" | "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[];
};

PayoutResponse

type PayoutResponse = {
  id: string;
  payoutNumber: string;
  vendorId: string;
  status: "pending" | "paid" | "cancelled" | "failed";
  periodStart: string;
  periodEnd: string;
  grossTotal: number;
  commissionTotal: number;
  netTotal: number;
  entryCount: number;
  bankAccountId: string | null;
  bankReference: string | null;
  notes: string | null;
  createdAt: string;
  paidAt: string | null;
  cancelledAt: string | null;
  entries?: LedgerEntryResponse[];   // only populated on the detail endpoint
};

LedgerEntryResponse

type LedgerEntryResponse = {
  id: string;
  vendorId: string;
  kind: "sale" | "refund" | "manual" | "commission_adjustment";
  status: "pending" | "available" | "paid_out" | "cancelled";
  grossAmount: number;
  commissionRate: number;            // basis points
  commissionAmount: number;
  netAmount: number;
  orderId: string | null;
  orderVendorId: string | null;
  orderReturnId: string | null;
  payoutId: string | null;
  pendingUntil: string | null;
  availableAt: string | null;
  paidOutAt: string | null;
  cancelledAt: string | null;
  description: string | null;
  createdAt: string;
};

VendorBalanceResponse

type VendorBalanceResponse = {
  vendorId: string;
  pending: number;                   // net subunits in 'pending' status
  available: number;                 // net subunits in 'available' status not yet on a draft payout
  lifetimeEarned: number;
  lifetimeRefunded: number;
  lifetimePaidOut: number;
  payoutHold: boolean;               // vendor.payouts.payout_hold flag
  commissionRate: number;            // basis points
};

Orders

Base path: /admin/orders.

GET /admin/orders — List orders

Required permission: order: view. Newest placed first across every customer and vendor, unless sortBy says otherwise.

Query

NameTypeDefaultNotes
pageint1>= 1
limitint(paginated default)
statusOrderStatus?Parent-order lifecycle filter
vendorIdstring?Narrows to orders containing a sub-order for this vendor
fulfillmentStatusOrderVendorStatus?Narrows to orders containing at least one sub-order in this fulfillment state. Admin-only — the sub-orders returned on each row are not narrowed. Backs the Print Label picker (?fulfillmentStatus=fulfilled)
courierAssignment"assigned" | "booked" | "unassigned" | "failed"?Filters on courier assignment in either grouping mode
labelableboolean?Keeps orders with at least one sub-order a courier label can be printed for
excludeLabelPrintedboolean?Drops orders whose courier labels have all been printed. An order is kept while any live sub-order — one not cancelled, delivered or returned — still has no successfully rendered shipping_label_job_item, so a partly printed multi-vendor order stays in the queue. Only success items count; a failed render produced no label. Backs the New Orders queue and the Print Labels picker, which both default to it and offer a Show all orders toggle that lifts only this filter
searchstring?Order-number search. The ORD-<year>- prefix and the serial's leading zeros are both optional, so 123, 00000123 and ORD-2026-00000123 all resolve to the same order; anything else is matched as a substring
customerSearchstring?Case-insensitive partial match on the order's shipping first name, last name or phone, or the linked account's name or email
productSearchstring?Case-insensitive partial match on the SKU or product name snapshotted on any of the order's lines. Correlated on order_id, so a match on any vendor's lines keeps the order
dateField"paidAt" | "createdAt" | "updatedAt"?Which timestamp the range below filters on. createdAt means the order's placed date — the column the admin table labels "Created At" — not the row's insert time, which differs on migrated orders
dateFromstring? (ISO datetime)Inclusive lower bound. Ignored without dateField
dateTostring? (ISO datetime)Inclusive upper bound. Inclusive, because the admin table sends end-of-day as …T23:59:59.999Z — an exclusive bound would drop the final second
excludeRefundedboolean?Drops orders whose payment is fully refunded. A refund writes only paymentStatus + refundedAmount — it moves neither order.status nor any sub-order's fulfillment state, so a refunded unshipped order stays confirmed with a pending sub-order and is otherwise indistinguishable from one still waiting to be picked. partially_refunded is kept: on an unshipped order that is a price adjustment or goodwill credit, and the goods still have to go out
excludeUnpaidboolean?Drops orders that are confirmed but still unpaid, keeping Cash-on-Delivery. COD is confirmed at place time and stays payment_status: "pending" until the courier remits the cash, so it is unpaid by design and still has to be picked and shipped — excluding it outright would empty every COD order out of the operator's queue. Nothing else writes confirmed-and-pending, so any other order in that state is a migrated row or a capture that never landed
sortBy"paidAt" | "createdAt" | "updatedAt"?createdAtTimestamp the list is ordered by. Same field names as dateField, so createdAt is again the placed date. Rows with no value for the chosen field sort last in either direction — a confirmed COD order has no paidAt, and a bare DESC would otherwise float it above every genuinely recent payment
sortDirection"asc" | "desc"?desc

Response 200 — paginated envelope of OrderResponse[].


GET /admin/orders/:id — Order detail

Required permission: order: view. Admin sees all fields (including provider payload). Inlines the most recent ~50 events; use /events for the full audit trail. Each vendorBreakdowns entry includes the live vendor summary.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

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

Required permission: order: cancel. Allowed only when no sub-order has been delivered. Cascades sub-orders, releases per-vendor reservations, writes audit rows.

A bag cancelled while still pending is restocked if its reservation had been committed at payment — nothing shipped, so the stock goes back on hand with an order_cancelled movement. A fulfilled bag (which admin cancel alone may cancel) is not restocked: those goods physically left and return through return → QC pass.

Body

{ "reason": "Customer asked via support chat" }
FieldTypeConstraints
reasonstring?Trimmed, 1..500

Response 200 — cancelled OrderResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409CONFLICTA sub-order is already delivered

POST /admin/orders/:id/restore — Restore a cancelled order

Required permission: order: restore. Undoes a cancel in place: the same order, number and prices go back to confirmed, and the sub-orders that cancel took down go back to pending. It never re-emits order.placed or order.paid, so no placement email, vendor alert or commission is created a second time.

Which sub-orders come back. Only the ones cancelled together with the order, identified by sharing its cancelledAt. A sub-order cancelled on its own earlier (for example a vendor out of stock) stays cancelled.

What it does

  • Stock: any unfinished restock from the cancel is settled first. Each restored sub-order's lines are then reserved and committed again, all or nothing, before the order changes. Stock that has sold since is a warning, not a refusal — see confirmation below.
  • Reward points: points the cancel gave back are taken from the customer again.
  • Couriers: the cancel voided any waybill, so the restored sub-orders lose their AWB, tracking code and provider. Their courier assignments are deleted and the voided shipment rows get a cancelledAt. With ClickPost auto-assign on, they are queued for a courier again.
  • Plugins: affiliate commissions rejected by the cancel return to PENDING, and free-gift usage is recorded again.
  • Audit and notifications: writes order.restored and one order_vendor.restored per sub-order, the latter carrying any cleared waybill in changes.from. A paid order whose cancel suggested a refund also gets payment.refund_suggestion_withdrawn. Emits order.restored, which notifies the customer and the vendors (email, push, in-app).

Confirmation: stock, gifts and coupons

GET /admin/orders/:id/restore-preview (same permission, writes nothing) reports what the restore would run into, so the operator decides before committing:

{
  "restorable": true,
  "refusal": null,                  // set instead when the order can't be restored at all
  "requiresConfirmation": true,
  "lines": [                        // only the lines with a problem
    {
      "orderLineId": "…", "orderVendorId": "…", "variantId": "…",
      "sku": "1013989", "title": "Night Repair Set", "quantity": 3,
      "isGift": false,
      "giftRuleId": null, "giftRuleName": null,
      "giftRuleUnavailable": null,  // MISSING | INACTIVE | NOT_STARTED | EXPIRED on a gift line
      "available": 1,               // null when the variant is not stock-tracked
      "shortfall": 2
    }
  ],
  "coupons": [{ "code": "SAVE10", "amount": 30000, "unavailable": "EXPIRED" }]
}

Anything listed makes requiresConfirmation true, and the restore then needs acknowledgeWarnings: true or it returns RESTORE_NEEDS_CONFIRMATION with the same lists. The restore re-runs the checks itself, so stock that sells between the preview and the call is caught too.

  • Short stock is informational for purchased lines: confirming restores anyway and reserves with oversell. The commit clamps on-hand at zero, so the shortfall is what ops owes the customer; it is recorded in the order.restored audit row under metadata.stockShortfalls.
  • Gift lines are the operator's choice per gift. A gift whose rule is missing, switched off, not started or expired — or whose stock is short — is listed with isGift: true; passing its orderLineId in dropGiftLineIds leaves it off the restored order (the line is deleted and recorded under metadata.droppedGiftLines). Gift lines carry no money, so dropping one does not change any total. Dropping every line of a sub-order leaves that sub-order cancelled; dropping every line of the order returns RESTORE_LEAVES_NOTHING.
  • Coupons are only reported. A coupon that is missing, switched off, not started or expired is listed, and its discount stays on the order — restoring never reprices, so a paid order stays settled. Coupon status is judged on the rule itself, never re-validated against usage limits: the order's own redemption survived the cancel and would count against its own limit.

Refused (409) when

CodeWhen
ORDER_NOT_CANCELLEDThe order is not cancelled
ORDER_NOT_RESTORABLENo cancellation record to undo, such as an order migrated from the old platform, or no sub-order was cancelled with the order
ORDER_WAS_NOT_CONFIRMEDThe order was still pending_payment when cancelled (stale-payment sweep or payment failure)
ORDER_REFUNDED_NOT_RESTORABLEpaymentStatus is refunded or partially_refunded, or a gateway refund is pending or completed
ORDER_HAS_RETURNSThe order has a return
ORDER_HAD_FULFILLED_SUBORDERSA restored sub-order was fulfilled when cancelled, so its goods had left
RESTORE_WINDOW_EXPIREDCancelled longer ago than admin.fulfillment.order_restore_window_hours (default 168; 0 removes the limit)
REWARD_BALANCE_INSUFFICIENTThe customer has spent the points the cancel gave back
REWARD_REDEMPTION_UNAVAILABLEThe order used points and redemption is now switched off
ORDER_ITEMS_UNAVAILABLEAn item could not be reserved even with oversell
RESTORE_NEEDS_CONFIRMATIONShort stock, an unavailable gift rule or an unaccepted coupon, without acknowledgeWarnings
RESTORE_LEAVES_NOTHINGEvery line was dropped
ORDER_CHANGED_CONCURRENTLYThe order or a sub-order changed during the restore; nothing was restored

The pre-cancel status is read from the audit log (order.cancelled / order.auto_cancelled changes.from.status, and each order_vendor.cancelled changes.from.fulfillmentStatus), since neither row records it.

Body

{
  "reason": "Customer changed their mind",
  "acknowledgeWarnings": true,
  "dropGiftLineIds": ["line_of_a_gift_we_no_longer_honour"]
}
FieldTypeConstraints
reasonstring?Trimmed, 1..500
acknowledgeWarningsbooleanDefault false; required when the preview reports anything
dropGiftLineIdsstring[]Default []; gift lines of the restored sub-orders, max 50

Response 200 — restored OrderResponse.


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

Required permission: order: update. For bank-transfer settlements, COD edge cases (vendor confirmed delivery offline), or support overrides. Rejects orders already paid or cancelled. If the order was at pending_payment, transitions it to confirmed and commits the inventory reservation.

Body

{
  "externalReference": "NEFT-UTR-12345",      // optional, 1..200
  "reason": "Customer wired funds directly"   // optional, 1..500
}

Response 200 — updated OrderResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409CONFLICTAlready paid or cancelled

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

Required permission: order: update. v1 refund flow is admin-driven — the admin issues the refund out-of-band in the gateway's dashboard, then calls this to flip payment_status to refunded. Order lifecycle status (confirmed/delivered) stays as-is — refund is a money-only operation and moves no stock.

Body

{
  "amount": 100000,                            // optional; defaults to remaining unpaid amount
  "returnId": "01J9...",                       // optional; links event to an order_return row
  "externalReference": "RZP-RFND-abc",         // optional, 1..200
  "reason": "Goodwill credit for late delivery"
}
FieldTypeConstraints
amountint?>= 1 subunits. Defaults to the remaining unpaid amount
returnIdstring?1..100; links per-return refund counter
externalReferencestring?1..200
reasonstring?1..500

Response 200 — refunded OrderResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409CONFLICTOrder not in a refundable state

POST /admin/orders/:id/refunds — Refund through the payment gateway

Required permission: order: refund. Provider-agnostic: dispatches to whichever gateway placed the order, for any provider implementing the payment port's refund capability. Prefer this over mark-refunded when it is available — the money actually moves, rather than the platform recording that someone moved it by hand.

Gateway refunds are asynchronous. The response describes the refund that was accepted, not one that has settled: state is normally PENDING, and the order's payment_status moves only when the provider confirms (its webhook, or the sync endpoint below). Some gateways settle synchronously, in which case the row comes back COMPLETED and the order is already updated.

gatewayRefundSupported on the order response says whether this endpoint applies. Orders on COD, or on a gateway without the capability, use mark-refunded instead.

Body

{
  "amount": 50000,                    // optional; defaults to the remaining unrefunded balance
  "returnId": "01J9...",              // optional; links to an order_return row
  "reason": "Damaged on arrival"      // optional, 1..500
}
FieldTypeConstraints
amountint?Positive subunits. Defaults to the remaining balance. Already-settled and in-flight refunds both count against the ceiling, so two quick calls cannot over-refund
returnIdstring?Advances that return's refund counter and reverses the vendor's ledger entry
reasonstring?1..500; recorded on the order timeline

Response 200 — the PaymentRefund row:

{
  "id": "01J9...",
  "orderId": "01J9...",
  "returnId": null,
  "provider": "phonepe",
  "merchantRefundId": "RFND-ORD-2026-00000123-a1b2c3d4e5f6",
  "providerRefundId": "OMR7878098045517540996",
  "amountSubunits": 50000,
  "state": "PENDING",
  "reason": "Damaged on arrival",
  "initiatedBy": "01J9...",
  "createdAt": "2026-07-30T11:22:33.456Z",
  "updatedAt": "2026-07-30T11:22:33.456Z"
}

initiatedBy matters beyond audit: when the provider's webhook lands, that user is replayed as the actor on the resulting order event, so the refund is always attributable to a real person.

Errors

StatusCodeWhen
400BAD_REQUESTThe order's provider has no gateway-refund capability — use mark-refunded
400BAD_REQUESTAmount is not positive, or exceeds the remaining balance
404NOT_FOUNDUnknown id
409CONFLICTOrder is not paid / partially_refunded

GET /admin/orders/:id/refunds — Gateway refund history

Required permission: order: refund. Newest first. Includes PENDING rows, which are not yet reflected in the order's refunded amount — surface them so an operator does not double-refund while one is in flight.

Response 200PaymentRefund[].


POST /admin/orders/:id/refunds/:merchantRefundId/sync — Re-read a pending refund

Required permission: order: refund. Recovery path for a refund whose completion webhook never arrived: polls the provider and applies the result. A no-op when the refund has already settled.

Response 200 — the PaymentRefund row after syncing.

Errors

StatusCodeWhen
400BAD_REQUESTProvider cannot report refund state
404NOT_FOUNDUnknown refund, or it belongs to a different order

GET /admin/orders/:id/events — Paginated audit log

Required permission: order: view. Order detail inlines only the most recent ~50 events; this endpoint pages through the full history.

Query

NameTypeDefaultNotes
pageint1>= 1
limitint
eventTypestring?Trimmed, 1..128. Narrow to one event class

Response 200 — paginated envelope of OrderEventResponse[].

Errors

StatusCodeWhen
404NOT_FOUNDUnknown order id

POST /admin/orders/cleanup-stale-pending — Stale pending-payment sweep

Required permission: order: cancel. Out-of-band escape hatch matching the hourly BullMQ cron. Cancels orders stuck in pending_payment longer than timeoutHours (defaults to admin.payment.pending_timeout_hours). Caps blast radius at limit orders per call.

This sweep is the only path that cancels an unpaid order: a gateway reporting payment.failed leaves it payable so the customer can retry. Before cancelling, each order's attempts are probed newest-first, so a payment taken on an attempt the shopper retried past is still recovered rather than cancelled.

Body

{ "timeoutHours": 24, "limit": 200 }
FieldTypeConstraints
timeoutHoursint?0..720. Defaults to admin.payment.pending_timeout_hours
limitint?1..1000. Defaults to the constant PENDING_PAYMENT_SWEEP_BATCH

Response 200

{ "data": { "scanned": 312, "cancelled": 14, "errors": 0 }, "message": "Success", "statusCode": 200 }

PATCH /admin/orders/:id/shipping-address — Correct the delivery address

Required permission: order: update. Overwrites the order's shipping snapshot and logs an order.shipping_address_updated event with before → after, in the same transaction.

Refused once a courier holds the address. It is read off the order row at booking and sent to the carrier, so afterwards the waybill and the printed label carry the old one — editing then would change this panel while the parcel still goes to the old address. Cancel the shipment and book again instead.

canEditShippingAddress on the order response answers the same question, so the button and the endpoint cannot disagree.

Body

{
  "address": {
    "firstName": "Priya",
    "lastName": "Menon",
    "fullAddress": "88 Brigade Road",
    "city": "Bengaluru",
    "state": "Karnataka",
    "pincode": "560001",
    "phone": "+919000011111",
    "country": "IN"          // optional, defaults to IN
  }
}

Same validation the storefront applies at checkout, so a corrected address can never be weaker than a placed one.

Response 200 — the order after the edit.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown order
409ORDER_ADDRESS_LOCKEDA courier already holds this address

POST /admin/orders/:id/fulfilled — Fulfil the whole order

Required permission: order: update. Per-order grouping only. Where one waybill covers every bag, a per-bag control lets an operator mark one bag delivered while its box-mates sit at fulfilled — for cartons that physically travel as one parcel. This advances every pending sub-order together, exactly as the carrier's own scan does.

Each bag still goes through the ordinary per-bag transition, so per-bag events, vendor earnings, rewards and customer notifications fire unchanged. Bags already past the target, and cancelled ones, are skipped rather than erroring, so a partly-advanced order can be finished with the same call.

Body — same account and carrier fields as the per-sub-order fulfil:

NameTypeNotes
providerIdstringShipping provider
methodstringCarrier / service code
credentialSource"platform" | "vendor"?Which shipping account books it

Response 200

{ "data": { "advanced": 3 } }

advanced: 0 means nothing on the order was still pending.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown order, or it has no sub-orders
409CONFLICTStore ships one parcel per vendor — use the per-sub-order action

POST /admin/orders/:id/delivered — Mark the whole order delivered

Required permission: order: update. The consolidated counterpart to the above; advances every fulfilled sub-order and records a vendor sale earning per bag, exactly as the per-bag path does. No body.

Same responses and errors as /fulfilled.


POST /admin/orders/reassign-courier — Re-run assignment across a selection

Required permission: order: update. The bulk counterpart to the per-order action below, backing the Assign button on the orders table's selection bar. Returns once the work is queued, not once couriers are picked.

One order failing does not stop the rest — an operator who filtered to "assignment failed" and selected the page wants the retriable ones queued, not the batch abandoned because one order was cancelled meanwhile. The response reports per order.

Whether the auto-assign plugin is installed is a deployment-wide fact rather than a per-order outcome, so a missing plugin fails the whole call (409) instead of returning 200 with every row errored.

Body

NameTypeNotes
orderIdsstring[]1–200 order ids

Response 200

{
  "data": {
    "queued": 4,                 // assignment jobs across the whole selection
    "failed": 1,                 // orders that could not be queued at all
    "results": [
      { "orderId": "…", "ok": true,  "queued": 1 },
      { "orderId": "…", "ok": true,  "queued": 0 },   // nothing left to assign
      { "orderId": "…", "ok": false, "queued": 0, "error": "Order … has no sub-orders" }
    ]
  }
}

Errors

StatusCodeWhen
409CONFLICTNo carrier plugin provides automatic assignment, or the selection exceeds 200 orders

POST /admin/orders/:id/reassign-courier — Re-run automatic courier assignment

Required permission: order: update. For an order whose automatic assignment failed, was skipped, or never ran. Resolves once the work is queued, not once a courier is picked — poll the order to see the result.

What gets queued follows the admin.shipping.shipment_grouping setting:

GroupingBehaviour
per_orderOne job for the whole parcel. The order-scoped shipment_assignment row is reclaimed off failed; no sub-order is named.
per_vendorOne job per pending sub-order that has no live assigned/booked assignment. Vendor-fulfilled bags are skipped, matching what placement does.

Response 200

{ "data": { "queued": 1 } }

queued: 0 means nothing on the order was still waiting for a courier.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown order, or it has no sub-orders
409CONFLICTNo carrier plugin provides automatic assignment on this deployment

Create, edit and clone

Operators can place an order on a customer's behalf, rewrite any detail of an existing order, and clone an order into a new one. Three permissions gate this, separate from order: update (which drives status and fulfilment): order: create, order: edit and order: clone.

The order number is always generated (ORD-YYYY-########) — for a created order and for a clone. It is never copied or accepted in a body.

Guest orders

An order can be placed for someone without an account by sending guest instead of customerId. It becomes the same guest the storefront creates: a throwaway anonymous account owns the order, and the contact (email, name, phone) is recorded against it with a tokenized order-status link. With notifyCustomer: true the guest receives the guest_order.confirmation email at the contact address; the account's own placeholder email is never used. The edit and clone drafts return customerId: null and the guest's contact for such orders.

Guest orders are provided by the guest-checkout plugin through ORDER_GUEST_CUSTOMER_PORT; without that plugin, guest is refused with 400 GUEST_ORDERS_UNAVAILABLE.

How an admin order is priced

Prices follow checkout's rules, with operator overrides:

  • Unit price — the variant's effective price (a live special price wins over list price). unitPrice on a line overrides it. When editing, a line left unchanged keeps the price it was ordered at.
  • CouponscouponCodes are validated by the store's discount rules (active window, platform, customer scope and login, product filters, minimums, purchase history, total and per-customer usage limits). A coupon that allows no combining refuses to stack with another. Each coupon comes off the products it is eligible for, and a free-shipping coupon zeroes the default shipping. Redemptions are recorded in discount_usage, re-checked under the coupon's row lock when the order is written. For a guest the rules see no account, so login-only and per-customer rules do not apply.
  • Manual discountdiscountAmount comes off whatever the coupons leave, split across products in proportion to their value (the smallest share takes the rounding remainder). It cannot exceed that remainder. Reward points are not redeemed on admin orders.
  • Free gifts — nothing is added automatically. POST /admin/orders/free-gifts lists what the store's gift rules offer for the order as it stands; a chosen gift is sent back as a line with its giftRuleId. Gift lines are free, excluded from the subtotal and from discount splitting, and re-checked against their rule when the order is written. The rule's usage is recorded once the order is confirmed.
  • Shipping — each vendor's shipping rule, unless vendorShipping overrides that vendor.
  • Tax — computed by the tax registry as the portion of the tax-inclusive prices, exactly as at checkout.

grandTotal = subtotal − discountTotal + shippingTotal.

Payment

No payment gateway is ever charged from the admin panel. payment.status records what has happened:

payment.statusMethodOrder becomesStock
paidanyconfirmed / paid, with a succeeded payment rowcommitted
pendingcodconfirmed / pending (collected on delivery)committed
pendingother, confirmOrder: true (default)confirmed / pendingcommitted
pendingother, confirmOrder: falsepending_payment / pendingreserved; the stale-pending sweep cancels it once its timeout passes

The provider and method must be enabled for the order's platform (GET /admin/orders/payment-options).

Stock

Creating (or cloning) reserves stock per vendor and commits it when the order is confirmed. An insufficient-stock line refuses the whole order with 409 INSUFFICIENT_INVENTORY unless allowOversell: true.

Editing moves stock by the difference:

  • Confirmed order — extra units are reserved and committed; removed units are put back with an order_edited inventory movement (capped at what the sub-order still holds). A later cancel restocks only what is left, so stock is never returned twice.
  • Order awaiting payment — the changed vendors' reservations are released and re-reserved at the new quantities.
  • A vendor whose last line is removed — its sub-order is cancelled in place (not deleted, so its history, invoice number and shipments stay intact) and its stock is restocked like any cancelled sub-order. A cancelled sub-order cannot take lines again.

What can still be edited

SituationEditable
Nothing has movedEverything
A sub-order is fulfilled, delivered or returned; a courier is assigned; a shipment or waybill exists; a refund exists (or refundedAmount > 0); a return existsCustomer note, internal note, billing address, attributes
Order cancelledCustomer note, internal note

GET /admin/orders/:id/edit-draft returns this as editMode: { mode: "full" \| "restricted" \| "notes_only", reasons: [...] }, and PATCH enforces the same rule — re-checked under a row lock, since a vendor can claim fulfilment in the meantime.

Side effects

  • Create / clone emit order.placed (and order.paid when paid). notifyCustomer: false skips every customer-facing notification template for those events; vendor notifications still go out. Customer stats, rewards and automatic courier assignment run as for a storefront order; affiliate attribution does not (there is no cart).
  • Edit emits order.updated_by_admin. Customer stats are recomputed (for both customers when the order moves), an invoice already issued for a changed sub-order is re-rendered under the same number, and ClickPost auto-assignment is queued for sub-orders the edit adds. A removed sub-order also emits order.vendor.cancelled.
  • Audit — every change writes an order_event in the same transaction, with before → after: order.customer_changed, order.guest_contact_updated, order.shipping_address_updated, order.billing_address_updated, order.platform_updated, order.lines_updated, order.pricing_updated, order.coupons_updated, order.vendor_added, order_vendor.cancelled, order.payment_updated, order.notes_updated, order.attributes_updated, order.payment_balance_changed. All rows of one edit share metadata.editId. A clone writes order.cloned on the source order.

AdminOrderDraft

The shape POST /admin/orders takes and both draft endpoints return.

type AdminOrderDraft = {
  customerId: string | null;              // an existing account; null for a guest
  guest: {                                // exactly one of customerId / guest
    email: string;
    name: string | null;                  // nullshipping address name
    phone: string | null;                 // nullshipping address phone
  } | null;
  shippingAddress: AddressInput;          // same validation as checkout
  billingAddress: AddressInput | null;    // null bills the shipping address
  platform: "WEB" | "APP";
  lines: Array<{
    id?: string;          // existing order line (edit only)
    variantId: string;
    quantity: number;     // 1..9999; one line per variant
    unitPrice?: number;   // subunits; omit for the catalog / already-ordered price
    giftRuleId?: string;  // a free gift chosen from POST /admin/orders/free-gifts
  }>;
  discountAmount: number;                 // subunits
  discountReason: string | null;
  couponCodes: string[];                  // max 10
  vendorShipping: Array<{ vendorId: string; shippingCost: number }>;
  payment: {
    provider: string;
    method: string;
    status: "pending" | "paid";
    externalReference?: string;
    confirmOrder?: boolean;               // default true
  };
  customerNote: string | null;            // shown to the customer
  internalNote: string | null;            // staff only
  attributes: Record<string, string>;     // staff-only key/values
};

AdminOrderQuote

type AdminOrderQuote = {
  lines: Array<{
    index: number; id: string | null; variantId: string; productId: string; vendorId: string;
    type: "PRODUCT" | "GIFT"; giftRuleId: string | null;
    sku: string; title: string; image: string | null; quantity: number;
    catalogUnitPrice: number | null; unitPrice: number;
    lineSubtotal: number; discountAllocated: number; lineTotal: number; taxAmount: number;
    availableQuantity: number | null;   // null when stock is not limited
    shortfall: number;                  // units beyond what is available
  }>;
  vendors: Array<{
    vendorId: string; vendorName: string; subtotal: number; discountAllocated: number;
    defaultShippingCost: number; shippingCost: number; taxAmount: number; total: number;
  }>;
  totals: { subtotal: number; discountTotal: number; shippingTotal: number; taxTotal: number; grandTotal: number };
  coupons: Array<{
    code: string; applied: boolean; reason: string | null;   // reason: why it was refused
    discountAmount: number; freeShipping: boolean;
    kept: boolean;                                           // already on the order being edited
  }>;
  warnings: Array<{ code: string; message: string; variantId: string | null; vendorId: string | null }>;
};

Warning codes: INSUFFICIENT_STOCK, PRICE_BELOW_CATALOG, PINCODE_NOT_SERVICEABLE, VARIANT_UNAVAILABLE, GIFT_LINE_DROPPED, CANCELLED_SUB_ORDER_SKIPPED, GUEST_CUSTOMER.


GET /admin/orders/payment-options — Payment options for an admin order

Required permission: order: view. Query platform=WEB|APP (default WEB).

Response 200Array<{ provider: string; methods: string[] }>.


POST /admin/orders/quote — Price a new order

Required permission: order: view. Writes nothing.

Body{ lines, discountAmount, couponCodes, customerId, platform, vendorShipping, shippingPincode? } (see AdminOrderDraft). Lines may be empty; customerId is null for a guest. A refused coupon is reported in coupons rather than failing the quote.

Response 200AdminOrderQuote.

Errors

StatusCodeWhen
400VARIANT_UNAVAILABLEUnknown or deleted variant
400PRICE_UNAVAILABLEThe variant has no price and no unitPrice was given
400DUPLICATE_VARIANTThe same variant on two lines
400DISCOUNT_EXCEEDS_SUBTOTALdiscountAmount is larger than what the coupons leave

POST /admin/orders/free-gifts — Free gifts for an order

Required permission: order: view. Writes nothing.

Body{ lines, couponCodes, customerId, platform }, the order as it stands in the form; gift lines in lines are ignored.

Response 200

type AdminOrderFreeGiftCheck = {
  rules: Array<{
    ruleId: string;
    name: string;
    reason: string;            // AUTOMATIC | BUYXGETY | COUPON_BASED:<code> | CHOICE
    requiresChoice: boolean;   // true: pick up to slotCount of options (quantity 1 each)
    slotCount: number;
    options: Array<{ variantId: string; productId: string; title: string; sku: string; image: string | null; quantity: number }>;
  }>;
};

POST /admin/orders — Create an order

Required permission: order: create.

BodyAdminOrderDraft plus:

{
  "notifyCustomer": true,   // false skips customer-facing notifications
  "allowOversell": false    // true reserves stock even where none is available
}

Response 201 — the created OrderResponse.

Errors

StatusCodeWhen
400CUSTOMER_NOT_FOUND, PAYMENT_OPTION_NOT_ENABLEDUnknown customer, or the provider/method is not enabled for the platform
400GUEST_ORDERS_UNAVAILABLEguest was sent but the guest-checkout plugin is not mounted
400VARIANT_UNAVAILABLE, PRICE_UNAVAILABLE, DUPLICATE_VARIANT, DISCOUNT_EXCEEDS_SUBTOTALAs for quote
400COUPON_INVALIDA coupon was refused; details carries coupon and reason
400GIFT_NOT_ELIGIBLEA gift line is not offered by its rule for these products
409INSUFFICIENT_INVENTORYNot enough stock and allowOversell is false; nothing is written

GET /admin/orders/:id/clone-draft — Prefill a clone

Required permission: order: clone. Writes nothing.

Copies the customer, both addresses, platform, lines at their original unit prices, discount (capped at the copied subtotal), per-vendor shipping, payment provider and method (status reset to pending), notes and attributes. Free-gift lines, deleted variants and cancelled sub-orders are dropped with a warning. The order number, payments, refunds, shipments, invoices and history are never copied.

Response 200

type AdminOrderCloneDraft = {
  draft: AdminOrderDraft;
  source: { orderId: string; orderNumber: string };
  warnings: AdminOrderWarning[];
};

POST /admin/orders/:id/clone — Place a cloned order

Required permission: order: clone. Same body, behaviour and errors as POST /admin/orders; the new order records metadata.clonedFromOrderId and the source order gets an order.cloned event. 404 when the source order does not exist.


GET /admin/orders/:id/edit-draft — Load an order for editing

Required permission: order: edit.

Response 200

type AdminOrderEditDraft = {
  draft: AdminOrderDraft;      // lines carry their order line `id`
  orderNumber: string;
  status: OrderStatus;
  paymentStatus: PaymentStatus;
  grandTotal: number;          // subunits, as stored
  editMode: { mode: "full" | "restricted" | "notes_only"; reasons: AdminOrderLockReason[] };
  updatedAt: string;           // send back as expectedUpdatedAt
};

AdminOrderLockReason: ORDER_CANCELLED, SUB_ORDER_SHIPPED, COURIER_ASSIGNED, SHIPMENT_BOOKED, REFUNDED, RETURN_EXISTS.


POST /admin/orders/:id/quote — Price an edit

Required permission: order: edit. Body and response as POST /admin/orders/quote; lines carrying their id keep the price they were ordered at, and shortfall counts only units beyond what the order already holds.


PATCH /admin/orders/:id — Edit an order

Required permission: order: edit. Every section is optional; an omitted section is left untouched.

Body

{
  "expectedUpdatedAt": "2026-09-16T10:00:00.000Z", // required — the edit draft's updatedAt
  "customerId": "…",                // or "guest": { "email": "…", "name": null, "phone": null }
  "shippingAddress": {  },
  "billingAddress": {  },          // null bills the shipping address
  "platform": "WEB",
  "lines": [ { "id": "…", "variantId": "…", "quantity": 2 }, { "variantId": "…", "quantity": 1, "unitPrice": 45000 } ],
  "discountAmount": 5000,
  "discountReason": "goodwill",
  "couponCodes": ["SAVE10"],
  "vendorShipping": [ { "vendorId": "…", "shippingCost": 0 } ],
  "payment": { "provider": "manual", "method": "bank-transfer", "status": "paid", "externalReference": "UTR123" },
  "customerNote": "Leave at the door",
  "internalNote": "Customer called to add an item",
  "attributes": { "po": "PO-7" },
  "allowOversell": false,
  "acknowledgePaymentBalance": false,
  "reason": "Customer request"      // recorded on every audit row of this edit
}

Send either customerId or guest. guest on an order that already belongs to a guest corrects that guest's contact; on any other order it moves the order to a new guest.

lines, when sent, is the complete list: lines left out are removed. couponCodes, when sent, is also the complete list: a coupon already on the order keeps the amount it was redeemed for (it is not re-validated, which would count the order's own redemption against its limit), a new code is validated, and a code left out is removed and its redemption given back. Existing gift lines are kept unless removed; newly added ones are checked against their rule. Payment provider and method can change only while the payment is pending; pending → paid records the payment (and confirms an order awaiting payment); paid → pending is refused — refund instead. When a paid order's total changes, acknowledgePaymentBalance: true is required: the difference is settled outside the edit and an order.payment_balance_changed event records it.

Response 200 — the updated OrderResponse.

Errors

StatusCodeWhen
400CUSTOMER_NOT_FOUND, PAYMENT_OPTION_NOT_ENABLED, ORDER_LINE_NOT_FOUNDUnknown customer, disabled payment option, or a line id that is not on this order
400VARIANT_UNAVAILABLE, PRICE_UNAVAILABLE, DUPLICATE_VARIANT, DISCOUNT_EXCEEDS_SUBTOTALAs for quote
400COUPON_INVALID, GIFT_NOT_ELIGIBLEA new coupon was refused, or a new gift line is not offered by its rule
409ORDER_STALEThe order changed since expectedUpdatedAt; reload and retry
409ORDER_EDIT_LOCKEDA sent section is not editable any more; details carries mode, reasons and the blocked fields
409INSUFFICIENT_INVENTORYNot enough stock for the added units and allowOversell is false; nothing is written
409PAYMENT_BALANCE_UNACKNOWLEDGEDA paid order's total would change without acknowledgePaymentBalance
409PAYMENT_ALREADY_CAPTURED, PAYMENT_METHOD_LOCKEDA paid payment set back to pending, or the method changed after payment
409VENDOR_SUB_ORDER_CANCELLEDLines added for a vendor whose sub-order was cancelled

Order exports

Base path: /admin/orders/exports. Background CSV/XLSX exports of the order list, behind their own orderExport resource rather than order: view — a whole-database export is a wider data-egress grant than reading one order.

The request carries the filters from the order list; the columns are fixed and not selectable, so a downstream spreadsheet can rely on the header row. Rendering happens on the worker; a finished file stays downloadable for 7 days, after which a daily sweep deletes the bytes and marks the row expired (the row itself is kept as an audit record).

OrderExport

type OrderExport = {
  id: string;
  format: "csv" | "xlsx";
  status: "pending" | "processing" | "ready" | "failed" | "expired";
  rowCount: number | null;     // item rows written, not orders
  truncated: boolean;          // true when the 100,000-row cap was hit
  fileName: string | null;
  fileSize: number | null;     // bytes
  error: string | null;
  requestedByUserId: string | null;
  createdAt: string;
  completedAt: string | null;
  expiresAt: string | null;
};

POST /admin/orders/exports — Queue an export

Required permission: orderExport: create.

Body

NameTypeDefaultNotes
format"csv" | "xlsx""csv"
statusOrderStatus?Same meaning as on GET /admin/orders
fulfillmentStatusOrderVendorStatus?
courierAssignment"assigned" | "booked" | "unassigned" | "failed"?
vendorIdstring?
searchstring?
customerSearchstring?
productSearchstring?
dateField"paidAt" | "createdAt" | "updatedAt"?
dateFromstring? (ISO datetime)
dateTostring? (ISO datetime)
excludeRefundedboolean?
excludeUnpaidboolean?

Omitting every filter exports every order.

Response 201OrderExport with status: "pending".

Columns written (one row per item; money in major units, datetimes as YYYY-MM-DD HH:mm:ss):

Order columns — Order No · Order ID · Cart ID · Placed At · Paid At · Confirmed At · Cancelled At · Updated At · Status · Payment Status · Fulfillment Status · Platform · Payment Provider · Payment Method · Registered Customer Name · Registered Customer Email · Shipping Name · Shipping Phone · Shipping Address · Shipping City · Shipping State · Shipping Pincode · Shipping Country · Billing Name · Billing Phone · Billing Address · Billing City · Billing State · Billing Pincode · Billing Country · Subtotal · Discount Total · Shipping Total · Tax Total · Grand Total · Refunded Amount · Redeemed Points · Customer IP · Customer Note · Vendors · Courier · AWB Numbers · Courier Status · Cancellation Reason

Item columns — Item Vendor · Item SKU · Item Name · Item Variant · Item Type · Item HSN · Item Quantity · Item Unit Price · Item Discount · Item Total

Appended columns — Invoice Number · Delivered At · Shipping Method · Fulfillment Method · Discount Percentage · Customer Order Count · Customer Lifetime Value · Is First Order · Days Since Last Order · Item Brand

Shipping Method is the method chosen at checkout (order_vendor.shipping_method); Fulfillment Method is the provider that booked the parcel (clickpost, manual, …). Discount Percentage is Discount Total / Subtotal × 100, blank when the subtotal is zero. Item Brand is the product's current brand title — unlike the other item columns it is not snapshotted at placement, so a brand renamed since the order shows its new title.

The four customer columns describe the customer's standing at the moment this order was placed, not today: Customer Order Count is every order that customer has placed, Customer Lifetime Value is their spend across delivered orders (the same definition the customer admin shows), Is First Order is Yes/No, and Days Since Last Order is the whole-day gap back to their previous order — blank on a first order. All four are blank on a guest order with no customer record.

An order with three items writes three rows, each repeating the order columns unchanged and differing only in the Item … columns. So the order-level money columns (Subtotal, Grand Total, Refunded Amount) repeat per row and must not be summed across a multi-item order — sum Item Total instead. Item values are snapshots taken at placement, not live product data, so a later rename or reprice does not rewrite history.

The item columns are appended after the order columns, so every pre-existing column keeps its position for a downstream spreadsheet.

An order carrying no lines still writes its one row, with the Item … columns blank — losing the order from the file entirely would be worse.

A multi-vendor order spreads across its item rows: Item Vendor names the bag each item belongs to, while the order-level Fulfillment Status, Vendors, Courier, AWB Numbers and Courier Status still list every distinct value comma-separated, so a half-shipped order is not reported as fully shipped.


GET /admin/orders/exports — Export history

Required permission: orderExport: view. Newest first.

Query — standard limit / offset.

Response 200 — paginated envelope of OrderExport[].


GET /admin/orders/exports/:id — Export status

Required permission: orderExport: view. Poll until status reaches ready or failed.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

GET /admin/orders/exports/:id/download — Download the file

Required permission: orderExport: view. Returns the raw file bytes with a content-disposition attachment header — not the JSON envelope.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id, or the object is gone from storage
409CONFLICTExport has expired, or is not ready yet

Sub-order processing

Base path: /admin/order-vendors. :orderVendorId is order_vendor.id. These are the ops-side fulfilment controls and always work — including for bags whose fulfillmentOwner is vendor, so staff can ship when a seller cannot.

PATCH /admin/order-vendors/:orderVendorId/shipment — Correct recorded shipment details

Required permission: order: update. Edits the provider, courier method, tracking code, and AWB stored against a bag that has already been processed. This rewrites our record only — the carrier booking made at fulfil time is not re-issued. Pending bags must go through POST :orderVendorId/fulfilled instead.

Every field is optional; omitted fields keep their stored value and explicit null clears tracking / AWB. Passing providerId, method, or credentialSource re-validates the pair against the chosen shipping account's allow-list.

Body

{
  "providerId": "clickpost",
  "method": "DELHIVERY_AIR",
  "trackingCode": "SC12345678",
  "awbNumber": "1234567890",
  "credentialSource": "platform"
}
FieldTypeConstraints
providerIdstring?Non-empty. Must be enabled for the resolved account
methodstring?Non-empty. Must be a courier the provider exposes for that account
trackingCodestring | null?Trimmed, <= 200. null clears
awbNumberstring | null?Trimmed, <= 200. null clears
credentialSource"platform" | "vendor"?Which shipping account the bag is recorded against. Defaults to the recorded one

Response 200 — the parent OrderResponse.

Writes an order_vendor.shipment_updated audit event carrying the full before → after of all four fields, in the same transaction as the update.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown sub-order id
409CONFLICTBag is still pending — fulfil it instead
409CONFLICTNeither a stored nor a supplied provider/method pair exists

Returns

GET /admin/returns — List returns

Required permission: order: view. Cross-vendor list.

Query

NameTypeDefaultNotes
pageint1
limitint
statusstring?Trimmed, 1..32. Filter by return status

Response 200 — paginated envelope of ReturnResponse[].


GET /admin/returns/:id — Return detail

Required permission: order: view.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/orders/:id/returns — Force-create a return

Required permission: order: update. Support / dispute-resolution path: admin creates a return without the customer's own request. Optionally pre-approves the return and overrides the computed refund_amount.

Body

{
  "orderVendorId": "01J9...",
  "reasonCode": "damaged_in_transit",
  "reasonNotes": "Customer photos in support ticket #ABC-123",
  "lines": [
    { "orderLineId": "01J9...", "quantity": 1, "reasonCode": "damaged_in_transit" }
  ],
  "preApprove": true,
  "refundAmountOverride": 90000
}
FieldTypeConstraints
orderVendorIdstringRequired; the sub-order being returned
reasonCodestring1..64
reasonNotesstring?max 2000
lines[]array1..100 entries
preApproveboolean?Skip vendor approval step
refundAmountOverrideint?>= 0 subunits

Response 200 — created ReturnResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
404NOT_FOUNDOrder or sub-order not found

POST /admin/returns/:id/override — Admin override transition

Required permission: order: update. Escape hatch transitions on a wedged return.

actionEffect
"force_refund"Clears qc_failure and moves to qc_passed so /admin/orders/:id/mark-refunded accepts the returnId
"approve"Reverses a vendor rejection — back to approved
"cancel"Cancels the return

Body

{ "action": "force_refund", "reason": "QC dispute escalation; refunding as goodwill" }
FieldTypeConstraints
actionenumOne of force_refund / approve / cancel
reasonstringRequired; 1..500

Response 200 — overridden ReturnResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
404NOT_FOUNDUnknown id

Vendor payouts

GET /admin/vendors/:id/balance — Vendor balance summary

Required permission: payout: view.

Response 200VendorBalanceResponse.


GET /admin/vendors/:id/ledger — Vendor ledger

Required permission: payout: view.

Query

NameTypeDefaultNotes
pageint1
limitint
kind"sale" | "refund" | "manual" | "commission_adjustment"?
status"pending" | "available" | "paid_out" | "cancelled"?

Response 200 — paginated envelope of LedgerEntryResponse[].


GET /admin/vendors/:id/payouts — Vendor payouts

Required permission: payout: view.

Query

NameTypeDefaultNotes
pageint1
limitint
status"pending" | "paid" | "cancelled" | "failed"?

Response 200 — paginated envelope of PayoutResponse[] (without entries).


GET /admin/payouts — Cross-vendor payout list

Required permission: payout: view. Same query as the per-vendor list.

Response 200 — paginated envelope of PayoutResponse[] (without entries).


GET /admin/payouts/:id — Payout detail

Required permission: payout: view. Returns the payout with its linked ledger entries inlined.

Response 200PayoutResponse with entries[] populated.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

GET /admin/vendors/:id/payout-config — Per-vendor payout policy

Required permission: payout: view. Returns the vendor's payout-policy override, the resolved effective values, and the platform defaults each null override inherits from. Backed by the dedicated vendor_payout_config table (previously the admin.payouts per-vendor settings group).

Response 200

{
  "override": {
    "commissionRate": 250,          // basis points, or null to inherit
    "minPayoutAmountSubunit": null, // subunits, or null to inherit
    "payoutHold": false,
    "notes": ""
  },
  "resolved": {                     // what the payout engine uses
    "commissionRate": 250,
    "minPayoutAmountSubunit": 5000,
    "payoutHold": false,
    "notes": ""
  },
  "platformDefaults": {             // admin.payouts.* fallbacks
    "commissionRate": 1000,
    "minPayoutAmountSubunit": 5000
  }
}

PUT /admin/vendors/:id/payout-config — Override a vendor's payout policy

Required permission: payout: configure. Full-representation upsert. commissionRate (basis points) and minPayoutAmountSubunit (subunits) are null to inherit the platform default; payoutHold freezes disbursements. The change is audit-logged via the vendor.payout_config_updated domain event. Vendors cannot see or edit these values.

Body

NameTypeNotes
commissionRateint (0–10000) | nullBasis points (1000 = 10%). null inherits admin.payouts.default_commission_rate.
minPayoutAmountSubunitint (≥0) | nullnull inherits admin.payouts.min_payout_amount_subunit.
payoutHoldbooleanBlocks createDraftPayout with 403 when true.
notesstring (≤5000)Private admin notes.

Response 200 — same shape as GET, reflecting the new values.


GET /admin/vendors/:id/bank-account — Vendor payout destination (masked)

Required permission: payout: view. Read-only admin view of the vendor's payout bank account with the account number masked to its last 4 digits. There is no admin write — vendors manage their own account via PUT /vendor/bank-account.

Response 200{ bankName, accountHolderName, accountNumberMasked, accountNumberLast4, routingNumber, swiftCode }, or null when none is on record.


POST /admin/vendors/:id/payouts — Create a draft payout

Required permission: payout: create. Picks all unattached available ledger entries (optionally within the supplied period), snapshots the sums, and creates a pending payout row. Rejects when the vendor is on hold (vendor_payout_config.payout_hold = true) or when no available entries exist.

Body

{
  "periodStart": "2026-04-01T00:00:00.000Z",
  "periodEnd":   "2026-04-30T23:59:59.999Z",
  "notes": "April settlement"
}
FieldTypeNotes
periodStartISO datetime?Inclusive lower bound. Default: no lower bound
periodEndISO datetime?Inclusive upper bound. Default: now
notesstring?Trimmed, max 2000

Response 201 — draft PayoutResponse.

Errors

StatusCodeWhen
403FORBIDDENVendor on payout hold
400VALIDATION_ERRORNo available entries match the period

POST /admin/payouts/:id/mark-paid — Mark a draft as paid

Required permission: payout: mark_paid. Admin pastes the bank reference (NEFT UTR, IMPS ref, etc.) from the offline transfer. Flips the payout to paid and every linked ledger entry to paid_out.

Body

{ "bankReference": "NEFT-UTR-12345", "notes": "Sent via HDFC NEFT" }
FieldTypeConstraints
bankReferencestringRequired, trimmed, 1..200
notesstring?Trimmed, max 2000

Response 200 — paid PayoutResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409CONFLICTPayout not in pending

POST /admin/payouts/:id/cancel — Cancel a draft

Required permission: payout: cancel. Releases linked ledger entries back to the pool (available).

Body

{ "reason": "Wrong period selected; redrafting" }
FieldTypeConstraints
reasonstringRequired, trimmed, 1..500

Response 200 — cancelled PayoutResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/vendors/:id/ledger/adjust — Manual ledger adjustment

Required permission: payout: adjust. Use for chargebacks, goodwill credits, off-platform reconciliation. Lands as a manual or commission_adjustment entry in available status — picked up by the next payout.

Body

{
  "amount": 50000,                    // signed subunits; positive credits, negative debits
  "kind": "manual",                   // or "commission_adjustment"
  "description": "Q1 goodwill credit"
}
FieldTypeConstraints
amountsigned intNo min — manual adjustments bypass commission math
kindenum"manual" or "commission_adjustment"
descriptionstringRequired, trimmed, 1..500

Response 200 — updated VendorBalanceResponse.


POST /admin/payouts/promote — Manually promote pending → available

Required permission: payout: create. Ops escape hatch when the BullMQ cron has not run or has been disabled. Runs PayoutService.promotePendingEntries() once.

Response 200

{ "data": { "promoted": 42 }, "message": "Success", "statusCode": 200 }

  • admin-rbac — gates every endpoint via order:* and payout:*. See admin-rbac.md.
  • cart — converted carts produce orders. See cart.md.
  • inventory — order placement reserves; mark-paid commits; cancel releases, and restocks an unshipped bag whose reservation was already committed.
  • payment — emits payment.captured events that drive the auto mark-paid path (manual override goes through this admin endpoint).
  • shipping — vendors transition sub-orders through fulfilment states.
  • vendorvendor.payouts.payout_hold setting gates POST /admin/vendors/:id/payouts.
  • settingsadmin.payment.pending_timeout_hours drives the stale-pending sweep, widened by admin.payment.retry_window_hours so the sweep never cancels inside the retry window.
  • notifications — order events emit notification triggers; see notifications.md.

Courier assignment and confirmation

When ClickPost automatic assignment is enabled (see admin/shipping-clickpost.md), a courier is chosen for each sub-order as the order is placed, and an operator turns that choice into a real shipment.

Where the assignment sits

Which level carries it depends on how the operator packs orders (admin.shipping.shipment_grouping, see admin/shipping-clickpost.md):

GroupingAssignment
per_vendor (default)One per sub-order, on each vendorBreakdowns entry.
per_orderOne on the order itself, as courierAssignment at the top level — the whole order ships as a single parcel.

Both fields use the same shape. A surface that needs "is anything assigned" should check the order-level one first and fall back to the bags, which is what the admin orders table does so its count reads "one shipment" for one box.

On the sub-order

Every sub-order in vendorBreakdowns carries a courierAssignment on admin surfaces (omitted elsewhere — it includes our carrier cost):

FieldMeaning
statusassigned | booked | failed | skipped
methodDisplay code for the account, derived from account_code
courierNameCarrier name as ClickPost reports it
accountCodeThe ClickPost account the shipment is booked on
credentialSourceplatform | vendor — which shipping account books it
manualtrue when an operator picked the carrier rather than the recommendation
shippingChargeQuoted carrier charge, integer subunits, nullable
failureReason / failureStageWhy it failed, and at which step
referenceCourier-facing reference for this dispatch
assignedAt / confirmedAtTimestamps, nullable

List filters

GET /admin/orders accepts two more query params:

  • courierAssignmentunassigned | assigned | booked | failed. assigned is a courier waiting to be confirmed, booked a confirmed shipment, unassigned neither. Matches orders holding at least one such assignment in either grouping mode; the sub-orders on each row are not narrowed.
  • labelable — keep only orders with at least one sub-order a label can be printed for: a booked shipment, not yet delivered, cancelled or returned.

Endpoints

EndpointPermissionPurpose
POST /admin/order-vendors/confirmorder:confirmShipmentBook shipments across a selection of orders. Body { orderIds: string[] }.
POST /admin/order-vendors/:orderVendorId/confirmorder:confirmShipmentBook one sub-order's shipment.
POST /admin/order-vendors/:orderVendorId/assignorder:updateRecord the courier an operator picked. Body { providerId, method, credentialSource }. Replaces whatever automatic assignment decided, including a failure. Nothing is booked.
POST /admin/order-vendors/:orderVendorId/reassignorder:updateQueue a fresh assignment after a failure. Returns once queued.

A hand-picked courier carries no carrier ranking, so confirming it books the chosen provider and method directly — under credentialSource, which is the one case where a confirm ships on the vendor's own account rather than the platform's. In per_order grouping the assignment is written on the order, not the bag, so it is the parcel that gets the courier.

Both confirm endpoints answer with one result per sub-order rather than all-or-nothing, so a single carrier rejection does not hide the rest of a bulk selection:

{
  "data": {
    "results": [
      { "orderVendorId": "ov_1", "ok": true, "awbNumber": "1234567890" },
      { "orderVendorId": "ov_2", "ok": false, "error": "Pin code not serviceable" }
    ],
    "confirmed": 1,
    "failed": 1
  }
}

Confirming books one waybill per sub-order, each an outbound carrier call, so a selection is capped at 100 shipments and the fan-out is throttled.

Booking a shipment does not mark a sub-order fulfilled — that follows the courier's pickup scan. A pending sub-order with a live awbNumber is the normal resting state between the two.

On this page

ConventionsAuthenticationResponse envelopeError envelopeMoney fieldsDomain typesOrderResponsecustomerTypeguestContactOrderVendorSummary (admin detail only)OrderEventResponseReturnResponsePayoutResponseLedgerEntryResponseVendorBalanceResponseOrdersGET /admin/orders — List ordersGET /admin/orders/:id — Order detailPOST /admin/orders/:id/cancel — Cancel on behalf of customerPOST /admin/orders/:id/restore — Restore a cancelled orderConfirmation: stock, gifts and couponsPOST /admin/orders/:id/mark-paid — Manually mark paidPOST /admin/orders/:id/mark-refunded — Mark refundedPOST /admin/orders/:id/refunds — Refund through the payment gatewayGET /admin/orders/:id/refunds — Gateway refund historyPOST /admin/orders/:id/refunds/:merchantRefundId/sync — Re-read a pending refundGET /admin/orders/:id/events — Paginated audit logPOST /admin/orders/cleanup-stale-pending — Stale pending-payment sweepPATCH /admin/orders/:id/shipping-address — Correct the delivery addressPOST /admin/orders/:id/fulfilled — Fulfil the whole orderPOST /admin/orders/:id/delivered — Mark the whole order deliveredPOST /admin/orders/reassign-courier — Re-run assignment across a selectionPOST /admin/orders/:id/reassign-courier — Re-run automatic courier assignmentCreate, edit and cloneGuest ordersHow an admin order is pricedPaymentStockWhat can still be editedSide effectsAdminOrderDraftAdminOrderQuoteGET /admin/orders/payment-options — Payment options for an admin orderPOST /admin/orders/quote — Price a new orderPOST /admin/orders/free-gifts — Free gifts for an orderPOST /admin/orders — Create an orderGET /admin/orders/:id/clone-draft — Prefill a clonePOST /admin/orders/:id/clone — Place a cloned orderGET /admin/orders/:id/edit-draft — Load an order for editingPOST /admin/orders/:id/quote — Price an editPATCH /admin/orders/:id — Edit an orderOrder exportsOrderExportPOST /admin/orders/exports — Queue an exportGET /admin/orders/exports — Export historyGET /admin/orders/exports/:id — Export statusGET /admin/orders/exports/:id/download — Download the fileSub-order processingPATCH /admin/order-vendors/:orderVendorId/shipment — Correct recorded shipment detailsReturnsGET /admin/returns — List returnsGET /admin/returns/:id — Return detailPOST /admin/orders/:id/returns — Force-create a returnPOST /admin/returns/:id/override — Admin override transitionVendor payoutsGET /admin/vendors/:id/balance — Vendor balance summaryGET /admin/vendors/:id/ledger — Vendor ledgerGET /admin/vendors/:id/payouts — Vendor payoutsGET /admin/payouts — Cross-vendor payout listGET /admin/payouts/:id — Payout detailGET /admin/vendors/:id/payout-config — Per-vendor payout policyPUT /admin/vendors/:id/payout-config — Override a vendor's payout policyGET /admin/vendors/:id/bank-account — Vendor payout destination (masked)POST /admin/vendors/:id/payouts — Create a draft payoutPOST /admin/payouts/:id/mark-paid — Mark a draft as paidPOST /admin/payouts/:id/cancel — Cancel a draftPOST /admin/vendors/:id/ledger/adjust — Manual ledger adjustmentPOST /admin/payouts/promote — Manually promote pending → availableRelated modulesCourier assignment and confirmationWhere the assignment sitsOn the sub-orderList filtersEndpoints