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"
    | "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>;
  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); checkout sets the TTL from admin.payment.checkout_hold_minutes (default 15); a reserve() without its own expiresAt falls back to INVENTORY_RESERVATION_TTL_MINUTES (15).

Every stock-bearing line is reserved, including free gifts. A GIFT cart or order line consumes inventory exactly like a purchased one, so gifts are decremented on commit and restocked on cancellation or return. A variant appearing more than once in a single reserve call — typically the same item both bought and gifted — is collapsed into a single summed item, giving one inventory_reservation row per (batch, variant). Because a batch belongs to exactly one vendor, a cart in which a vendor contributes only a gift produces its own batch for that vendor.

Order-stage TTL

A reservation is minted at cart stage and re-keyed onto the order when it is placed, which does not re-base expires_at. Since a pending_payment order may outlive the cart-stage TTL, place-order calls InventoryService.extendReservation({ orderId, expiresAt }) to push the expiry out to admin.payment.reservation_hold_hours plus a 30-minute grace. Without this the stock is released almost immediately while the order is still payable, and the later commit silently no-ops.

The hold is deliberately shorter than the payment-retry window, so an unpaid order stops costing inventory long before it stops being payable. Once it lapses the batch expires normally; a payment retry then calls InventoryService.reserve({ orderId, ... }) to acquire the stock again — one batch per vendor, gift lines included — and is refused if any line has sold out. A sold-out gift fails the retry too, unlike checkout, which would drop it: by then the gift is an order line against a frozen total with no cart to remove it from, so ORDER_ITEMS_UNAVAILABLE ("place a new order") is the correct outcome. hasActiveReservationForOrder(orderId) is what tells the two paths apart.

extendReservation is monotonic: it never shortens an existing expiry, so it is idempotent. It needs no queue work — the delayed expire-reservation job queued at reserve time fires at the old instant, matches nothing (expire requires expires_at <= now()), and no-ops; sweep-expired-reservations then expires the batch at its new time.

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 an admin edit lowers a quantity on a committed sub-order, InventoryOrderEditService.restockOrderEdit writes one order_edited movement per variant (referenceType="order_vendor", referenceId=<orderVendorId>, metadata.editId), capped at what the sub-order still holds. findCommittedQuantitiesForSubOrder subtracts those movements, so a later cancel restocks only the remainder. Added units are reserved and committed like any order. reserve(input, { allowOversell: true }) skips the availability check for an operator who knowingly sells stock that is not on the shelf; it is not reachable from any request DTO.

Once every unit is back, the sub-order's committed batches move to status restocked. A restored order reserves and commits fresh batches, so a later cancel restocks only those, nets only the order_edited movements written since the last restock, and keys its movements to a different batch. Restocks written before batches were marked this way are keyed referenceType="order_vendor" and are still recognised for a sub-order's first cycle.

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

Configuration

SettingDefaultPurpose
admin.payment.checkout_hold_minutes15How long checkout holds stock before the order is placed
admin.payment.reservation_hold_hours24How long an unpaid order keeps its reservation (see order.md)

On this page