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
orderrow aggregates the customer-facing totals while eachorder_vendorrow is the fulfilment unit. Inventory reservations, fulfilment status, and the vendor ledger all key offorder_vendor.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
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-gifts | order: view |
POST /admin/orders | order: create |
GET /admin/orders/:id/edit-draft, POST /admin/orders/:id/quote, PATCH /admin/orders/:id | order: edit |
GET /admin/orders/:id/clone-draft, POST /admin/orders/:id/clone | order: clone |
POST /admin/orders/:id/cancel, POST /admin/orders/cleanup-stale-pending | order: cancel |
POST /admin/orders/:id/mark-paid, POST /admin/orders/:id/mark-refunded | order: update |
POST/GET /admin/orders/:id/refunds, POST /admin/orders/:id/refunds/:merchantRefundId/sync | order: 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/delivered | order: update |
GET/POST /admin/orders/exports, GET /admin/orders/exports/:id, GET /admin/orders/exports/:id/download | orderExport: view / orderExport: create |
GET /admin/returns, GET /admin/returns/:id | order: view |
POST /admin/orders/:id/returns, POST /admin/returns/:id/override | order: 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-account | payout: view |
POST /admin/vendors/:id/payouts, POST /admin/payouts/promote | payout: create |
POST /admin/payouts/:id/mark-paid | payout: mark_paid |
POST /admin/payouts/:id/cancel | payout: cancel |
POST /admin/vendors/:id/ledger/adjust | payout: adjust |
PUT /admin/vendors/:id/payout-config | payout: configure |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT (illegal state transition, e.g. cancelling a delivered order) |
| 500 | INTERNAL_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:
| Value | Meaning |
|---|---|
guest | No 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. |
new | The account's first purchase. |
returning | The 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
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (paginated default) | — |
status | OrderStatus? | — | Parent-order lifecycle filter |
vendorId | string? | — | Narrows to orders containing a sub-order for this vendor |
fulfillmentStatus | OrderVendorStatus? | — | 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 |
labelable | boolean? | — | Keeps orders with at least one sub-order a courier label can be printed for |
excludeLabelPrinted | boolean? | — | 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 |
search | string? | — | 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 |
customerSearch | string? | — | Case-insensitive partial match on the order's shipping first name, last name or phone, or the linked account's name or email |
productSearch | string? | — | 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 |
dateFrom | string? (ISO datetime) | — | Inclusive lower bound. Ignored without dateField |
dateTo | string? (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 |
excludeRefunded | boolean? | — | 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 |
excludeUnpaid | boolean? | — | 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"? | createdAt | Timestamp 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown 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" }| Field | Type | Constraints |
|---|---|---|
reason | string? | Trimmed, 1..500 |
Response 200 — cancelled OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | A 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.restoredand oneorder_vendor.restoredper sub-order, the latter carrying any cleared waybill inchanges.from. A paid order whose cancel suggested a refund also getspayment.refund_suggestion_withdrawn. Emitsorder.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.restoredaudit row undermetadata.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 itsorderLineIdindropGiftLineIdsleaves it off the restored order (the line is deleted and recorded undermetadata.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 returnsRESTORE_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
| Code | When |
|---|---|
ORDER_NOT_CANCELLED | The order is not cancelled |
ORDER_NOT_RESTORABLE | No 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_CONFIRMED | The order was still pending_payment when cancelled (stale-payment sweep or payment failure) |
ORDER_REFUNDED_NOT_RESTORABLE | paymentStatus is refunded or partially_refunded, or a gateway refund is pending or completed |
ORDER_HAS_RETURNS | The order has a return |
ORDER_HAD_FULFILLED_SUBORDERS | A restored sub-order was fulfilled when cancelled, so its goods had left |
RESTORE_WINDOW_EXPIRED | Cancelled longer ago than admin.fulfillment.order_restore_window_hours (default 168; 0 removes the limit) |
REWARD_BALANCE_INSUFFICIENT | The customer has spent the points the cancel gave back |
REWARD_REDEMPTION_UNAVAILABLE | The order used points and redemption is now switched off |
ORDER_ITEMS_UNAVAILABLE | An item could not be reserved even with oversell |
RESTORE_NEEDS_CONFIRMATION | Short stock, an unavailable gift rule or an unaccepted coupon, without acknowledgeWarnings |
RESTORE_LEAVES_NOTHING | Every line was dropped |
ORDER_CHANGED_CONCURRENTLY | The 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"]
}| Field | Type | Constraints |
|---|---|---|
reason | string? | Trimmed, 1..500 |
acknowledgeWarnings | boolean | Default false; required when the preview reports anything |
dropGiftLineIds | string[] | 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Already 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"
}| Field | Type | Constraints |
|---|---|---|
amount | int? | >= 1 subunits. Defaults to the remaining unpaid amount |
returnId | string? | 1..100; links per-return refund counter |
externalReference | string? | 1..200 |
reason | string? | 1..500 |
Response 200 — refunded OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Order 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
}| Field | Type | Constraints |
|---|---|---|
amount | int? | 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 |
returnId | string? | Advances that return's refund counter and reverses the vendor's ledger entry |
reason | string? | 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
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | The order's provider has no gateway-refund capability — use mark-refunded |
| 400 | BAD_REQUEST | Amount is not positive, or exceeds the remaining balance |
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Order 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 200 — PaymentRefund[].
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
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Provider cannot report refund state |
| 404 | NOT_FOUND | Unknown 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
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | — | — |
eventType | string? | — | Trimmed, 1..128. Narrow to one event class |
Response 200 — paginated envelope of OrderEventResponse[].
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown 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 }| Field | Type | Constraints |
|---|---|---|
timeoutHours | int? | 0..720. Defaults to admin.payment.pending_timeout_hours |
limit | int? | 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown order |
| 409 | ORDER_ADDRESS_LOCKED | A 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:
| Name | Type | Notes |
|---|---|---|
providerId | string | Shipping provider |
method | string | Carrier / 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown order, or it has no sub-orders |
| 409 | CONFLICT | Store 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
| Name | Type | Notes |
|---|---|---|
orderIds | string[] | 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
| Status | Code | When |
|---|---|---|
| 409 | CONFLICT | No 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:
| Grouping | Behaviour |
|---|---|
per_order | One job for the whole parcel. The order-scoped shipment_assignment row is reclaimed off failed; no sub-order is named. |
per_vendor | One 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown order, or it has no sub-orders |
| 409 | CONFLICT | No 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).
unitPriceon a line overrides it. When editing, a line left unchanged keeps the price it was ordered at. - Coupons —
couponCodesare 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 indiscount_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 discount —
discountAmountcomes 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-giftslists what the store's gift rules offer for the order as it stands; a chosen gift is sent back as a line with itsgiftRuleId. 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
vendorShippingoverrides 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.status | Method | Order becomes | Stock |
|---|---|---|---|
paid | any | confirmed / paid, with a succeeded payment row | committed |
pending | cod | confirmed / pending (collected on delivery) | committed |
pending | other, confirmOrder: true (default) | confirmed / pending | committed |
pending | other, confirmOrder: false | pending_payment / pending | reserved; 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_editedinventory 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
| Situation | Editable |
|---|---|
| Nothing has moved | Everything |
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 exists | Customer note, internal note, billing address, attributes |
| Order cancelled | Customer 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(andorder.paidwhen paid).notifyCustomer: falseskips 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 emitsorder.vendor.cancelled. - Audit — every change writes an
order_eventin 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 sharemetadata.editId. A clone writesorder.clonedon 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; // null → shipping address name
phone: string | null; // null → shipping 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 200 — Array<{ 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 200 — AdminOrderQuote.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VARIANT_UNAVAILABLE | Unknown or deleted variant |
| 400 | PRICE_UNAVAILABLE | The variant has no price and no unitPrice was given |
| 400 | DUPLICATE_VARIANT | The same variant on two lines |
| 400 | DISCOUNT_EXCEEDS_SUBTOTAL | discountAmount 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.
Body — AdminOrderDraft plus:
{
"notifyCustomer": true, // false skips customer-facing notifications
"allowOversell": false // true reserves stock even where none is available
}Response 201 — the created OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | CUSTOMER_NOT_FOUND, PAYMENT_OPTION_NOT_ENABLED | Unknown customer, or the provider/method is not enabled for the platform |
| 400 | GUEST_ORDERS_UNAVAILABLE | guest was sent but the guest-checkout plugin is not mounted |
| 400 | VARIANT_UNAVAILABLE, PRICE_UNAVAILABLE, DUPLICATE_VARIANT, DISCOUNT_EXCEEDS_SUBTOTAL | As for quote |
| 400 | COUPON_INVALID | A coupon was refused; details carries coupon and reason |
| 400 | GIFT_NOT_ELIGIBLE | A gift line is not offered by its rule for these products |
| 409 | INSUFFICIENT_INVENTORY | Not 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
| Status | Code | When |
|---|---|---|
| 400 | CUSTOMER_NOT_FOUND, PAYMENT_OPTION_NOT_ENABLED, ORDER_LINE_NOT_FOUND | Unknown customer, disabled payment option, or a line id that is not on this order |
| 400 | VARIANT_UNAVAILABLE, PRICE_UNAVAILABLE, DUPLICATE_VARIANT, DISCOUNT_EXCEEDS_SUBTOTAL | As for quote |
| 400 | COUPON_INVALID, GIFT_NOT_ELIGIBLE | A new coupon was refused, or a new gift line is not offered by its rule |
| 409 | ORDER_STALE | The order changed since expectedUpdatedAt; reload and retry |
| 409 | ORDER_EDIT_LOCKED | A sent section is not editable any more; details carries mode, reasons and the blocked fields |
| 409 | INSUFFICIENT_INVENTORY | Not enough stock for the added units and allowOversell is false; nothing is written |
| 409 | PAYMENT_BALANCE_UNACKNOWLEDGED | A paid order's total would change without acknowledgePaymentBalance |
| 409 | PAYMENT_ALREADY_CAPTURED, PAYMENT_METHOD_LOCKED | A paid payment set back to pending, or the method changed after payment |
| 409 | VENDOR_SUB_ORDER_CANCELLED | Lines 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
| Name | Type | Default | Notes |
|---|---|---|---|
format | "csv" | "xlsx" | "csv" | — |
status | OrderStatus? | — | Same meaning as on GET /admin/orders |
fulfillmentStatus | OrderVendorStatus? | — | ” |
courierAssignment | "assigned" | "booked" | "unassigned" | "failed"? | — | ” |
vendorId | string? | — | ” |
search | string? | — | ” |
customerSearch | string? | — | ” |
productSearch | string? | — | ” |
dateField | "paidAt" | "createdAt" | "updatedAt"? | — | ” |
dateFrom | string? (ISO datetime) | — | ” |
dateTo | string? (ISO datetime) | — | ” |
excludeRefunded | boolean? | — | ” |
excludeUnpaid | boolean? | — | ” |
Omitting every filter exports every order.
Response 201 — OrderExport 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id, or the object is gone from storage |
| 409 | CONFLICT | Export 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"
}| Field | Type | Constraints |
|---|---|---|
providerId | string? | Non-empty. Must be enabled for the resolved account |
method | string? | Non-empty. Must be a courier the provider exposes for that account |
trackingCode | string | null? | Trimmed, <= 200. null clears |
awbNumber | string | 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown sub-order id |
| 409 | CONFLICT | Bag is still pending — fulfil it instead |
| 409 | CONFLICT | Neither a stored nor a supplied provider/method pair exists |
Returns
GET /admin/returns — List returns
Required permission: order: view. Cross-vendor list.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | — |
limit | int | — | — |
status | string? | — | Trimmed, 1..32. Filter by return status |
Response 200 — paginated envelope of ReturnResponse[].
GET /admin/returns/:id — Return detail
Required permission: order: view.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown 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
}| Field | Type | Constraints |
|---|---|---|
orderVendorId | string | Required; the sub-order being returned |
reasonCode | string | 1..64 |
reasonNotes | string? | max 2000 |
lines[] | array | 1..100 entries |
preApprove | boolean? | Skip vendor approval step |
refundAmountOverride | int? | >= 0 subunits |
Response 200 — created ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Order or sub-order not found |
POST /admin/returns/:id/override — Admin override transition
Required permission: order: update. Escape hatch transitions on a wedged return.
action | Effect |
|---|---|
"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" }| Field | Type | Constraints |
|---|---|---|
action | enum | One of force_refund / approve / cancel |
reason | string | Required; 1..500 |
Response 200 — overridden ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Unknown id |
Vendor payouts
GET /admin/vendors/:id/balance — Vendor balance summary
Required permission: payout: view.
Response 200 — VendorBalanceResponse.
GET /admin/vendors/:id/ledger — Vendor ledger
Required permission: payout: view.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | — |
limit | int | — | — |
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
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | — |
limit | int | — | — |
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 200 — PayoutResponse with entries[] populated.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown 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
| Name | Type | Notes |
|---|---|---|
commissionRate | int (0–10000) | null | Basis points (1000 = 10%). null inherits admin.payouts.default_commission_rate. |
minPayoutAmountSubunit | int (≥0) | null | null inherits admin.payouts.min_payout_amount_subunit. |
payoutHold | boolean | Blocks createDraftPayout with 403 when true. |
notes | string (≤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"
}| Field | Type | Notes |
|---|---|---|
periodStart | ISO datetime? | Inclusive lower bound. Default: no lower bound |
periodEnd | ISO datetime? | Inclusive upper bound. Default: now |
notes | string? | Trimmed, max 2000 |
Response 201 — draft PayoutResponse.
Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Vendor on payout hold |
| 400 | VALIDATION_ERROR | No 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" }| Field | Type | Constraints |
|---|---|---|
bankReference | string | Required, trimmed, 1..200 |
notes | string? | Trimmed, max 2000 |
Response 200 — paid PayoutResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | CONFLICT | Payout 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" }| Field | Type | Constraints |
|---|---|---|
reason | string | Required, trimmed, 1..500 |
Response 200 — cancelled PayoutResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown 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"
}| Field | Type | Constraints |
|---|---|---|
amount | signed int | No min — manual adjustments bypass commission math |
kind | enum | "manual" or "commission_adjustment" |
description | string | Required, 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 }Related modules
admin-rbac— gates every endpoint viaorder:*andpayout:*. Seeadmin-rbac.md.cart— converted carts produce orders. Seecart.md.inventory— order placement reserves;mark-paidcommits; cancel releases, and restocks an unshipped bag whose reservation was already committed.payment— emitspayment.capturedevents that drive the automark-paidpath (manual override goes through this admin endpoint).shipping— vendors transition sub-orders through fulfilment states.vendor—vendor.payouts.payout_holdsetting gatesPOST /admin/vendors/:id/payouts.settings—admin.payment.pending_timeout_hoursdrives the stale-pending sweep, widened byadmin.payment.retry_window_hoursso the sweep never cancels inside the retry window.notifications— order events emit notification triggers; seenotifications.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):
| Grouping | Assignment |
|---|---|
per_vendor (default) | One per sub-order, on each vendorBreakdowns entry. |
per_order | One 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):
| Field | Meaning |
|---|---|
status | assigned | booked | failed | skipped |
method | Display code for the account, derived from account_code |
courierName | Carrier name as ClickPost reports it |
accountCode | The ClickPost account the shipment is booked on |
credentialSource | platform | vendor — which shipping account books it |
manual | true when an operator picked the carrier rather than the recommendation |
shippingCharge | Quoted carrier charge, integer subunits, nullable |
failureReason / failureStage | Why it failed, and at which step |
reference | Courier-facing reference for this dispatch |
assignedAt / confirmedAt | Timestamps, nullable |
List filters
GET /admin/orders accepts two more query params:
courierAssignment—unassigned|assigned|booked|failed.assignedis a courier waiting to be confirmed,bookeda confirmed shipment,unassignedneither. 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
| Endpoint | Permission | Purpose |
|---|---|---|
POST /admin/order-vendors/confirm | order:confirmShipment | Book shipments across a selection of orders. Body { orderIds: string[] }. |
POST /admin/order-vendors/:orderVendorId/confirm | order:confirmShipment | Book one sub-order's shipment. |
POST /admin/order-vendors/:orderVendorId/assign | order:update | Record 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/reassign | order:update | Queue 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.
Notifications Module — Admin
HTTP surface for the admin notification operations: composing and sending broadcasts (one-shot email and/or push to a targeted audience) and inspecting the notification log…
Payment CCAvenue — Admin
How an operator configures, enables and refunds through CCAvenue, including the MARS panel checklist, the IP allowlist requirement, and the Instant Gratification assumption this integration depends on.