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 viaCatalogModule.forRoot()inapps/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 */ }
}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
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
| 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 (envelope above).
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 |
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
| 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 / — 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Batch 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
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 |
Configuration
| Env var | Default | Purpose |
|---|---|---|
INVENTORY_RESERVATION_TTL_MINUTES | 60 | How long a reservation batch is held before auto-expiry |
ORDER_REQUEST_RESERVATION_TTL_MINUTES | — | Legacy fallback for the above |
Free Gift Module
Admin-facing HTTP endpoints for managing platform-wide free-gift rules — three rule types (Automatic, BuyXGetY, Coupon-based), criteria-based eligibility (cart subtotal, order…
Notifications Module
HTTP surface for the event-driven notification system — customer + vendor mobile-device registration for FCM push, admin broadcasts (one-off email or push to a defined audience),…