Supercommerce API Docs
Full Module Docs

Inventory Module

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

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

Source: api-modules/inventory (registered via CatalogModule.forRoot() in apps/api/src/app.module.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).

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

Used in payloads and responses below.

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

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

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 (envelope above).

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

POST / — 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 / — 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 /: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 /: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

Configuration

Env varDefaultPurpose
INVENTORY_RESERVATION_TTL_MINUTES60How long a reservation batch is held before auto-expiry
ORDER_REQUEST_RESERVATION_TTL_MINUTESLegacy fallback for the above

On this page