# Inventory

Part of the [Shopdesk Commerce API reference](https://www.shopdeskbd.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.shopdeskbd.com`. Authenticate every request with `Authorization: Bearer shopdesk_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.shopdeskbd.com/developers/errors.md).

## Adjust stock at a location

`POST /ext/v1/inventory/adjustments` · Requires scope `inventory:write`

The staff adjustment path verbatim: row-locked, ledgered, activity-logged. Address the variant by `variant_id` OR `sku` — a stock sync holding SKUs needs no catalog lookup first. A `reason` is always required here; on-hand can never go below zero or below the reserved quantity; `damage` must remove stock; `return` can only add stock; `purchase` requires a `unit_cost` and folds into the moving-average cost, and `unit_cost` is refused on every other type.

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | Yes | REQUIRED. A unique value per logical request (a UUID is ideal); reuse it verbatim when retrying. A retry replays the original response with `Idempotent-Replayed: true` instead of running twice. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Target store. REQUIRED with a company-scoped key; with a store-scoped key it may only repeat the key's own store. |
| `location_id` | `uuid` | Yes | Location (warehouse) to adjust. |
| `variant_id` | `uuid` | No | The variant being adjusted. Provide this OR `sku`. |
| `sku` | `string` | No | Resolve the variant by SKU instead of id — unique within the store being written to. Ignored when `variant_id` is also given. |
| `quantity_change` | `number` | No | Signed change to on-hand. Positive adds stock, negative removes; never zero. Cannot take on-hand below zero or below reservations — the same rules staff adjustments obey. Provide this OR `set_on_hand`. |
| `set_on_hand` | `number` | No | The absolute on-hand this location should END AT — what a stock-take or a spreadsheet actually says. The delta is computed server-side from the quantity read under the row lock, so nothing can slip in between the read and the write. Provide this OR `quantity_change`. A target that already matches is a successful no-op, not an error. |
| `expected_on_hand` | `number` | No | Optimistic-concurrency guard: the on-hand you believe is there. Compared under the row lock; if it no longer matches, the write is refused with 409 `CONFLICT` carrying the actual value, instead of applying against a number that moved. Works with either mode. |
| `type` | `string` (default `adjustment`) | No | Ledger category. `purchase` adds stock at a required `unit_cost`; `damage` must remove stock; `return` can only add stock; default `adjustment`. A decrease filed as `adjustment` or `damage` is booked as a loss on the P&L at the current average cost. One of: `purchase`, `return`, `adjustment`, `damage`. |
| `reason` | `string` | Yes | Stored on the ledger entry. Required. |
| `unit_cost` | `number` | No | Per-unit cost of the moved units. REQUIRED for `purchase` (stock cannot enter at no cost) and refused on any other type, which moves units at the variant's current average cost. Write-only — cost never appears in any response (Q6). |
| `low_stock_threshold` | `number` | No | Optionally update the low-stock threshold for this level. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.shopdeskbd.com/ext/v1/inventory/adjustments" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47" \
  -H "Content-Type: application/json" \
  -d '{
  "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "quantity_change": -5,
  "reason": "Cycle count correction — shelf B4."
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/adjustments");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "quantity_change": -5,
    "reason": "Cycle count correction — shelf B4."
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "quantity_change": -5,
    "reason": "Cycle count correction — shelf B4.",
}

response = requests.post(
    "https://api.shopdeskbd.com/ext/v1/inventory/adjustments",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'location_id' => '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001',
    'variant_id' => '5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001',
    'quantity_change' => -5,
    'reason' => 'Cycle count correction — shelf B4.',
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/inventory/adjustments');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: 3f2b8c1e-9d4a-4e7b-8a6f-2c5d1e0b9a47', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

### Responses

- `201` — The adjusted stock level, in the same shape as `GET /v1/inventory/levels` rows.
- `400` — The required `Idempotency-Key` header is missing, empty, or longer than 255 characters (`VALIDATION_ERROR`; `error.fields` names the header).
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — The key's scopes do not cover this endpoint.
- `404` — Unknown location, or no variant with that id/SKU in the store.
- `409` — `expected_on_hand` no longer matches what is on hand; `fields` carries the actual quantity so the retry needs no extra read; or a request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`) — retry once it has finished.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.

**Example 200 response**

```json
{
  "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "sku": "TSHIRT-RED-M",
  "on_hand": 10,
  "reserved": 7,
  "available": 3,
  "low_stock_threshold": 5,
  "is_low_stock": false,
  "status": "active",
  "location": {
    "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "name": "Main Warehouse",
    "code": "WH-01"
  },
  "variant": {
    "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "sku": "TSHIRT-RED-M",
    "barcode": null,
    "name": "Red / M",
    "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "product_name": "Cotton T-shirt"
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "updated_at": "2026-08-11T10:00:00.000Z"
}
```

## List stock levels (one row per variant per location)

`GET /ext/v1/inventory/levels` · Requires scope `inventory:read`

Each row carries `on_hand`, `reserved` and the `available` difference — sell against `available`, not `on_hand`.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `number` (default `25`, max 100) | No | Page size. |
| `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). |
| `ending_before` | `string` | No | Return the page BEFORE this object id. |
| `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. |
| `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |
| `warehouse_id` | `string` | No | Only levels at this location. |
| `variant_id` | `string` | No | Only levels for this variant. |
| `low_stock` | `boolean` | No | Only levels at or below the variant's low-stock threshold — the reorder query, answered server-side so an integrator does not page the whole catalog to find twelve rows. |
| `missing_only` | `boolean` | No | Only variants that have NO stock level at `warehouse_id` yet — never stocked there, not stocked at zero. The migration question ("what is still left to set?"), answered without paging products and levels separately and diffing them. Requires `warehouse_id`; cannot be combined with `low_stock`. Rows come back in the level shape with `id`, `created_at` and `updated_at` null, zero quantities, and `status: "stock_not_set"`. Archived products are excluded. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/levels?limit=25" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/levels");
Object.entries({
  limit: "25",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/inventory/levels",
    params={
        "limit": "25",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/inventory/levels?limit=25');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
$meta = $response['meta'];
```

### Responses

- `200` — A cursor page of stock levels.
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — The key's scopes do not cover this endpoint.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.

**Example 200 response**

```json
{
  "data": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "sku": "TSHIRT-RED-M",
      "on_hand": 10,
      "reserved": 7,
      "available": 3,
      "low_stock_threshold": 5,
      "is_low_stock": false,
      "status": "active",
      "location": {
        "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "name": "Main Warehouse",
        "code": "WH-01"
      },
      "variant": {
        "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "sku": "TSHIRT-RED-M",
        "barcode": null,
        "name": "Red / M",
        "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "product_name": "Cotton T-shirt"
      },
      "created_at": "2026-08-11T10:00:00.000Z",
      "updated_at": "2026-08-11T10:00:00.000Z"
    }
  ],
  "meta": {
    "has_more": true,
    "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001"
  }
}
```

## List stock movements (the append-only ledger behind the levels)

`GET /ext/v1/inventory/movements` · Requires scope `inventory:read`

Every quantity change with before/after readings — reservations, sales, adjustments, transfers. Costs never appear here.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | `number` (default `25`, max 100) | No | Page size. |
| `starting_after` | `string` | No | Return the page AFTER this object id (the previous page's `next_cursor`). |
| `ending_before` | `string` | No | Return the page BEFORE this object id. |
| `sort` | `string` (default `-created_at`) | No | Newest first by default. Only `created_at` is sortable — a cursor over a mutable key (like `updated_at`) cannot page reliably. Use `updated_after` to sync changes. One of: `created_at`, `-created_at`. |
| `store_id` | `string` | No | Restrict to one store. Required breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |
| `warehouse_id` | `string` | No | Only movements at this location. |
| `variant_id` | `string` | No | Only movements for this variant. |

### Request

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/inventory/movements?limit=25" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/inventory/movements");
Object.entries({
  limit: "25",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/inventory/movements",
    params={
        "limit": "25",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/inventory/movements?limit=25');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
$meta = $response['meta'];
```

### Responses

- `200` — A cursor page of movements.
- `401` — Missing/invalid API key, revoked or expired key, plan without API access, or a dead store.
- `403` — The key's scopes do not cover this endpoint.
- `422` — Validation failed — `error.fields` maps each offending field to its messages.
- `429` — Rate limit exceeded for this key. Honor `Retry-After` and the `X-RateLimit-*` headers.

**Example 200 response**

```json
{
  "data": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "location_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "variant_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "type": "adjustment",
      "quantity": -2,
      "quantity_before": 12,
      "quantity_after": 10,
      "reference_type": "manual",
      "reference_id": null,
      "note": "Damaged in transit",
      "created_at": "2026-08-11T10:00:00.000Z"
    }
  ],
  "meta": {
    "has_more": true,
    "next_cursor": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001"
  }
}
```
