Supercommerce API Docs
Vendor API

Order Module — Vendor surface

Vendor-facing HTTP surface for sub-order fulfillment (assign shipping provider, mark fulfilled, mark delivered, cancel), returns processing (approve / reject / pickup / receive /…

Vendor-facing HTTP surface for sub-order fulfillment (assign shipping provider, mark fulfilled, mark delivered, cancel), returns processing (approve / reject / pickup / receive / QC), and payouts (balance, ledger, and payout history). A parent order row can have one or more order_vendor sub-orders; the vendor surface only ever sees its own sub-orders and the customer's shipping address — never other vendors' slices, billing address, or the parent's payment internals.

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


Conventions

Authentication

All endpoints require a Better-Auth bearer session with an active vendor.

Authorization: Bearer <session-token>

The vendor is resolved via resolveActiveVendorId(session) — prefers session.session.activeOrganizationId (better-auth canonical) and falls back to activeVendorId. Sessions missing an active vendor are rejected with 403 Forbidden (FORBIDDEN).

Tenant scoping

:id on order endpoints is order_vendor.id (a sub-order). :id on return endpoints is return_request.id. :id on payout endpoints is vendor_payout.id. Ids that belong to a different vendor return 404 Not Found, not 403 — no leak of foreign ids.

Response envelope

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, INVALID_TRANSITION, SUB_ORDER_NOT_CANCELLABLE
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Money + dates

All monetary fields (subtotals, totals, refund amounts, ledger amounts, etc.) are integer subunits (paise / cents / eurocents — single currency per tenant). Date fields are ISO datetimes.

Audit trail

Every status mutation writes one or more rows to order_events in the same transaction as the change. Each row carries actor_type (user / vendor / admin / system / webhook), actor_id, source, structured changes, and optional metadata. The most recent rows ship inline on detail responses (events[]).


Domain types

Sub-order lifecycle — OrderVendorStatus

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

VendorOrderResponse

Vendor-scoped sub-order shape. Strips other vendors' sub-orders and the customer's billing address — vendors only see their slice + shipping address.

type TaxComponent = { type: string; rate: number; amount: number };

type VendorOrderResponse = {
  id: string;                              // order_vendor.id
  orderId: string;
  orderNumber: string;
  parentStatus: "pending_payment" | "confirmed" | "fulfilled" | "delivered" | "cancelled" | "refunded";
  fulfillmentStatus: OrderVendorStatus;

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

  shippingProviderId: string | null;       // null while pending
  shippingMethod: string | null;
  trackingCode: string | null;
  awbNumber: string | null;

  taxBreakdown: TaxComponent[];            // aggregated across lines + shipping
  shippingNetAmount: number | null;
  shippingTaxBreakdown: TaxComponent[];

  shippingAddress: {
    firstName: string; lastName: string;
    fullAddress: string;
    city: string; pincode: string; state: string;
    phone: string; country: string;
  };

  lines: OrderLineResponse[];
  events: OrderEventResponse[];

  fulfilledAt: string | null;              // ISO
  deliveredAt: string | null;
  cancelledAt: string | null;
  cancellationReason: string | null;
  placedAt: string;
};

OrderLineResponse

type OrderLineResponse = {
  id: string;
  vendorId: string;
  variantId: string | null;
  productId: string | null;
  sku: string;
  productNameAtOrder: string;
  variantNameAtOrder: string | null;
  imageAtOrder: string | null;
  hsnCodeAtOrder: string | null;           // snapshotted from variant at place-order
  type: "product" | "gift" | "shipping";   // see order.md for full type set
  quantity: number;
  unitPrice: number;
  lineSubtotal: number;
  discountAllocated: number;
  lineTotal: number;
  netAmount: number | null;                // pre-tax portion (null when vendor has no taxes)
  taxBreakdown: TaxComponent[];
};

ReturnResponse

type ReturnResponse = {
  id: string;
  returnNumber: string;
  orderId: string;
  orderVendorId: string;
  customerId: string | null;
  vendorId: string;
  type: string;                            // e.g. "refund", "exchange"
  status: string;                          // requestedapprovedpicked_upreceivedqc_passed/qc_failedrefunded | rejected | cancelled
  reasonCode: string | null;
  reasonNotes: string | null;
  refundAmount: number;                    // subunits
  refundedAmount: number;
  externalRefundReference: string | null;
  shippingProvider: string | null;
  awbNumber: string | null;
  trackingCode: string | null;
  rejectionReason: string | null;
  qcFailureReason: string | null;
  requestedAt: string;                     // ISO
  approvedAt: string | null;
  rejectedAt: string | null;
  pickedUpAt: string | null;
  receivedAt: string | null;
  qcPassedAt: string | null;
  qcFailedAt: string | null;
  refundedAt: string | null;
  cancelledAt: string | null;
  lines: ReturnLineResponse[];
  photos: ReturnPhotoResponse[];
};

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

type ReturnPhotoResponse = {
  id: string;
  storageKey: string;
  contentType: string | null;
  fileSizeBytes: number | null;
  sortOrder: number;
  uploadedAt: string;
};

Payouts — VendorBalanceResponse, LedgerEntryResponse, PayoutResponse

type VendorBalanceResponse = {
  vendorId: string;
  pending: number;                         // 'pending' ledger entries (return window not elapsed)
  available: number;                       // 'available' entries not yet on a draft payout
  lifetimeEarned: number;
  lifetimeRefunded: number;
  lifetimePaidOut: number;
  payoutHold: boolean;                     // vendor.payouts.payout_hold flag
  commissionRate: number;                  // basis points (10000 = 100.00%)
};

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;
};

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 on detail
};

Vendor sub-orders

Base path: /vendor/orders. :id is order_vendor.id.

GET /vendor/orders — List my sub-orders

Paginated, newest first.

Query

NameTypeDefaultNotes
pageint1>= 1
limitint201..100
statusOrderVendorStatus?Optional filter

Response 200 — paginated envelope of VendorOrderResponse.


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

Returns the active vendor's VendorOrderResponse for one sub-order.

Errors

StatusCodeWhen
404NOT_FOUNDSub-order does not belong to active vendor

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

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

Body

{ "reason": "Out of stock at warehouse" }
FieldTypeConstraints
reasonstring?Trimmed; 1..500 chars. Required when sub-order is fulfilled

Response 200 — updated VendorOrderResponse with fulfillmentStatus="cancelled" and cancelledAt stamped.

Side effects

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

Errors

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

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

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

Body

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

Response 200 — updated VendorOrderResponse.

Side effects

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

Errors

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

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

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

Body

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

Response 200

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

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

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

Body — empty.

Response 200 — updated VendorOrderResponse.

Side effects

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

Errors

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

Vendor returns

Base path: /vendor/returns. :id is return_request.id. Return requests are created by the customer (or admin) against a delivered sub-order; the vendor approves/rejects, schedules pickup, receives the parcel, and runs QC. Refunds themselves are admin-actioned.

GET /vendor/returns — List my returns

Query

NameTypeDefaultNotes
pageint1>= 1
limitint201..100
statusstring?Trimmed; 1..32 chars. Filter by return status

Response 200 — paginated envelope of ReturnResponse.


GET /vendor/returns/:id — Return detail

Response 200ReturnResponse.

Errors

StatusCodeWhen
404NOT_FOUNDReturn not owned by active vendor

POST /vendor/returns/:id/approve — Approve a return

Optionally override the computed refund_amount (capped between 0 and the system-computed amount).

Body

{ "refundAmountOverride": 49900 }          // optional
FieldTypeConstraints
refundAmountOverrideint?>= 0 and ≤ computed refundAmount

Response 200 — updated ReturnResponse with approvedAt stamped.


POST /vendor/returns/:id/reject — Reject a return

Body

{ "reason": "Item shows signs of wear inconsistent with the return reason" }
FieldTypeConstraints
reasonstringTrimmed; 1..500 chars. Required

Response 200 — updated ReturnResponse with rejectedAt and rejectionReason stamped.


POST /vendor/returns/:id/pickup — Mark courier pickup confirmed

Optionally attach courier identifiers from the pickup attempt.

Body

{
  "awbNumber": "AWB12345",                 // optional
  "trackingCode": "TRK67890"               // optional
}
FieldTypeConstraints
awbNumberstring?Trimmed; 1..200 chars
trackingCodestring?Trimmed; 1..200 chars

Response 200 — updated ReturnResponse with pickedUpAt stamped.


POST /vendor/returns/:id/receive — Mark the returned parcel as received

Body — empty.

Response 200 — updated ReturnResponse with receivedAt stamped.


POST /vendor/returns/:id/qc-pass — Pass QC and restock

Restocks each return line via the inventory module. Restock is best-effort per line — each call is idempotent on (return_request, return_line). Refund itself still requires admin action.

Body — empty.

Response 200 — updated ReturnResponse with qcPassedAt stamped and lines[].restocked = true where applicable.


POST /vendor/returns/:id/qc-fail — Fail QC (no restock)

Terminal for the vendor flow. Admin can still force-refund via the admin override endpoint.

Body

{ "reason": "Item arrived damaged beyond resale" }
FieldTypeConstraints
reasonstringTrimmed; 1..500 chars. Required

Response 200 — updated ReturnResponse with qcFailedAt and qcFailureReason stamped.


Vendor payouts

Base path: /vendor. Read-only — payout creation, marking paid, balance adjustments, and cancellation are all admin actions; the vendor sees the ledger and the payouts admin has cut.

GET /vendor/balance — Vendor balance

Snapshot of the active vendor's funds and commission rate.

Response 200VendorBalanceResponse.

{
  "data": {
    "vendorId": "01J9...",
    "pending": 125000,
    "available": 240000,
    "lifetimeEarned": 5400000,
    "lifetimeRefunded": 120000,
    "lifetimePaidOut": 5040000,
    "payoutHold": false,
    "commissionRate": 1500                 // 15.00%
  }
}

GET /vendor/ledger — Ledger entries

Paginated ledger for the active vendor. One row per sale, refund, manual adjustment, or commission adjustment.

Query

NameTypeDefaultNotes
pageint1>= 1
limitint201..100
kind"sale" | "refund" | "manual" | "commission_adjustment"?Filter by entry kind
status"pending" | "available" | "paid_out" | "cancelled"?Filter by entry status

Response 200 — paginated envelope of LedgerEntryResponse.


GET /vendor/payouts — List my payouts

Query

NameTypeDefaultNotes
pageint1>= 1
limitint201..100
status"pending" | "paid" | "cancelled" | "failed"?Filter by payout status

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


GET /vendor/payouts/:id — Payout detail

Returns the payout plus its constituent entries[] (full LedgerEntryResponse[]).

Response 200PayoutResponse with entries[] populated.

Errors

StatusCodeWhen
404NOT_FOUNDPayout not owned by active vendor

Domain events

Emitted via EventEmitter2. The vendor surface only emits a subset; the full catalogue is in order.md.

EventFired when
order.vendor.fulfilledSub-order pending → fulfilled via POST /vendor/orders/:id/fulfilled
order.vendor.deliveredSub-order fulfilled → delivered via POST /vendor/orders/:id/delivered
order.vendor.cancelledSub-order * → cancelled via POST /vendor/orders/:id/cancel
order.cancelledAuto-cascade when every sibling sub-order is cancelled
order.paidCOD auto-paid when every non-cancelled sub-order is delivered
order.return.*Return lifecycle transitions on the vendor returns endpoints
vendor.payout.*Payout lifecycle (admin actions — read-only for vendors)

  • cart — orders are created by committing a prepared checkout; cart never appears on the vendor surface.
  • inventory — sub-order cancel releases reservations; return QC-pass restocks.
  • shipping + shipping-clickpostproviderId/method validation and the createShipment() / cancelShipment() hooks live there. See shipping.md, shipping-clickpost.md.
  • tax-flat — line and shipping taxBreakdown is computed from per-vendor tax config. See tax.md.
  • notifications — listens to order.vendor.* events for vendor-targeted email/push.

On this page