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"
    | "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>;
  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.

Query

NameTypeDefault
limitint100

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...",
      "metadata": { "warehouse": "BLR-1" },
      "createdAt": "2026-04-28T14:05:12.000Z"
    }
  ],
  "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 batch. Idempotent: re-applying an already-applied batch is a no-op.

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.

Errors

StatusCodeWhen
409CONFLICTBatch is in failed_validation, failed, or applying and cannot be applied
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); TTL is controlled by INVENTORY_RESERVATION_TTL_MINUTES (default 60).

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