# Products

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).

## List products

`GET /ext/v1/products` · Requires scope `products:read`

Variants are nested in each product — they are the commercial unit. Use `updated_after` to sync only what changed.

### 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. |
| `status` | `string` | No | Filter to one of the values below. One of: `draft`, `active`, `archived`. |
| `category_id` | `string` | No | Only products in this category. |
| `updated_after` | `date-time` | No | Only products changed since this instant — the sync filter. Pair it with the default `-created_at` order and page with the cursor. |
| `search` | `string` | No | Match on product name or variant SKU. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS "https://api.salafems.com/ext/v1/products?limit=25&updated_after=2026-08-01T00%3A00%3A00Z" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/products");
Object.entries({
  limit: "25",
  updated_after: "2026-08-01T00:00:00Z",
}).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/products",
    params={
        "limit": "25",
        "updated_after": "2026-08-01T00:00:00Z",
    },
    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/products?limit=25&updated_after=2026-08-01T00%3A00%3A00Z');
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 products.
- `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",
      "name": "Classic Cotton T-Shirt",
      "slug": "classic-cotton-t-shirt",
      "short_description": null,
      "description": null,
      "thumbnail": null,
      "status": "active",
      "delivery_charge": "1250.00",
      "rating_average": "4.50",
      "rating_count": 12,
      "brand": {
        "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "name": "Acme",
        "slug": "acme"
      },
      "categories": [
        {
          "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
          "name": "T-Shirts",
          "slug": "t-shirts",
          "is_primary": true
        }
      ],
      "images": [
        {
          "url": "https://cdn.example.com/p/1.webp",
          "position": 0
        }
      ],
      "variants": [
        {
          "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
          "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
          "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
          "sku": "TSHIRT-RED-M",
          "barcode": "0123456789012",
          "name": "Red / M",
          "price": "1250.00",
          "discount_price": "1250.00",
          "weight": "0.25",
          "dimensions": null,
          "low_stock_threshold": 5,
          "status": "active",
          "image": {
            "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
            "url": "https://cdn.example.com/p/1.webp"
          },
          "created_at": "2026-08-11T10:00:00.000Z",
          "updated_at": "2026-08-11T10:00:00.000Z"
        }
      ],
      "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"
  }
}
```

## Create a product

`POST /ext/v1/products` · Requires scope `products:write`

At least one variant (a simple product has exactly one). Slugs auto-generate from the name and auto-suffix when taken; a duplicate SKU is a 409. Media, attribute wiring and initial stock are dashboard concerns — stock enters through `POST /v1/inventory/adjustments`.

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### 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. |
| `name` | `string` | Yes | — |
| `slug` | `string` | No | URL-safe slug. Auto-generated from the name when omitted; auto-suffixed when taken. |
| `brand_id` | `uuid` | No | — |
| `short_description` | `string` | No | — |
| `description` | `string` | No | — |
| `status` | `string` (default `draft`) | No | — One of: `draft`, `active`, `archived`. |
| `delivery_charge` | `number` | No | Per-product delivery-charge override: 0 = free delivery, N = flat charge; omit for the standard shipping rules. |
| `categories` | `ProductCategoryLinkDto[]` | No | — |
| `variants` | `ProductVariantWriteDto[]` | Yes | — |

### `categories[]` fields

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | — |
| `is_primary` | `boolean` (default `false`) | No | — |

### `variants[]` fields

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | No | Update only: patches this existing variant. Omit to create a new one. |
| `sku` | `string` | Yes | — |
| `barcode` | `string` | No | — |
| `name` | `string` | Yes | — |
| `price` | `number` | Yes | — |
| `discount_price` | `number` | No | Sale price — must be strictly below `price`. |
| `cost_price` | `number` | No | What the unit costs the store. Write-only (never serialized, Q6); defaults to 0 when omitted. |
| `weight` | `number` | No | — |
| `low_stock_threshold` | `number` (default `0`) | No | — |
| `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.salafems.com/ext/v1/products" \
  -H "Authorization: Bearer $SALAF_API_KEY" \
  -H "Idempotency-Key: e7a1…-your-uuid" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Classic Cotton T-Shirt",
  "status": "active",
  "variants": [
    {
      "sku": "TSHIRT-RED-M",
      "name": "Red / M",
      "price": 1250,
      "cost_price": 850
    }
  ]
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/products");

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({
    "name": "Classic Cotton T-Shirt",
    "status": "active",
    "variants": [
      {
        "sku": "TSHIRT-RED-M",
        "name": "Red / M",
        "price": 1250,
        "cost_price": 850
      }
    ]
  }),
});

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 = {
    "name": "Classic Cotton T-Shirt",
    "status": "active",
    "variants": [
        {
            "sku": "TSHIRT-RED-M",
            "name": "Red / M",
            "price": 1250,
            "cost_price": 850,
        },
    ],
}

response = requests.post(
    "https://api.salafems.com/ext/v1/products",
    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([
    'name' => 'Classic Cotton T-Shirt',
    'status' => 'active',
    'variants' => [
        [
            'sku' => 'TSHIRT-RED-M',
            'name' => 'Red / M',
            'price' => 1250,
            'cost_price' => 850,
        ],
    ],
]);

$ch = curl_init('https://api.salafems.com/ext/v1/products');
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 created product, variants nested.
- `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.
- `409` — A variant SKU is already in use in this store.
- `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",
  "name": "Classic Cotton T-Shirt",
  "slug": "classic-cotton-t-shirt",
  "short_description": null,
  "description": null,
  "thumbnail": null,
  "status": "active",
  "delivery_charge": "1250.00",
  "rating_average": "4.50",
  "rating_count": 12,
  "brand": {
    "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "name": "Acme",
    "slug": "acme"
  },
  "categories": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "name": "T-Shirts",
      "slug": "t-shirts",
      "is_primary": true
    }
  ],
  "images": [
    {
      "url": "https://cdn.example.com/p/1.webp",
      "position": 0
    }
  ],
  "variants": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "sku": "TSHIRT-RED-M",
      "barcode": "0123456789012",
      "name": "Red / M",
      "price": "1250.00",
      "discount_price": "1250.00",
      "weight": "0.25",
      "dimensions": null,
      "low_stock_threshold": 5,
      "status": "active",
      "image": {
        "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "url": "https://cdn.example.com/p/1.webp"
      },
      "created_at": "2026-08-11T10:00:00.000Z",
      "updated_at": "2026-08-11T10:00:00.000Z"
    }
  ],
  "created_at": "2026-08-11T10:00:00.000Z",
  "updated_at": "2026-08-11T10:00:00.000Z"
}
```

## Get a product by id

`GET /ext/v1/products/{id}` · Requires scope `products:read`

The full product object, variants nested.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The product id. |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID");

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 object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID",
    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']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SALAF_API_KEY');

$ch = curl_init('https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID');
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']}");
}
```

### Responses

- `200` — The product.
- `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` — Product not found.
- `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",
  "name": "Classic Cotton T-Shirt",
  "slug": "classic-cotton-t-shirt",
  "short_description": null,
  "description": null,
  "thumbnail": null,
  "status": "active",
  "delivery_charge": "1250.00",
  "rating_average": "4.50",
  "rating_count": 12,
  "brand": {
    "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "name": "Acme",
    "slug": "acme"
  },
  "categories": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "name": "T-Shirts",
      "slug": "t-shirts",
      "is_primary": true
    }
  ],
  "images": [
    {
      "url": "https://cdn.example.com/p/1.webp",
      "position": 0
    }
  ],
  "variants": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "sku": "TSHIRT-RED-M",
      "barcode": "0123456789012",
      "name": "Red / M",
      "price": "1250.00",
      "discount_price": "1250.00",
      "weight": "0.25",
      "dimensions": null,
      "low_stock_threshold": 5,
      "status": "active",
      "image": {
        "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "url": "https://cdn.example.com/p/1.webp"
      },
      "created_at": "2026-08-11T10:00:00.000Z",
      "updated_at": "2026-08-11T10:00:00.000Z"
    }
  ],
  "created_at": "2026-08-11T10:00:00.000Z",
  "updated_at": "2026-08-11T10:00:00.000Z"
}
```

## Update a product

`PATCH /ext/v1/products/{id}` · Requires scope `products:write`

Partial update of the product fields. `categories` (when present) replaces the link set; `variants` (when present) reconciles the FULL variant set — patch by id, create without one, delete the rest unless stock history forbids it. Same semantics as the dashboard.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The product id. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### 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. |
| `name` | `string` | No | — |
| `slug` | `string` | No | — |
| `brand_id` | `uuid` | No | Set null to detach the brand. |
| `short_description` | `string` | No | — |
| `description` | `string` | No | — |
| `status` | `string` | No | — One of: `draft`, `active`, `archived`. |
| `delivery_charge` | `object` | No | Set null to return to the standard shipping rules. |
| `categories` | `ProductCategoryLinkDto[]` | No | When present, REPLACES the product's category links. |
| `variants` | `ProductVariantWriteDto[]` | No | When present, reconciles the FULL variant set in one transaction — entries with an `id` are patched, entries without one are created, and existing variants missing from the list are removed (refused when they carry stock history). Same semantics as the dashboard. |

### `categories[]` fields

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | — |
| `is_primary` | `boolean` (default `false`) | No | — |

### `variants[]` fields

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | No | Update only: patches this existing variant. Omit to create a new one. |
| `sku` | `string` | Yes | — |
| `barcode` | `string` | No | — |
| `name` | `string` | Yes | — |
| `price` | `number` | Yes | — |
| `discount_price` | `number` | No | Sale price — must be strictly below `price`. |
| `cost_price` | `number` | No | What the unit costs the store. Write-only (never serialized, Q6); defaults to 0 when omitted. |
| `weight` | `number` | No | — |
| `low_stock_threshold` | `number` (default `0`) | No | — |
| `status` | `string` (default `active`) | No | — One of: `active`, `inactive`. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS -X PATCH "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID" \
  -H "Authorization: Bearer $SALAF_API_KEY" \
  -H "Idempotency-Key: e7a1…-your-uuid" \
  -H "Content-Type: application/json" \
  -d '{
  "status": "active"
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID");

const response = await fetch(url, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.SALAF_API_KEY}`,
    "Idempotency-Key": "e7a1…-your-uuid",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "status": "active"
  }),
});

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 = {
    "status": "active",
}

response = requests.patch(
    "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID",
    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([
    'status' => 'active',
]);

$ch = curl_init('https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    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

- `200` — The updated product, variants nested.
- `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` — Product not found.
- `409` — A variant SKU is already in use in this store.
- `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",
  "name": "Classic Cotton T-Shirt",
  "slug": "classic-cotton-t-shirt",
  "short_description": null,
  "description": null,
  "thumbnail": null,
  "status": "active",
  "delivery_charge": "1250.00",
  "rating_average": "4.50",
  "rating_count": 12,
  "brand": {
    "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "name": "Acme",
    "slug": "acme"
  },
  "categories": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "name": "T-Shirts",
      "slug": "t-shirts",
      "is_primary": true
    }
  ],
  "images": [
    {
      "url": "https://cdn.example.com/p/1.webp",
      "position": 0
    }
  ],
  "variants": [
    {
      "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "store_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
      "sku": "TSHIRT-RED-M",
      "barcode": "0123456789012",
      "name": "Red / M",
      "price": "1250.00",
      "discount_price": "1250.00",
      "weight": "0.25",
      "dimensions": null,
      "low_stock_threshold": 5,
      "status": "active",
      "image": {
        "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
        "url": "https://cdn.example.com/p/1.webp"
      },
      "created_at": "2026-08-11T10:00:00.000Z",
      "updated_at": "2026-08-11T10:00:00.000Z"
    }
  ],
  "created_at": "2026-08-11T10:00:00.000Z",
  "updated_at": "2026-08-11T10:00:00.000Z"
}
```

## Archive a product

`POST /ext/v1/products/{id}/archive` · Requires scope `products:write`

Non-destructive: variants, images and inventory survive with their status parked, and the dashboard can restore. Archived products drop out of `GET /v1/products`.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `uuid` | Yes | The product id. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Idempotency-Key` | `string` | No | Optional, but honored: send it to make retries of this request safe. |

### 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. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS -X POST "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive" \
  -H "Authorization: Bearer $SALAF_API_KEY" \
  -H "Idempotency-Key: e7a1…-your-uuid" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive");

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({}),
});

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 = {}

response = requests.post(
    "https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive",
    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((object) []);

$ch = curl_init('https://api.salafems.com/ext/v1/products/REPLACE_WITH_ID/archive');
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` — Archival confirmed.
- `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` — Product not found.
- `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",
  "archived": true
}
```

## Get a variant by id, SKU or barcode

`GET /ext/v1/variants/{identifier}` · Requires scope `products:read`

Answers "what is this thing in my hand?" for a scanned barcode or a SKU from a spreadsheet.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `identifier` | `string` | Yes | A variant id, SKU or barcode. All three resolve here. |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `store_id` | `uuid` | No | Restrict the lookup to one store. Breadth control for company-scoped keys; on a store-scoped key it must match the key's own store. |

### Request

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS "https://api.salafems.com/ext/v1/variants/SKU-001" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/variants/SKU-001");

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 object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.salafems.com/ext/v1/variants/SKU-001",
    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']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SALAF_API_KEY');

$ch = curl_init('https://api.salafems.com/ext/v1/variants/SKU-001');
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']}");
}
```

### Responses

- `200` — The variant.
- `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` — Variant not found.
- `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",
  "product_id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
  "sku": "TSHIRT-RED-M",
  "barcode": "0123456789012",
  "name": "Red / M",
  "price": "1250.00",
  "discount_price": "1250.00",
  "weight": "0.25",
  "dimensions": null,
  "low_stock_threshold": 5,
  "status": "active",
  "image": {
    "id": "5f7d2f60-0d1c-4b3a-9a68-6f4d21f6a001",
    "url": "https://cdn.example.com/p/1.webp"
  },
  "created_at": "2026-08-11T10:00:00.000Z",
  "updated_at": "2026-08-11T10:00:00.000Z"
}
```
