> ## Documentation Index
> Fetch the complete documentation index at: https://docs.exclusivo.one/llms.txt
> Use this file to discover all available pages before exploring further.

# Products

> List, create, read, update and delete products.

`https://exclusivo.one/api/v1` · scopes `products:read`, `products:write`

## List products

```http theme={"system"}
GET /api/v1/products
```

| Parameter    | Type    | Default |                                           |
| ------------ | ------- | ------- | ----------------------------------------- |
| `limit`      | integer | `50`    | 1–100                                     |
| `cursor`     | string  | —       | A product id from `pagination.nextCursor` |
| `collection` | string  | —       | Collection slug, max 120 chars            |
| `type`       | enum    | —       | `physical`, `digital` or `nft`            |

<CodeGroup>
  ```bash cURL theme={"system"}
  curl "https://exclusivo.one/api/v1/products?limit=20&type=physical" \
    -H "Authorization: Bearer ex_live_your_key_here"
  ```

  ```js JavaScript theme={"system"}
  const res = await fetch(
    'https://exclusivo.one/api/v1/products?limit=20&type=physical',
    { headers: { Authorization: `Bearer ${process.env.EXC_API_KEY}` } }
  );
  const { data, pagination } = await res.json();
  ```

  ```python Python theme={"system"}
  import os, requests

  r = requests.get(
      "https://exclusivo.one/api/v1/products",
      params={"limit": 20, "type": "physical"},
      headers={"Authorization": f"Bearer {os.environ['EXC_API_KEY']}"},
  )
  body = r.json()
  ```
</CodeGroup>

```json Response theme={"system"}
{
  "data": [
    {
      "id": "prod_abc123",
      "name": "Ceramic Mug",
      "slug": "ceramic-mug",
      "price": "28.00",
      "type": "physical",
      "inventory": 42,
      "trackInventory": true,
      "requiresShipping": true,
      "weightGrams": 380,
      "collections": ["homeware"],
      "image": "https://…",
      "createdAt": "2026-08-01T09:12:00.000Z"
    }
  ],
  "pagination": {
    "hasMore": true,
    "nextCursor": "prod_abc123",
    "count": 20
  }
}
```

<Warning>
  **`collection` and `type` filter the page you got, not the whole catalogue.**

  Filtering happens after the page is fetched. So a page of 50 products containing
  3 digital ones returns `count: 3` while `hasMore` is still true — and an empty
  page doesn't mean there are no more matches.

  Page until `hasMore` is false and accumulate. Never stop on a short page.
</Warning>

```js Paginating with a filter theme={"system"}
async function allProducts(params = {}) {
  const out = [];
  let cursor = null;

  do {
    const qs = new URLSearchParams({ ...params, limit: '100', ...(cursor ? { cursor } : {}) });
    const res = await fetch(`https://exclusivo.one/api/v1/products?${qs}`, {
      headers: { Authorization: `Bearer ${process.env.EXC_API_KEY}` },
    });
    const { data, pagination } = await res.json();
    out.push(...data);
    cursor = pagination.hasMore ? pagination.nextCursor : null;
  } while (cursor);   // not: while (data.length)

  return out;
}
```

## Create a product

```http theme={"system"}
POST /api/v1/products
```

Needs `products:write`.

| Field              | Type             | Required |                                                     |
| ------------------ | ---------------- | :------: | --------------------------------------------------- |
| `name`             | string           |     ✅    | 1–160 chars                                         |
| `price`            | string \| number |     ✅    |                                                     |
| `slug`             | string           |          | 1–180 chars. Generated from the name if you omit it |
| `description`      | string           |          |                                                     |
| `image`            | string           |          | Main image URL                                      |
| `moreImages`       | string\[]        |          | Up to 20                                            |
| `type`             | enum             |          | `physical`, `digital`, `nft`                        |
| `inventory`        | integer          |          | Non-negative                                        |
| `trackInventory`   | boolean          |          |                                                     |
| `requiresShipping` | boolean          |          |                                                     |
| `weightGrams`      | number           |          | Non-negative. Needed for live carrier rates         |
| `collection`       | string           |          | Max 120 chars                                       |
| `collections`      | string\[]        |          | Up to 25                                            |

```bash theme={"system"}
curl -X POST https://exclusivo.one/api/v1/products \
  -H "Authorization: Bearer ex_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ceramic Mug",
    "price": "28.00",
    "type": "physical",
    "inventory": 50,
    "trackInventory": true,
    "requiresShipping": true,
    "weightGrams": 380,
    "collections": ["homeware"]
  }'
```

<Tip>
  Always set `weightGrams` on physical products. Without it live carrier rates
  can't quote, and you'll find out at a customer's checkout rather than here.
</Tip>

## Read, update, delete

```http theme={"system"}
GET    /api/v1/products/{productId}
PUT    /api/v1/products/{productId}
DELETE /api/v1/products/{productId}
```

`GET` needs `products:read`; the others need `products:write`.

`PUT` takes the same fields as create and merges — anything you leave out stays
as it was.

## Errors

| Status |                                                         |
| ------ | ------------------------------------------------------- |
| `400`  | `Invalid query parameters` or `Invalid product payload` |
| `401`  | Missing or invalid key                                  |
| `403`  | Wrong scope                                             |
| `404`  | Not in your shop                                        |
| `429`  | Rate limited                                            |

A `400` won't tell you which field. Post `name` and `price` alone, then add the
rest back one at a time.

## Related

* [Inventory](/api/store/inventory) — adjusting stock without a full update.
* [Webhooks](/api/webhooks) — `product.created`, `product.updated`, `product.deleted`.
