# Inventory

Part of the [Salaf Commerce API reference](https://www.salafems.com/developers/reference.md). Paths below are shown exactly as the server routes them — prefix them with `https://api.salafems.com`. Authenticate every request with `Authorization: Bearer salaf_sk_YOUR_API_KEY`; each operation names the scope its key must hold. Every failure uses the one error envelope — see [Errors](https://www.salafems.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. A `reason` is always required here; on-hand can never go below zero or below the reserved quantity; `damage` must remove stock; `purchase` requires a `unit_cost` and folds into the moving-average cost.

### 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` | Yes | The variant being adjusted. |
| `quantity_change` | `number` | Yes | 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. |
| `type` | `string` (default `adjustment`) | No | Ledger category. `purchase` adds stock at a required `unit_cost`; `damage` must remove stock; default `adjustment`. 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); otherwise the variant's current cost values the movement. 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 SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.salafems.com/ext/v1/inventory/adjustments" \
  -H "Authorization: Bearer $SALAF_API_KEY" \
  -H "Idempotency-Key: e7a1…-your-uuid" \
  -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.salafems.com/ext/v1/inventory/adjustments");

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SALAF_API_KEY}`,
    "Idempotency-Key": "e7a1…-your-uuid",
    "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.salafems.com/ext/v1/inventory/adjustments",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}",
        "Idempotency-Key": "e7a1…-your-uuid",
    },
    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('SALAF_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.salafems.com/ext/v1/inventory/adjustments');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: e7a1…-your-uuid', '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.
- `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 variant.
- `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"
  },
  "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. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

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

**JavaScript**

```javascript
const url = new URL("https://api.salafems.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.SALAF_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.salafems.com/ext/v1/inventory/levels",
    params={
        "limit": "25",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SALAF_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('SALAF_API_KEY');

$ch = curl_init('https://api.salafems.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"
      },
      "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 SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

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

**JavaScript**

```javascript
const url = new URL("https://api.salafems.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.SALAF_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.salafems.com/ext/v1/inventory/movements",
    params={
        "limit": "25",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SALAF_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('SALAF_API_KEY');

$ch = curl_init('https://api.salafems.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"
  }
}
```
