Supercommerce API Docs
Vendor API

Inventory Module — Vendor surface

Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, vendor-wide variant listing, and bulk CSV imports.

Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, vendor-wide variant listing, and bulk CSV imports.

Source: api-modules/inventory/src/controllers/vendor-inventory.controller.ts, api-modules/inventory/src/controllers/vendor-inventory-variants.controller.ts, api-modules/inventory/src/controllers/vendor-inventory-import.controller.ts.


Conventions

Authentication

All endpoints require a Better-Auth bearer session.

Authorization: Bearer <session-token>

The vendor scope is read from the session via resolveActiveVendorId() — it prefers session.session.activeOrganizationId (better-auth canonical) and falls back to activeVendorId. If neither is set, the request fails with 403 Forbidden (No active vendor on session).

Tenant scoping

Every read and write scopes to the active vendor's id. Product / variant / batch ids that belong to a different vendor return 404 Not Found, never 403 — so existence of another vendor's row can't be inferred.

Response envelope

Successful responses are wrapped by ResponseInterceptor:

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

Error envelope

Failures are normalized by HttpExceptionFilter:

{
  "data": null,
  "message": "Human-readable summary",
  "statusCode": 400,
  "errorCode": "BAD_REQUEST",
  "errors": [ /* zod issues, when validation fails */ ],
  "debug": { /* dev-only: error + stack */ }
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, NOT_NULL_VIOLATION
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION
413HTTP_413 (payload too large)
422UNPROCESSABLE_ENTITY
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Domain types

InventorySnapshot

type InventoryStockStatus =
  | "in_stock"
  | "low_stock"
  | "out_of_stock"
  | "untracked"
  | "backorder";

type InventorySnapshot = {
  variantId: string;
  productId: string;
  vendorId: string;
  trackInventory: boolean;
  quantityOnHand: number;
  reservedQuantity: number;
  safetyStockQuantity: number;
  lowStockThreshold: number | null;
  allowBackorder: boolean;
  backorderLimit: number | null;
  availableQuantity: number | null;    // null when trackInventory=false
  isOrderable: boolean;
  stockStatus: InventoryStockStatus;
};

InventoryMovement

Immutable audit row, one per stock change.

type InventoryMovement = {
  id: string;
  variantId: string;
  productId: string;
  vendorId: string;
  reservationId: string | null;
  type:
    | "adjustment"
    | "reservation_created"
    | "reservation_committed"
    | "reservation_released"
    | "reservation_expired"
    | "order_cancelled"
    | "order_edited"
    | "return_restocked"
    | "import";
  quantityDelta: number;               // signed
  reservedDelta: number;               // signed
  previousQuantityOnHand: number;
  newQuantityOnHand: number;
  previousReservedQuantity: number;
  newReservedQuantity: number;
  reason: string | null;
  referenceType: string | null;
  referenceId: string | null;
  actorId: string | null;
  metadata: Record<string, unknown> | null;
  createdAt: string;                   // ISO
};

VendorInventoryVariantListItem

Lightweight projection for the vendor-wide variant list.

type VendorInventoryVariantListItem = {
  variantId: string;
  productId: string;
  sku: string | null;
  productTitle: string;
  productThumbnail: string | null;
  trackInventory: boolean;
  availableQuantity: number | null;    // null when trackInventory=false
  stockStatus: InventoryStockStatus;
};

InventoryImportPreview

type InventoryImportPreview = {
  batchId: string;
  status:
    | "validated"
    | "failed_validation"
    | "applied"
    | "applying"
    | "failed";
  totalRows: number;
  validRows: number;
  invalidRows: number;
  rows: InventoryImportPreviewRow[];
};

type InventoryImportPreviewRow = {
  rowNumber: number;
  sku: string | null;
  variantId: string | null;
  productId: string | null;
  productTitle: string | null;
  variantLabel: string | null;
  currentQuantityOnHand: number | null;
  quantityDelta: number | null;
  newQuantityOnHand: number | null;
  status: "valid" | "invalid" | "applied" | "skipped";
  errorCode?:
    | "MISSING_SKU"
    | "MISSING_QUANTITY"
    | "INVALID_QUANTITY"
    | "DUPLICATE_SKU_IN_FILE"
    | "SKU_NOT_FOUND"
    | "SKU_NOT_OWNED_BY_VENDOR"
    | "VARIANT_DELETED"
    | "INVENTORY_ROW_NOT_FOUND";
  errorMessage?: string;
};

Vendor-wide variant listing

Base path: /vendor/inventory/variants.

GET /vendor/inventory/variants — List the vendor's variants

Cross-product variant list with stock + status. Use for the inventory dashboard, low-stock pickers, and the import preview's product context.

Query

NameTypeDefaultNotes
qstring?Substring match on product title / SKU
stockStatus"in_stock" | "low_stock" | "out_of_stock" | "backorder" | "untracked"?Filter by current stock bucket
limitint501..200
offsetint0>= 0

Response 200 — paginated envelope of VendorInventoryVariantListItem[].


Per-variant inventory

Base path: /vendor/products/:productId/variants/:variantId/inventory.

GET / — Get inventory snapshot

Returns the current stock state for a variant the calling vendor owns.

Path params

NameTypeNotes
productIdstring (UUID)Must belong to the active vendor
variantIdstring (UUID)Must belong to productId

Response 200

{
  "data": {
    "variantId": "01J9...",
    "productId": "01J9...",
    "vendorId": "01J9...",
    "trackInventory": true,
    "quantityOnHand": 42,
    "reservedQuantity": 3,
    "safetyStockQuantity": 5,
    "lowStockThreshold": 10,
    "allowBackorder": false,
    "backorderLimit": null,
    "availableQuantity": 39,
    "isOrderable": true,
    "stockStatus": "in_stock"
  },
  "message": "Success",
  "statusCode": 200
}

Errors

StatusCodeWhen
403FORBIDDENSession has no active vendor
404NOT_FOUNDVariant does not exist or is not owned by the active vendor

PATCH /policy — Update inventory policy

Update tracking settings, safety stock, low-stock threshold, and backorder rules.

Body (all fields optional; only provided fields are changed)

{
  "trackInventory": true,
  "safetyStockQuantity": 5,
  "lowStockThreshold": 10,             // nullable
  "allowBackorder": false,
  "backorderLimit": null               // nullable
}
FieldTypeConstraints
trackInventoryboolean?When false, availableQuantity becomes null and stockStatus is untracked
safetyStockQuantityint?>= 0
lowStockThresholdint | null?>= 0
allowBackorderboolean?
backorderLimitint | null?>= 0. Only meaningful when allowBackorder=true

Response 200 — updated InventorySnapshot.

Side effects — emits INVENTORY_POLICY_UPDATED on the EventEmitter bus.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod validation
403FORBIDDENNo active vendor on session
404NOT_FOUNDVariant not owned by vendor, or inventory row vanished mid-update

POST /adjustments — Adjust on-hand quantity

Apply a manual stock change with an audit trail.

Body

{
  "quantityDelta": -2,
  "reason": "Damaged in warehouse",
  "referenceType": "internal_note",
  "referenceId": "note-1234",
  "metadata": { "warehouse": "BLR-1" }
}
FieldTypeConstraints
quantityDeltaintRequired; must not be 0. Negative values are allowed but cannot exceed available stock unless backorder is enabled
reasonstring1–500 chars
referenceTypestring?Free-form, ≤100 chars
referenceIdstring?Free-form, ≤255 chars
metadataRecord<string, unknown>Default {}

Response 200 — updated InventorySnapshot.

Side effects

  • Writes one inventoryMovement row with type="adjustment".
  • Emits INVENTORY_ADJUSTED on the EventEmitter bus.

Errors

StatusCodeWhen
400VALIDATION_ERRORBad delta, missing reason, etc.
409CONFLICTAdjustment would drive available stock below allowed floor
404NOT_FOUNDVariant not owned by vendor

GET /movements — List audit trail

Returns inventory movements for the variant, newest first. Paginated (offset/limit) with the standard metadata envelope.

Query

NameTypeDefaultNotes
limitint1001–500
offsetint0
typestringOptional movement-type filter (e.g. adjustment, reservation_committed, return_restocked)

Response 200

{
  "data": [
    {
      "id": "01J9...",
      "variantId": "01J9...",
      "productId": "01J9...",
      "vendorId": "01J9...",
      "reservationId": null,
      "type": "adjustment",
      "quantityDelta": -2,
      "reservedDelta": 0,
      "previousQuantityOnHand": 44,
      "newQuantityOnHand": 42,
      "previousReservedQuantity": 3,
      "newReservedQuantity": 3,
      "reason": "Damaged in warehouse",
      "referenceType": "internal_note",
      "referenceId": "note-1234",
      "actorId": "01J9...",
      "createdAt": "2026-04-28T14:05:12.000Z"
    }
  ],
  "metadata": { "total": 137, "limit": 100, "offset": 0, "hasMore": true },
  "message": "Success",
  "statusCode": 200
}

CSV imports

Base path: /vendor/inventory/imports.

Two-step flow: upload + validate → review preview → apply.

Constraints

Value
Max file size2 MiB (INVENTORY_IMPORT_MAX_BYTES)
Max rows5000 (INVENTORY_IMPORT_MAX_ROWS)
Accepted mimetypetext/csv (or .csv extension)
Default reason"CSV stock import"

CSV format

ColumnRequiredNotes
skuyesMust match a variant SKU owned by the vendor
quantityyesInteger; treated as the absolute new quantity on-hand (not a delta)
reasonnoPer-row override; falls back to multipart reason field
referencenoPer-row override; falls back to multipart reference field

GET /vendor/inventory/imports/template — Download CSV template

Returns a text/csv stream prefilled with the vendor's active variants — one row per (variant) SKU with the current quantity on hand. Use this as the starting point for a stock-take.

Response is not wrapped (the ResponseInterceptor is skipped); the file is delivered with:

content-type: text/csv; charset=utf-8
content-disposition: attachment; filename="inventory-import-template.csv"

POST /vendor/inventory/imports — Upload + validate CSV

multipart/form-data upload. Validates every row but does not apply any changes — review the preview and call apply to commit.

Form parts

FieldTypeNotes
(file)binaryExactly one file part. CSV, ≤2 MiB
reasonstring?Default reason for rows missing one. Trimmed
referencestring?Default reference for rows missing one

Example (curl)

curl -X POST https://api.example/vendor/inventory/imports \
  -H "Authorization: Bearer $SESSION" \
  -F "file=@stock-2026-04-28.csv;type=text/csv" \
  -F "reason=Monthly stocktake" \
  -F "reference=stocktake-apr-2026"

Response 200InventoryImportPreview

{
  "data": {
    "batchId": "01J9...",
    "status": "validated",
    "totalRows": 1200,
    "validRows": 1187,
    "invalidRows": 13,
    "rows": [
      {
        "rowNumber": 1,
        "sku": "TSHIRT-RED-M",
        "variantId": "01J9...",
        "productId": "01J9...",
        "productTitle": "Red Tee",
        "variantLabel": "M",
        "currentQuantityOnHand": 42,
        "quantityDelta": -2,
        "newQuantityOnHand": 40,
        "status": "valid"
      },
      {
        "rowNumber": 2,
        "sku": "UNKNOWN-SKU",
        "variantId": null,
        "productId": null,
        "productTitle": null,
        "variantLabel": null,
        "currentQuantityOnHand": null,
        "quantityDelta": null,
        "newQuantityOnHand": null,
        "status": "invalid",
        "errorCode": "SKU_NOT_FOUND",
        "errorMessage": "No variant matches SKU \"UNKNOWN-SKU\""
      }
    ]
  },
  "message": "Success",
  "statusCode": 200
}

If invalidRows > 0, status is failed_validation and apply is not allowed until the file is re-uploaded.

Errors

StatusCodeWhen
400BAD_REQUESTMissing file, non-CSV mimetype, or no .csv extension
409CONFLICTMore than one file part in the request
413HTTP_413File exceeds INVENTORY_IMPORT_MAX_BYTES (2 MiB)
422UNPROCESSABLE_ENTITYRow count exceeds INVENTORY_IMPORT_MAX_ROWS (5000)

GET /vendor/inventory/imports — List import batches

Returns recent imports for the active vendor.

Response 200

{
  "data": [
    {
      "batchId": "01J9...",
      "fileName": "stock-2026-04-28.csv",
      "status": "applied",
      "totalRows": 1200,
      "validRows": 1200,
      "invalidRows": 0,
      "createdAt": "2026-04-28T13:22:00.000Z",
      "appliedAt": "2026-04-28T13:24:11.000Z"
    }
  ],
  "message": "Success",
  "statusCode": 200
}

GET /vendor/inventory/imports/:batchId — Get import details

Returns the full InventoryImportPreview for a batch, including every row and its status (mirrors the upload response).

Errors

StatusCodeWhen
404NOT_FOUNDBatch does not exist or belongs to another vendor

POST /vendor/inventory/imports/:batchId/apply — Apply a validated import

Commits the changes from a validated (or partially_applied) batch. The claim is an atomic status CAS, so a second concurrent apply loses and 409s rather than double-counting.

Body — empty.

Response 200 — final InventoryImportPreview with status="applied" and each row marked applied.

Side effects

  • Writes one inventoryMovement row per applied row, type="import".
  • Emits INVENTORY_IMPORT_APPLIED on the EventEmitter bus — also for a partial run, covering the rows that did commit.

Chunking and partial failure

Rows are applied in transactions of 200 rows so inventory_stock row locks are never held across the whole file; a concurrent checkout reserve/commit waits on at most one chunk. The apply is therefore not all-or-nothing:

  • the failing chunk rolls back whole — no row is left half-applied;
  • chunks that already committed keep their rows at applied;
  • the batch lands on status="partially_applied" with progress in metadata.apply (appliedRows, pendingRows, failedAtRowNumber, error), and the error is returned to the caller;
  • re-applying resumes: the claim accepts partially_applied and only picks up rows still valid, so applied rows can never double-count.

Errors

StatusCodeWhen
409CONFLICTBatch is in failed_validation, failed, applying, or applied and cannot be claimed (validated and partially_applied are the claimable states)
404NOT_FOUNDBatch not owned by vendor

Reservations (internal API)

Reservation lifecycle (reservecommit / release / expire) is not exposed over HTTP. It is invoked in-process via InventoryService from checkout/order flows. Background expiry runs on the inventory BullMQ queue (jobs expire-reservation, sweep-expired-reservations); the store operator sets how long checkout holds stock (default 15 minutes) and how long an unpaid order keeps it (default 24 hours).

Cancellation restock

Committing a reservation at payment decrements quantity_on_hand. Releasing only ever moves an active batch, so it cannot undo a commit — a cancel after payment would otherwise leave the stock permanently missing while the goods sit on the shelf.

InventoryService.restockCancelledSubOrder({ orderId, orderVendorId, vendorId }) reverses that commit, and the order module calls it only for a sub-order whose fulfilment status was pending when it was cancelled:

  • Cancelled before it shipped (pending) → stock is restocked automatically. One order_cancelled movement per variant, referenceType="inventory_reservation_batch", referenceId=<newest committed batch>, metadata.orderVendorId, adding back exactly what the matching reservation_committed movement removed (a backordered commit clamps on-hand at zero, so it removed fewer units than it reserved). reservedQuantity is untouched — the commit already released it.
  • Cancelled after it shipped (fulfilled)no automatic restock. Those goods physically left, so they come back only through the return flow: qc_pass writes a return_restocked movement per return line.
  • Delivered or returned → cannot be cancelled at all; use the return flow.
  • Never paid (batch still active, or expired before payment) → nothing to reverse. releaseReservation restores reservedQuantity and on-hand was never touched, so no order_cancelled movement is written.

Admin order edits. When the store operator lowers a quantity on a committed sub-order, one order_edited movement per variant adds the removed units back (referenceType="order_vendor", referenceId=<orderVendorId>), capped at what the sub-order still holds; a later cancel restocks only the remainder.

The restock is idempotent: a replayed cancel is skipped by a pre-check on the existing order_cancelled movement, backed by the partial unique index inventory_movement_order_cancel_restock_uidx on (vendor_id, variant_id, reference_type, reference_id, type) WHERE type = 'order_cancelled'. It runs after the cancel commits and is best-effort — a failure is logged and leaves the cancel standing, since a later retry is safe.

Refunds are money-only and never move stock.

Domain events emitted on reservation transitions:

EventFired when
INVENTORY_RESERVATION_CREATEDA new reservation batch is held
INVENTORY_RESERVATION_COMMITTEDOrder placed; reserved stock decrements on-hand
INVENTORY_RESERVATION_RELEASEDCart abandoned or order cancelled before commit
INVENTORY_RESERVATION_EXPIREDTTL elapsed without commit/release

  • catalog — variants belong to products; an inventory row is provisioned when a variant is created. See catalog.md.
  • order — places orders by committing reservations. order_line.hsn_code_at_order is snapshotted from the variant. See order.md.
  • cart — checkout-prepare calls reserve against this module's in-process port. See cart.md.

On this page