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 */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, NOT_NULL_VIOLATION |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION |
| 413 | HTTP_413 (payload too large) |
| 422 | UNPROCESSABLE_ENTITY |
| 500 | INTERNAL_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
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Substring match on product title / SKU |
stockStatus | "in_stock" | "low_stock" | "out_of_stock" | "backorder" | "untracked"? | — | Filter by current stock bucket |
limit | int | 50 | 1..200 |
offset | int | 0 | >= 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
| Name | Type | Notes |
|---|---|---|
productId | string (UUID) | Must belong to the active vendor |
variantId | string (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
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Session has no active vendor |
| 404 | NOT_FOUND | Variant 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
}| Field | Type | Constraints |
|---|---|---|
trackInventory | boolean? | When false, availableQuantity becomes null and stockStatus is untracked |
safetyStockQuantity | int? | >= 0 |
lowStockThreshold | int | null? | >= 0 |
allowBackorder | boolean? | |
backorderLimit | int | null? | >= 0. Only meaningful when allowBackorder=true |
Response 200 — updated InventorySnapshot.
Side effects — emits INVENTORY_POLICY_UPDATED on the EventEmitter bus.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation |
| 403 | FORBIDDEN | No active vendor on session |
| 404 | NOT_FOUND | Variant 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" }
}| Field | Type | Constraints |
|---|---|---|
quantityDelta | int | Required; must not be 0. Negative values are allowed but cannot exceed available stock unless backorder is enabled |
reason | string | 1–500 chars |
referenceType | string? | Free-form, ≤100 chars |
referenceId | string? | Free-form, ≤255 chars |
metadata | Record<string, unknown> | Default {} |
Response 200 — updated InventorySnapshot.
Side effects
- Writes one
inventoryMovementrow withtype="adjustment". - Emits
INVENTORY_ADJUSTEDon the EventEmitter bus.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Bad delta, missing reason, etc. |
| 409 | CONFLICT | Adjustment would drive available stock below allowed floor |
| 404 | NOT_FOUND | Variant not owned by vendor |
GET /movements — List audit trail
Returns inventory movements for the variant, newest first.
Query
| Name | Type | Default |
|---|---|---|
limit | int | 100 |
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 size | 2 MiB (INVENTORY_IMPORT_MAX_BYTES) |
| Max rows | 5000 (INVENTORY_IMPORT_MAX_ROWS) |
| Accepted mimetype | text/csv (or .csv extension) |
| Default reason | "CSV stock import" |
CSV format
| Column | Required | Notes |
|---|---|---|
sku | yes | Must match a variant SKU owned by the vendor |
quantity | yes | Integer; treated as the absolute new quantity on-hand (not a delta) |
reason | no | Per-row override; falls back to multipart reason field |
reference | no | Per-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
| Field | Type | Notes |
|---|---|---|
| (file) | binary | Exactly one file part. CSV, ≤2 MiB |
reason | string? | Default reason for rows missing one. Trimmed |
reference | string? | 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 200 — InventoryImportPreview
{
"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
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Missing file, non-CSV mimetype, or no .csv extension |
| 409 | CONFLICT | More than one file part in the request |
| 413 | HTTP_413 | File exceeds INVENTORY_IMPORT_MAX_BYTES (2 MiB) |
| 422 | UNPROCESSABLE_ENTITY | Row 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Batch 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
inventoryMovementrow per applied row,type="import". - Emits
INVENTORY_IMPORT_APPLIEDon the EventEmitter bus.
Errors
| Status | Code | When |
|---|---|---|
| 409 | CONFLICT | Batch is in failed_validation, failed, or applying and cannot be applied |
| 404 | NOT_FOUND | Batch not owned by vendor |
Reservations (internal API)
Reservation lifecycle (reserve → commit / 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:
| Event | Fired when |
|---|---|
INVENTORY_RESERVATION_CREATED | A new reservation batch is held |
INVENTORY_RESERVATION_COMMITTED | Order placed; reserved stock decrements on-hand |
INVENTORY_RESERVATION_RELEASED | Cart abandoned or order cancelled before commit |
INVENTORY_RESERVATION_EXPIRED | TTL elapsed without commit/release |
Related modules
catalog— variants belong to products; an inventory row is provisioned when a variant is created. Seecatalog.md.order— places orders by committing reservations.order_line.hsn_code_at_orderis snapshotted from the variant. Seeorder.md.cart— checkout-prepare callsreserveagainst this module's in-process port. Seecart.md.
Catalog Module — Vendor surface
Vendor-facing CRUD over the vendor's own products, variants, options, and tabs, plus the vendor-side "request a new taxonomy entry" workflow (new brand, category, tag, or…
Invoice Module — Vendor
HTTP surface for a vendor to download and regenerate the GST tax invoice for their own sub-order.