# Products · Management API

> Management API reference for products: GET /sites/{id}/products, POST /sites/{id}/products, GET /sites/{id}/products/{productId}, PATCH /sites/{id}/products/{productId}, ….

> Markdown version of https://kanorio.com/en/developers/management-api/products. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

Base URL: `https://app.kanorio.com/api/v1` · [Errors](https://kanorio.com/en/developers/basics/errors) · [Pagination](https://kanorio.com/en/developers/basics/pagination)

### List products

`GET /sites/{id}/products`

- Stability: beta
- Permission: `store:view`
- Rate limit: default (120/minute per token)
- MCP tool: `products_list`

List products (ACTIVE first, then ARCHIVED; excludes sample template products by default unless includeTemplates=true) with images, variants and paid order counts; storeCurrency is the ISO 4217 code all amounts use. Errors: FORBIDDEN, INVALID_CURSOR.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `status` | query | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | no | — |
| `includeTemplates` | query | boolean \| "true" \| "false" | no | Include sample template products seeded for new stores (defaults to false). |
| `limit` | query | integer | no | Maximum number of items to return, 1–100. Defaults to 20. |
| `cursor` | query | string | no | Opaque cursor from the previous page's nextCursor. Omit for the first page. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].id` | string | yes | — |
| `data[].title` | string | yes | — |
| `data[].description` | string \| null | yes | — |
| `data[].slug` | string \| null | yes | Last URL segment of the product page. |
| `data[].status` | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | yes | DRAFT is not on the public shop. ACTIVE is published. ARCHIVED is retired. |
| `data[].seo` | object | yes | Search and share fields stored on the product. |
| `data[].seo.title` | string \| null | yes | — |
| `data[].seo.description` | string \| null | yes | — |
| `data[].seo.keywords` | string[] | yes | — |
| `data[].seo.ogTitle` | string \| null | yes | — |
| `data[].seo.ogDescription` | string \| null | yes | — |
| `data[].seo.ogImage` | string \| null | yes | Share image display URL. Changes with versions; do not store it. |
| `data[].seo.ogImageAssetId` | string \| null | yes | assetId of the share image when it is in the media library. |
| `data[].seo.canonicalUrl` | string \| null | yes | — |
| `data[].seo.robotsIndex` | "index" \| "noindex" | yes | — |
| `data[].seo.robotsFollow` | "follow" \| "nofollow" | yes | — |
| `data[].seo.excludeFromSiteSearch` | boolean | yes | Hide from visitor site search. Does not change robotsIndex. |
| `data[].type` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | yes | — |
| `data[].isTemplate` | boolean | yes | Sample product seeded for new sellers; not public. |
| `data[].images` | object[] | yes | — |
| `data[].images[].assetId` | string \| null | yes | assetId in the media library; null for older images that are not in it. |
| `data[].images[].url` | string | yes | Display URL (changes with versions; do not store it). |
| `data[].images[].alt` | string | no | — |
| `data[].images[].width` | integer | no | — |
| `data[].images[].height` | integer | no | — |
| `data[].imageFramings` | object \| null[] | no | Optional presentation framing per image (parallel to images; omitted when all images use automatic mode). |
| `data[].imageFramings[].focus` | object | no | — |
| `data[].imageFramings[].screens` | object | no | — |
| `data[].imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `data[].imageFramings[].padColor` | string | no | — |
| `data[].purchaseNotes` | string \| null | yes | — |
| `data[].allowCustomAmount` | boolean | yes | — |
| `data[].reviewsEnabled` | boolean | yes | — |
| `data[].metadata` | object | yes | Public integration fields (flat key/value, up to 50 keys, 16 KB). Visible to the storefront and Delivery API. |
| `data[].privateMetadata` | object | yes | Private integration fields; only the console, members and service accounts can read them. |
| `data[].primaryCategoryId` | string \| null | yes | — |
| `data[].options` | object[] | yes | — |
| `data[].options[].id` | string | yes | — |
| `data[].options[].name` | string | yes | — |
| `data[].options[].position` | integer | yes | — |
| `data[].options[].values` | string[] | yes | — |
| `data[].pricingTiers` | object[] | yes | Purchasable variants; manage them in the console. |
| `data[].pricingTiers[].id` | string | yes | — |
| `data[].pricingTiers[].title` | string | yes | Variant title derived from option values (or the legacy name). |
| `data[].pricingTiers[].optionValues` | string[] | yes | — |
| `data[].pricingTiers[].amount` | integer | yes | Minor units in the store currency. |
| `data[].pricingTiers[].compareAtAmount` | integer \| null | yes | — |
| `data[].pricingTiers[].isPublished` | boolean | yes | — |
| `data[].pricingTiers[].sortOrder` | integer | yes | — |
| `data[].pricingTiers[].highlightBadge` | string \| null | yes | — |
| `data[].pricingTiers[].requiresShipping` | boolean | yes | Whether buyers of this variant enter a delivery address and pay shipping. Variants without their own setting follow productType, so one product can mix a course-only variant and a course + printed book variant. Set per variant in the console. |
| `data[].pricingTiers[].accessDays` | integer \| null | yes | Days buyers of this variant can access every deliverable it grants, counted from payment; null = permanent. One product can sell a 3-month variant and a lifetime variant. Set it with products.update({ pricingTiers: [{ id, accessDays }] }). |
| `data[].deliverables` | object[] | yes | Deliverables granted on purchase (any product type). tierId null = every variant. How long buyers keep access is pricingTiers[].accessDays. Manage with products.update({ deliverables }). |
| `data[].deliverables[].deliverableId` | string | yes | — |
| `data[].deliverables[].tierId` | string \| null | yes | — |
| `data[].orderCount` | integer | no | Paid orders (list only). |
| `data[].createdAt` | string | yes | — |
| `data[].updatedAt` | string | yes | — |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |
| `storeCurrency` | string | yes | Lowercase ISO 4217, e.g. twd, usd. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `INVALID_CURSOR` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/products" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "id": "prod_example01",
      "title": "Strawberry Birthday Cake",
      "description": "Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.",
      "slug": "strawberry-birthday-cake",
      "status": "ACTIVE",
      "seo": {
        "title": "Strawberry Birthday Cake",
        "description": "Seasonal strawberries and fresh whipped cream.",
        "keywords": [
          "cake",
          "birthday"
        ],
        "ogTitle": null,
        "ogDescription": null,
        "ogImage": null,
        "ogImageAssetId": null,
        "excludeFromSiteSearch": false,
        "canonicalUrl": null,
        "robotsIndex": "index",
        "robotsFollow": "follow"
      },
      "type": "PHYSICAL",
      "isTemplate": false,
      "images": [
        {
          "assetId": "asset_example01",
          "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
          "alt": "Strawberry birthday cake",
          "width": 2400,
          "height": 1600
        }
      ],
      "purchaseNotes": "Please leave your preferred pickup time in the order note.",
      "metadata": {},
      "privateMetadata": {},
      "allowCustomAmount": false,
      "reviewsEnabled": true,
      "primaryCategoryId": "pcat_example01",
      "options": [
        {
          "id": "opt_example01",
          "name": "Size",
          "position": 0,
          "values": [
            "6-inch",
            "8-inch"
          ]
        }
      ],
      "pricingTiers": [
        {
          "id": "tier_example01",
          "title": "6-inch",
          "optionValues": [
            "6-inch"
          ],
          "amount": 880,
          "compareAtAmount": null,
          "isPublished": true,
          "sortOrder": 0,
          "highlightBadge": null,
          "requiresShipping": true,
          "accessDays": null
        },
        {
          "id": "tier_example02",
          "title": "8-inch",
          "optionValues": [
            "8-inch"
          ],
          "amount": 1280,
          "compareAtAmount": null,
          "isPublished": true,
          "sortOrder": 1,
          "highlightBadge": null,
          "requiresShipping": true,
          "accessDays": null
        }
      ],
      "deliverables": [],
      "orderCount": 18,
      "createdAt": "2026-08-20T06:00:00.000Z",
      "updatedAt": "2026-10-06T08:00:00.000Z"
    }
  ],
  "storeCurrency": "TWD",
  "hasMore": false,
  "nextCursor": null
}
```

### Create a product

`POST /sites/{id}/products`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `products_create`

Create a DRAFT product. It stays off the public shop until products.publish. Add variants and prices in the console; a product with no published variant still cannot be purchased after it is published. detailMarkdown and faq are optional and render on the product detail page. Images and ogImageAssetId are media-library assets: pass their assetId (upload with media.upload or media.import_stock first). Slug is generated from the title when omitted. SEO fields are optional and only the ones you send are stored. productType only decides whether the product is shipped; link deliverables (courses, downloads) afterwards with products.update { deliverables } on either type. Errors: FORBIDDEN, VALIDATION (details.reason), SITE_SUSPENDED, ASSET_NOT_FOUND (details.assetIds: not in this site's media library, in the trash, or its file is missing), ASSET_NOT_ALLOWED (not an image, or a product file).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | yes | — |
| `description` | string | no | Plain text description shown on the store card, checkout and product page. Optional; omit or send an empty string for none. Markdown markers are removed. |
| `images` | object[] | yes | Up to 10; the first is the cover image. May be empty: the product page then renders without a gallery and listings show a placeholder. |
| `images[].assetId` | string | yes | assetId of an image in the media library (media.upload, media.import_stock, media.list). |
| `images[].alt` | string | no | — |
| `imageFramings` | object \| null[] | no | Optional framing per image (parallel to images; omit or pass null for automatic presentation). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | no | — |
| `allowCustomAmount` | boolean | no | — |
| `reviewsEnabled` | boolean | no | — |
| `slug` | string | no | — |
| `productType` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | no | Only about shipping, and only the default: DIGITAL (default) has nothing to ship, PHYSICAL is shipped and needs delivery settings. Each variant can override it (pricingTiers[].requiresShipping, set in the console). Either type can grant deliverables on purchase (products.update { deliverables }). |
| `detailMarkdown` | string \| null | no | Markdown shown under the product images on the detail page. Null or an empty string clears it. Omit on update to leave it unchanged. |
| `faq` | object[] | no | On update, replaces the whole FAQ list. Omit to leave it unchanged. Pass [] to clear. |
| `faq[].id` | string | no | — |
| `faq[].question` | string | yes | — |
| `faq[].answer` | string | yes | Markdown subset, same as the FAQ module. |
| `metadata` | object \| null | no | Replace all public integration fields (flat key/value, up to 50 keys, 16 KB); null clears them. |
| `privateMetadata` | object \| null | no | Replace all private integration fields; null clears them. |
| `seoTitle` | string \| null | no | Search title. Null or empty clears it. |
| `seoDescription` | string \| null | no | — |
| `seoKeywords` | string[] \| null | no | Null or [] clears keywords. |
| `ogImageAssetId` | string \| null | no | Share image assetId. Null removes it. Without one, an image may be generated automatically. |
| `ogImageAlt` | string \| null | no | — |
| `ogImageFraming` | object \| null | no | — |
| `ogImageFraming.focus` | object | no | — |
| `ogImageFraming.focus.x` | number | yes | — |
| `ogImageFraming.focus.y` | number | yes | — |
| `ogImageFraming.screens` | object | no | — |
| `ogImageFraming.screens.desktop` | object | no | — |
| `ogImageFraming.screens.tablet` | object | no | — |
| `ogImageFraming.screens.mobile` | object | no | — |
| `ogImageFraming.fitMode` | "contain" \| "cover" | no | — |
| `ogImageFraming.padColor` | string | no | — |
| `ogTitle` | string \| null | no | — |
| `ogDescription` | string \| null | no | — |
| `canonicalUrl` | string \| null | no | — |
| `robotsIndex` | "index" \| "noindex" | no | — |
| `robotsFollow` | "follow" \| "nofollow" | no | — |
| `twitterCard` | "summary" \| "summary_large_image" | no | — |
| `autoOgEnabled` | boolean | no | — |
| `excludeFromSiteSearch` | boolean | no | Hide from visitor site search. Does not change robotsIndex. |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `title` | string | yes | — |
| `description` | string \| null | yes | — |
| `slug` | string \| null | yes | Last URL segment of the product page. |
| `status` | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | yes | DRAFT is not on the public shop. ACTIVE is published. ARCHIVED is retired. |
| `seo` | object | yes | Search and share fields stored on the product. |
| `seo.title` | string \| null | yes | — |
| `seo.description` | string \| null | yes | — |
| `seo.keywords` | string[] | yes | — |
| `seo.ogTitle` | string \| null | yes | — |
| `seo.ogDescription` | string \| null | yes | — |
| `seo.ogImage` | string \| null | yes | Share image display URL. Changes with versions; do not store it. |
| `seo.ogImageAssetId` | string \| null | yes | assetId of the share image when it is in the media library. |
| `seo.canonicalUrl` | string \| null | yes | — |
| `seo.robotsIndex` | "index" \| "noindex" | yes | — |
| `seo.robotsFollow` | "follow" \| "nofollow" | yes | — |
| `seo.excludeFromSiteSearch` | boolean | yes | Hide from visitor site search. Does not change robotsIndex. |
| `type` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | yes | — |
| `isTemplate` | boolean | yes | Sample product seeded for new sellers; not public. |
| `images` | object[] | yes | — |
| `images[].assetId` | string \| null | yes | assetId in the media library; null for older images that are not in it. |
| `images[].url` | string | yes | Display URL (changes with versions; do not store it). |
| `images[].alt` | string | no | — |
| `images[].width` | integer | no | — |
| `images[].height` | integer | no | — |
| `imageFramings` | object \| null[] | no | Optional presentation framing per image (parallel to images; omitted when all images use automatic mode). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | yes | — |
| `allowCustomAmount` | boolean | yes | — |
| `reviewsEnabled` | boolean | yes | — |
| `metadata` | object | yes | Public integration fields (flat key/value, up to 50 keys, 16 KB). Visible to the storefront and Delivery API. |
| `privateMetadata` | object | yes | Private integration fields; only the console, members and service accounts can read them. |
| `primaryCategoryId` | string \| null | yes | — |
| `options` | object[] | yes | — |
| `options[].id` | string | yes | — |
| `options[].name` | string | yes | — |
| `options[].position` | integer | yes | — |
| `options[].values` | string[] | yes | — |
| `pricingTiers` | object[] | yes | Purchasable variants; manage them in the console. |
| `pricingTiers[].id` | string | yes | — |
| `pricingTiers[].title` | string | yes | Variant title derived from option values (or the legacy name). |
| `pricingTiers[].optionValues` | string[] | yes | — |
| `pricingTiers[].amount` | integer | yes | Minor units in the store currency. |
| `pricingTiers[].compareAtAmount` | integer \| null | yes | — |
| `pricingTiers[].isPublished` | boolean | yes | — |
| `pricingTiers[].sortOrder` | integer | yes | — |
| `pricingTiers[].highlightBadge` | string \| null | yes | — |
| `pricingTiers[].requiresShipping` | boolean | yes | Whether buyers of this variant enter a delivery address and pay shipping. Variants without their own setting follow productType, so one product can mix a course-only variant and a course + printed book variant. Set per variant in the console. |
| `pricingTiers[].accessDays` | integer \| null | yes | Days buyers of this variant can access every deliverable it grants, counted from payment; null = permanent. One product can sell a 3-month variant and a lifetime variant. Set it with products.update({ pricingTiers: [{ id, accessDays }] }). |
| `deliverables` | object[] | yes | Deliverables granted on purchase (any product type). tierId null = every variant. How long buyers keep access is pricingTiers[].accessDays. Manage with products.update({ deliverables }). |
| `deliverables[].deliverableId` | string | yes | — |
| `deliverables[].tierId` | string \| null | yes | — |
| `orderCount` | integer | no | Paid orders (list only). |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | details.reason |
| `SITE_SUSPENDED` | 403 | — |
| `ASSET_NOT_FOUND` | 404 | details.assetIds: not in this site's media library, in the trash, or its file is missing |
| `ASSET_NOT_ALLOWED` | 422 | not an image, or a product file |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/products" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Strawberry Birthday Cake","productType":"PHYSICAL","description":"Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.","images":[{"assetId":"asset_example01","alt":"Strawberry birthday cake"}]}'
```

```json
{
  "id": "prod_example01",
  "title": "Strawberry Birthday Cake",
  "description": "Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.",
  "slug": "strawberry-birthday-cake",
  "status": "ACTIVE",
  "seo": {
    "title": "Strawberry Birthday Cake",
    "description": "Seasonal strawberries and fresh whipped cream.",
    "keywords": [
      "cake",
      "birthday"
    ],
    "ogTitle": null,
    "ogDescription": null,
    "ogImage": null,
    "ogImageAssetId": null,
    "excludeFromSiteSearch": false,
    "canonicalUrl": null,
    "robotsIndex": "index",
    "robotsFollow": "follow"
  },
  "type": "PHYSICAL",
  "isTemplate": false,
  "images": [
    {
      "assetId": "asset_example01",
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "alt": "Strawberry birthday cake",
      "width": 2400,
      "height": 1600
    }
  ],
  "purchaseNotes": "Please leave your preferred pickup time in the order note.",
  "metadata": {},
  "privateMetadata": {},
  "allowCustomAmount": false,
  "reviewsEnabled": true,
  "primaryCategoryId": "pcat_example01",
  "options": [
    {
      "id": "opt_example01",
      "name": "Size",
      "position": 0,
      "values": [
        "6-inch",
        "8-inch"
      ]
    }
  ],
  "pricingTiers": [
    {
      "id": "tier_example01",
      "title": "6-inch",
      "optionValues": [
        "6-inch"
      ],
      "amount": 880,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 0,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    },
    {
      "id": "tier_example02",
      "title": "8-inch",
      "optionValues": [
        "8-inch"
      ],
      "amount": 1280,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 1,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    }
  ],
  "deliverables": [],
  "orderCount": 18,
  "createdAt": "2026-08-20T06:00:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Retrieve a product

`GET /sites/{id}/products/{productId}`

- Stability: beta
- Permission: `store:view`
- Rate limit: default (120/minute per token)
- MCP tool: `products_get`

Get one product with images, options and variants. Errors: FORBIDDEN, PRODUCT_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `productId` | path | string | yes | Product id. Get it from products.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `title` | string | yes | — |
| `description` | string \| null | yes | — |
| `slug` | string \| null | yes | Last URL segment of the product page. |
| `status` | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | yes | DRAFT is not on the public shop. ACTIVE is published. ARCHIVED is retired. |
| `seo` | object | yes | Search and share fields stored on the product. |
| `seo.title` | string \| null | yes | — |
| `seo.description` | string \| null | yes | — |
| `seo.keywords` | string[] | yes | — |
| `seo.ogTitle` | string \| null | yes | — |
| `seo.ogDescription` | string \| null | yes | — |
| `seo.ogImage` | string \| null | yes | Share image display URL. Changes with versions; do not store it. |
| `seo.ogImageAssetId` | string \| null | yes | assetId of the share image when it is in the media library. |
| `seo.canonicalUrl` | string \| null | yes | — |
| `seo.robotsIndex` | "index" \| "noindex" | yes | — |
| `seo.robotsFollow` | "follow" \| "nofollow" | yes | — |
| `seo.excludeFromSiteSearch` | boolean | yes | Hide from visitor site search. Does not change robotsIndex. |
| `type` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | yes | — |
| `isTemplate` | boolean | yes | Sample product seeded for new sellers; not public. |
| `images` | object[] | yes | — |
| `images[].assetId` | string \| null | yes | assetId in the media library; null for older images that are not in it. |
| `images[].url` | string | yes | Display URL (changes with versions; do not store it). |
| `images[].alt` | string | no | — |
| `images[].width` | integer | no | — |
| `images[].height` | integer | no | — |
| `imageFramings` | object \| null[] | no | Optional presentation framing per image (parallel to images; omitted when all images use automatic mode). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | yes | — |
| `allowCustomAmount` | boolean | yes | — |
| `reviewsEnabled` | boolean | yes | — |
| `metadata` | object | yes | Public integration fields (flat key/value, up to 50 keys, 16 KB). Visible to the storefront and Delivery API. |
| `privateMetadata` | object | yes | Private integration fields; only the console, members and service accounts can read them. |
| `primaryCategoryId` | string \| null | yes | — |
| `options` | object[] | yes | — |
| `options[].id` | string | yes | — |
| `options[].name` | string | yes | — |
| `options[].position` | integer | yes | — |
| `options[].values` | string[] | yes | — |
| `pricingTiers` | object[] | yes | Purchasable variants; manage them in the console. |
| `pricingTiers[].id` | string | yes | — |
| `pricingTiers[].title` | string | yes | Variant title derived from option values (or the legacy name). |
| `pricingTiers[].optionValues` | string[] | yes | — |
| `pricingTiers[].amount` | integer | yes | Minor units in the store currency. |
| `pricingTiers[].compareAtAmount` | integer \| null | yes | — |
| `pricingTiers[].isPublished` | boolean | yes | — |
| `pricingTiers[].sortOrder` | integer | yes | — |
| `pricingTiers[].highlightBadge` | string \| null | yes | — |
| `pricingTiers[].requiresShipping` | boolean | yes | Whether buyers of this variant enter a delivery address and pay shipping. Variants without their own setting follow productType, so one product can mix a course-only variant and a course + printed book variant. Set per variant in the console. |
| `pricingTiers[].accessDays` | integer \| null | yes | Days buyers of this variant can access every deliverable it grants, counted from payment; null = permanent. One product can sell a 3-month variant and a lifetime variant. Set it with products.update({ pricingTiers: [{ id, accessDays }] }). |
| `deliverables` | object[] | yes | Deliverables granted on purchase (any product type). tierId null = every variant. How long buyers keep access is pricingTiers[].accessDays. Manage with products.update({ deliverables }). |
| `deliverables[].deliverableId` | string | yes | — |
| `deliverables[].tierId` | string \| null | yes | — |
| `orderCount` | integer | no | Paid orders (list only). |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `PRODUCT_NOT_FOUND` | 404 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/products/{productId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "id": "prod_example01",
  "title": "Strawberry Birthday Cake",
  "description": "Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.",
  "slug": "strawberry-birthday-cake",
  "status": "ACTIVE",
  "seo": {
    "title": "Strawberry Birthday Cake",
    "description": "Seasonal strawberries and fresh whipped cream.",
    "keywords": [
      "cake",
      "birthday"
    ],
    "ogTitle": null,
    "ogDescription": null,
    "ogImage": null,
    "ogImageAssetId": null,
    "excludeFromSiteSearch": false,
    "canonicalUrl": null,
    "robotsIndex": "index",
    "robotsFollow": "follow"
  },
  "type": "PHYSICAL",
  "isTemplate": false,
  "images": [
    {
      "assetId": "asset_example01",
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "alt": "Strawberry birthday cake",
      "width": 2400,
      "height": 1600
    }
  ],
  "purchaseNotes": "Please leave your preferred pickup time in the order note.",
  "metadata": {},
  "privateMetadata": {},
  "allowCustomAmount": false,
  "reviewsEnabled": true,
  "primaryCategoryId": "pcat_example01",
  "options": [
    {
      "id": "opt_example01",
      "name": "Size",
      "position": 0,
      "values": [
        "6-inch",
        "8-inch"
      ]
    }
  ],
  "pricingTiers": [
    {
      "id": "tier_example01",
      "title": "6-inch",
      "optionValues": [
        "6-inch"
      ],
      "amount": 880,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 0,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    },
    {
      "id": "tier_example02",
      "title": "8-inch",
      "optionValues": [
        "8-inch"
      ],
      "amount": 1280,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 1,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    }
  ],
  "deliverables": [],
  "orderCount": 18,
  "createdAt": "2026-08-20T06:00:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Update a product

`PATCH /sites/{id}/products/{productId}`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `products_update`

Partially update a product (only provided fields change; images replace the whole list). SEO fields are the same: omitted fields stay as they are, and null clears a text or share image. Publishing is products.publish: a DRAFT product stays off the shop. Changes to an ACTIVE product appear on the public store immediately. Errors: FORBIDDEN, PRODUCT_NOT_FOUND, VALIDATION (details.reason), SITE_SUSPENDED, ASSET_NOT_FOUND (details.assetIds: not in this site's media library, in the trash, or its file is missing), ASSET_NOT_ALLOWED (not an image, or a product file).

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `productId` | path | string | yes | Product id. Get it from products.list. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | string | no | — |
| `description` | string | no | Plain text description shown on the store card, checkout and product page. Optional; omit or send an empty string for none. Markdown markers are removed. |
| `images` | object[] | no | Up to 10; the first is the cover image. May be empty: the product page then renders without a gallery and listings show a placeholder. |
| `images[].assetId` | string | yes | assetId of an image in the media library (media.upload, media.import_stock, media.list). |
| `images[].alt` | string | no | — |
| `imageFramings` | object \| null[] | no | Optional framing per image (parallel to images; omit or pass null for automatic presentation). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | no | — |
| `allowCustomAmount` | boolean | no | — |
| `reviewsEnabled` | boolean | no | — |
| `slug` | string | no | — |
| `productType` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | no | Default shipping for variants without their own setting. Changing it affects future checkouts only; orders keep what was decided at checkout. Omit to leave it unchanged. |
| `detailMarkdown` | string \| null | no | Markdown shown under the product images on the detail page. Null or an empty string clears it. Omit on update to leave it unchanged. |
| `faq` | object[] | no | On update, replaces the whole FAQ list. Omit to leave it unchanged. Pass [] to clear. |
| `faq[].id` | string | no | — |
| `faq[].question` | string | yes | — |
| `faq[].answer` | string | yes | Markdown subset, same as the FAQ module. |
| `metadata` | object \| null | no | Replace all public integration fields (flat key/value, up to 50 keys, 16 KB); null clears them. |
| `privateMetadata` | object \| null | no | Replace all private integration fields; null clears them. |
| `seoTitle` | string \| null | no | Search title. Null or empty clears it. |
| `seoDescription` | string \| null | no | — |
| `seoKeywords` | string[] \| null | no | Null or [] clears keywords. |
| `ogImageAssetId` | string \| null | no | Share image assetId. Null removes it. Without one, an image may be generated automatically. |
| `ogImageAlt` | string \| null | no | — |
| `ogImageFraming` | object \| null | no | — |
| `ogImageFraming.focus` | object | no | — |
| `ogImageFraming.focus.x` | number | yes | — |
| `ogImageFraming.focus.y` | number | yes | — |
| `ogImageFraming.screens` | object | no | — |
| `ogImageFraming.screens.desktop` | object | no | — |
| `ogImageFraming.screens.tablet` | object | no | — |
| `ogImageFraming.screens.mobile` | object | no | — |
| `ogImageFraming.fitMode` | "contain" \| "cover" | no | — |
| `ogImageFraming.padColor` | string | no | — |
| `ogTitle` | string \| null | no | — |
| `ogDescription` | string \| null | no | — |
| `canonicalUrl` | string \| null | no | — |
| `robotsIndex` | "index" \| "noindex" | no | — |
| `robotsFollow` | "follow" \| "nofollow" | no | — |
| `twitterCard` | "summary" \| "summary_large_image" | no | — |
| `autoOgEnabled` | boolean | no | — |
| `excludeFromSiteSearch` | boolean | no | Hide from visitor site search. Does not change robotsIndex. |
| `deliverables` | object[] | no | Replace the whole list of deliverables granted on purchase. Create deliverables first with deliverables.create. Pass [] to unlink all. Access duration is not set here: use pricingTiers[].accessDays. |
| `deliverables[].deliverableId` | string | yes | — |
| `deliverables[].tierId` | string \| null | no | — |
| `deliverables[].accessDays` | any | no | — |
| `pricingTiers` | object[] | no | Set access duration on existing variants by id: [{ id, accessDays }], accessDays null = permanent. It applies to every deliverable the variant grants. Only accessDays can be changed here; other variant fields are managed in the console. Variants not listed are left unchanged. |
| `pricingTiers[].id` | string | yes | — |
| `pricingTiers[].accessDays` | integer \| null | yes | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `title` | string | yes | — |
| `description` | string \| null | yes | — |
| `slug` | string \| null | yes | Last URL segment of the product page. |
| `status` | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | yes | DRAFT is not on the public shop. ACTIVE is published. ARCHIVED is retired. |
| `seo` | object | yes | Search and share fields stored on the product. |
| `seo.title` | string \| null | yes | — |
| `seo.description` | string \| null | yes | — |
| `seo.keywords` | string[] | yes | — |
| `seo.ogTitle` | string \| null | yes | — |
| `seo.ogDescription` | string \| null | yes | — |
| `seo.ogImage` | string \| null | yes | Share image display URL. Changes with versions; do not store it. |
| `seo.ogImageAssetId` | string \| null | yes | assetId of the share image when it is in the media library. |
| `seo.canonicalUrl` | string \| null | yes | — |
| `seo.robotsIndex` | "index" \| "noindex" | yes | — |
| `seo.robotsFollow` | "follow" \| "nofollow" | yes | — |
| `seo.excludeFromSiteSearch` | boolean | yes | Hide from visitor site search. Does not change robotsIndex. |
| `type` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | yes | — |
| `isTemplate` | boolean | yes | Sample product seeded for new sellers; not public. |
| `images` | object[] | yes | — |
| `images[].assetId` | string \| null | yes | assetId in the media library; null for older images that are not in it. |
| `images[].url` | string | yes | Display URL (changes with versions; do not store it). |
| `images[].alt` | string | no | — |
| `images[].width` | integer | no | — |
| `images[].height` | integer | no | — |
| `imageFramings` | object \| null[] | no | Optional presentation framing per image (parallel to images; omitted when all images use automatic mode). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | yes | — |
| `allowCustomAmount` | boolean | yes | — |
| `reviewsEnabled` | boolean | yes | — |
| `metadata` | object | yes | Public integration fields (flat key/value, up to 50 keys, 16 KB). Visible to the storefront and Delivery API. |
| `privateMetadata` | object | yes | Private integration fields; only the console, members and service accounts can read them. |
| `primaryCategoryId` | string \| null | yes | — |
| `options` | object[] | yes | — |
| `options[].id` | string | yes | — |
| `options[].name` | string | yes | — |
| `options[].position` | integer | yes | — |
| `options[].values` | string[] | yes | — |
| `pricingTiers` | object[] | yes | Purchasable variants; manage them in the console. |
| `pricingTiers[].id` | string | yes | — |
| `pricingTiers[].title` | string | yes | Variant title derived from option values (or the legacy name). |
| `pricingTiers[].optionValues` | string[] | yes | — |
| `pricingTiers[].amount` | integer | yes | Minor units in the store currency. |
| `pricingTiers[].compareAtAmount` | integer \| null | yes | — |
| `pricingTiers[].isPublished` | boolean | yes | — |
| `pricingTiers[].sortOrder` | integer | yes | — |
| `pricingTiers[].highlightBadge` | string \| null | yes | — |
| `pricingTiers[].requiresShipping` | boolean | yes | Whether buyers of this variant enter a delivery address and pay shipping. Variants without their own setting follow productType, so one product can mix a course-only variant and a course + printed book variant. Set per variant in the console. |
| `pricingTiers[].accessDays` | integer \| null | yes | Days buyers of this variant can access every deliverable it grants, counted from payment; null = permanent. One product can sell a 3-month variant and a lifetime variant. Set it with products.update({ pricingTiers: [{ id, accessDays }] }). |
| `deliverables` | object[] | yes | Deliverables granted on purchase (any product type). tierId null = every variant. How long buyers keep access is pricingTiers[].accessDays. Manage with products.update({ deliverables }). |
| `deliverables[].deliverableId` | string | yes | — |
| `deliverables[].tierId` | string \| null | yes | — |
| `orderCount` | integer | no | Paid orders (list only). |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `PRODUCT_NOT_FOUND` | 404 | — |
| `VALIDATION` | 400 | details.reason |
| `SITE_SUSPENDED` | 403 | — |
| `ASSET_NOT_FOUND` | 404 | details.assetIds: not in this site's media library, in the trash, or its file is missing |
| `ASSET_NOT_ALLOWED` | 422 | not an image, or a product file |

```bash
curl -X PATCH "https://app.kanorio.com/api/v1/sites/{id}/products/{productId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"purchaseNotes":"Please leave your preferred pickup time in the order note."}'
```

```json
{
  "id": "prod_example01",
  "title": "Strawberry Birthday Cake",
  "description": "Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.",
  "slug": "strawberry-birthday-cake",
  "status": "ACTIVE",
  "seo": {
    "title": "Strawberry Birthday Cake",
    "description": "Seasonal strawberries and fresh whipped cream.",
    "keywords": [
      "cake",
      "birthday"
    ],
    "ogTitle": null,
    "ogDescription": null,
    "ogImage": null,
    "ogImageAssetId": null,
    "excludeFromSiteSearch": false,
    "canonicalUrl": null,
    "robotsIndex": "index",
    "robotsFollow": "follow"
  },
  "type": "PHYSICAL",
  "isTemplate": false,
  "images": [
    {
      "assetId": "asset_example01",
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "alt": "Strawberry birthday cake",
      "width": 2400,
      "height": 1600
    }
  ],
  "purchaseNotes": "Please leave your preferred pickup time in the order note.",
  "metadata": {},
  "privateMetadata": {},
  "allowCustomAmount": false,
  "reviewsEnabled": true,
  "primaryCategoryId": "pcat_example01",
  "options": [
    {
      "id": "opt_example01",
      "name": "Size",
      "position": 0,
      "values": [
        "6-inch",
        "8-inch"
      ]
    }
  ],
  "pricingTiers": [
    {
      "id": "tier_example01",
      "title": "6-inch",
      "optionValues": [
        "6-inch"
      ],
      "amount": 880,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 0,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    },
    {
      "id": "tier_example02",
      "title": "8-inch",
      "optionValues": [
        "8-inch"
      ],
      "amount": 1280,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 1,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    }
  ],
  "deliverables": [],
  "orderCount": 18,
  "createdAt": "2026-08-20T06:00:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Publish a product

`POST /sites/{id}/products/{productId}/publish`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: publish (30/hour per token)
- MCP tool: `products_publish`

Publish a product (status ACTIVE) so it appears on the public shop and its detail page. Sample products and archived products cannot be published. Idempotent when already ACTIVE. Errors: FORBIDDEN, PRODUCT_NOT_FOUND, PRODUCT_NOT_LISTABLE, SITE_SUSPENDED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `productId` | path | string | yes | Product id. Get it from products.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `title` | string | yes | — |
| `description` | string \| null | yes | — |
| `slug` | string \| null | yes | Last URL segment of the product page. |
| `status` | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | yes | DRAFT is not on the public shop. ACTIVE is published. ARCHIVED is retired. |
| `seo` | object | yes | Search and share fields stored on the product. |
| `seo.title` | string \| null | yes | — |
| `seo.description` | string \| null | yes | — |
| `seo.keywords` | string[] | yes | — |
| `seo.ogTitle` | string \| null | yes | — |
| `seo.ogDescription` | string \| null | yes | — |
| `seo.ogImage` | string \| null | yes | Share image display URL. Changes with versions; do not store it. |
| `seo.ogImageAssetId` | string \| null | yes | assetId of the share image when it is in the media library. |
| `seo.canonicalUrl` | string \| null | yes | — |
| `seo.robotsIndex` | "index" \| "noindex" | yes | — |
| `seo.robotsFollow` | "follow" \| "nofollow" | yes | — |
| `seo.excludeFromSiteSearch` | boolean | yes | Hide from visitor site search. Does not change robotsIndex. |
| `type` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | yes | — |
| `isTemplate` | boolean | yes | Sample product seeded for new sellers; not public. |
| `images` | object[] | yes | — |
| `images[].assetId` | string \| null | yes | assetId in the media library; null for older images that are not in it. |
| `images[].url` | string | yes | Display URL (changes with versions; do not store it). |
| `images[].alt` | string | no | — |
| `images[].width` | integer | no | — |
| `images[].height` | integer | no | — |
| `imageFramings` | object \| null[] | no | Optional presentation framing per image (parallel to images; omitted when all images use automatic mode). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | yes | — |
| `allowCustomAmount` | boolean | yes | — |
| `reviewsEnabled` | boolean | yes | — |
| `metadata` | object | yes | Public integration fields (flat key/value, up to 50 keys, 16 KB). Visible to the storefront and Delivery API. |
| `privateMetadata` | object | yes | Private integration fields; only the console, members and service accounts can read them. |
| `primaryCategoryId` | string \| null | yes | — |
| `options` | object[] | yes | — |
| `options[].id` | string | yes | — |
| `options[].name` | string | yes | — |
| `options[].position` | integer | yes | — |
| `options[].values` | string[] | yes | — |
| `pricingTiers` | object[] | yes | Purchasable variants; manage them in the console. |
| `pricingTiers[].id` | string | yes | — |
| `pricingTiers[].title` | string | yes | Variant title derived from option values (or the legacy name). |
| `pricingTiers[].optionValues` | string[] | yes | — |
| `pricingTiers[].amount` | integer | yes | Minor units in the store currency. |
| `pricingTiers[].compareAtAmount` | integer \| null | yes | — |
| `pricingTiers[].isPublished` | boolean | yes | — |
| `pricingTiers[].sortOrder` | integer | yes | — |
| `pricingTiers[].highlightBadge` | string \| null | yes | — |
| `pricingTiers[].requiresShipping` | boolean | yes | Whether buyers of this variant enter a delivery address and pay shipping. Variants without their own setting follow productType, so one product can mix a course-only variant and a course + printed book variant. Set per variant in the console. |
| `pricingTiers[].accessDays` | integer \| null | yes | Days buyers of this variant can access every deliverable it grants, counted from payment; null = permanent. One product can sell a 3-month variant and a lifetime variant. Set it with products.update({ pricingTiers: [{ id, accessDays }] }). |
| `deliverables` | object[] | yes | Deliverables granted on purchase (any product type). tierId null = every variant. How long buyers keep access is pricingTiers[].accessDays. Manage with products.update({ deliverables }). |
| `deliverables[].deliverableId` | string | yes | — |
| `deliverables[].tierId` | string \| null | yes | — |
| `orderCount` | integer | no | Paid orders (list only). |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `PRODUCT_NOT_FOUND` | 404 | — |
| `PRODUCT_NOT_LISTABLE` | 422 | — |
| `SITE_SUSPENDED` | 403 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/products/{productId}/publish" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "id": "prod_example01",
  "title": "Strawberry Birthday Cake",
  "description": "Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.",
  "slug": "strawberry-birthday-cake",
  "status": "ACTIVE",
  "seo": {
    "title": "Strawberry Birthday Cake",
    "description": "Seasonal strawberries and fresh whipped cream.",
    "keywords": [
      "cake",
      "birthday"
    ],
    "ogTitle": null,
    "ogDescription": null,
    "ogImage": null,
    "ogImageAssetId": null,
    "excludeFromSiteSearch": false,
    "canonicalUrl": null,
    "robotsIndex": "index",
    "robotsFollow": "follow"
  },
  "type": "PHYSICAL",
  "isTemplate": false,
  "images": [
    {
      "assetId": "asset_example01",
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "alt": "Strawberry birthday cake",
      "width": 2400,
      "height": 1600
    }
  ],
  "purchaseNotes": "Please leave your preferred pickup time in the order note.",
  "metadata": {},
  "privateMetadata": {},
  "allowCustomAmount": false,
  "reviewsEnabled": true,
  "primaryCategoryId": "pcat_example01",
  "options": [
    {
      "id": "opt_example01",
      "name": "Size",
      "position": 0,
      "values": [
        "6-inch",
        "8-inch"
      ]
    }
  ],
  "pricingTiers": [
    {
      "id": "tier_example01",
      "title": "6-inch",
      "optionValues": [
        "6-inch"
      ],
      "amount": 880,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 0,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    },
    {
      "id": "tier_example02",
      "title": "8-inch",
      "optionValues": [
        "8-inch"
      ],
      "amount": 1280,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 1,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    }
  ],
  "deliverables": [],
  "orderCount": 18,
  "createdAt": "2026-08-20T06:00:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Unpublish a product

`POST /sites/{id}/products/{productId}/unpublish`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: publish (30/hour per token)
- MCP tool: `products_unpublish`

Unpublish a product (status DRAFT). It leaves the public shop and its detail page returns not found. Sample products and archived products cannot be unpublished; restore an archived product in the console first. Idempotent when already DRAFT. Errors: FORBIDDEN, PRODUCT_NOT_FOUND, PRODUCT_NOT_LISTABLE, SITE_SUSPENDED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `productId` | path | string | yes | Product id. Get it from products.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `title` | string | yes | — |
| `description` | string \| null | yes | — |
| `slug` | string \| null | yes | Last URL segment of the product page. |
| `status` | "DRAFT" \| "ACTIVE" \| "ARCHIVED" | yes | DRAFT is not on the public shop. ACTIVE is published. ARCHIVED is retired. |
| `seo` | object | yes | Search and share fields stored on the product. |
| `seo.title` | string \| null | yes | — |
| `seo.description` | string \| null | yes | — |
| `seo.keywords` | string[] | yes | — |
| `seo.ogTitle` | string \| null | yes | — |
| `seo.ogDescription` | string \| null | yes | — |
| `seo.ogImage` | string \| null | yes | Share image display URL. Changes with versions; do not store it. |
| `seo.ogImageAssetId` | string \| null | yes | assetId of the share image when it is in the media library. |
| `seo.canonicalUrl` | string \| null | yes | — |
| `seo.robotsIndex` | "index" \| "noindex" | yes | — |
| `seo.robotsFollow` | "follow" \| "nofollow" | yes | — |
| `seo.excludeFromSiteSearch` | boolean | yes | Hide from visitor site search. Does not change robotsIndex. |
| `type` | "DIGITAL" \| "PHYSICAL" \| "SERVICE" | yes | — |
| `isTemplate` | boolean | yes | Sample product seeded for new sellers; not public. |
| `images` | object[] | yes | — |
| `images[].assetId` | string \| null | yes | assetId in the media library; null for older images that are not in it. |
| `images[].url` | string | yes | Display URL (changes with versions; do not store it). |
| `images[].alt` | string | no | — |
| `images[].width` | integer | no | — |
| `images[].height` | integer | no | — |
| `imageFramings` | object \| null[] | no | Optional presentation framing per image (parallel to images; omitted when all images use automatic mode). |
| `imageFramings[].focus` | object | no | — |
| `imageFramings[].focus.x` | number | yes | — |
| `imageFramings[].focus.y` | number | yes | — |
| `imageFramings[].screens` | object | no | — |
| `imageFramings[].screens.desktop` | object | no | — |
| `imageFramings[].screens.tablet` | object | no | — |
| `imageFramings[].screens.mobile` | object | no | — |
| `imageFramings[].fitMode` | "contain" \| "cover" | no | — |
| `imageFramings[].padColor` | string | no | — |
| `purchaseNotes` | string \| null | yes | — |
| `allowCustomAmount` | boolean | yes | — |
| `reviewsEnabled` | boolean | yes | — |
| `metadata` | object | yes | Public integration fields (flat key/value, up to 50 keys, 16 KB). Visible to the storefront and Delivery API. |
| `privateMetadata` | object | yes | Private integration fields; only the console, members and service accounts can read them. |
| `primaryCategoryId` | string \| null | yes | — |
| `options` | object[] | yes | — |
| `options[].id` | string | yes | — |
| `options[].name` | string | yes | — |
| `options[].position` | integer | yes | — |
| `options[].values` | string[] | yes | — |
| `pricingTiers` | object[] | yes | Purchasable variants; manage them in the console. |
| `pricingTiers[].id` | string | yes | — |
| `pricingTiers[].title` | string | yes | Variant title derived from option values (or the legacy name). |
| `pricingTiers[].optionValues` | string[] | yes | — |
| `pricingTiers[].amount` | integer | yes | Minor units in the store currency. |
| `pricingTiers[].compareAtAmount` | integer \| null | yes | — |
| `pricingTiers[].isPublished` | boolean | yes | — |
| `pricingTiers[].sortOrder` | integer | yes | — |
| `pricingTiers[].highlightBadge` | string \| null | yes | — |
| `pricingTiers[].requiresShipping` | boolean | yes | Whether buyers of this variant enter a delivery address and pay shipping. Variants without their own setting follow productType, so one product can mix a course-only variant and a course + printed book variant. Set per variant in the console. |
| `pricingTiers[].accessDays` | integer \| null | yes | Days buyers of this variant can access every deliverable it grants, counted from payment; null = permanent. One product can sell a 3-month variant and a lifetime variant. Set it with products.update({ pricingTiers: [{ id, accessDays }] }). |
| `deliverables` | object[] | yes | Deliverables granted on purchase (any product type). tierId null = every variant. How long buyers keep access is pricingTiers[].accessDays. Manage with products.update({ deliverables }). |
| `deliverables[].deliverableId` | string | yes | — |
| `deliverables[].tierId` | string \| null | yes | — |
| `orderCount` | integer | no | Paid orders (list only). |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `PRODUCT_NOT_FOUND` | 404 | — |
| `PRODUCT_NOT_LISTABLE` | 422 | — |
| `SITE_SUSPENDED` | 403 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/products/{productId}/unpublish" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "id": "prod_example01",
  "title": "Strawberry Birthday Cake",
  "description": "Seasonal strawberries and fresh whipped cream. Pre-order at least one day ahead.",
  "slug": "strawberry-birthday-cake",
  "status": "ACTIVE",
  "seo": {
    "title": "Strawberry Birthday Cake",
    "description": "Seasonal strawberries and fresh whipped cream.",
    "keywords": [
      "cake",
      "birthday"
    ],
    "ogTitle": null,
    "ogDescription": null,
    "ogImage": null,
    "ogImageAssetId": null,
    "excludeFromSiteSearch": false,
    "canonicalUrl": null,
    "robotsIndex": "index",
    "robotsFollow": "follow"
  },
  "type": "PHYSICAL",
  "isTemplate": false,
  "images": [
    {
      "assetId": "asset_example01",
      "url": "https://media.kanorio.com/example/sunny-bakery-hero.jpg",
      "alt": "Strawberry birthday cake",
      "width": 2400,
      "height": 1600
    }
  ],
  "purchaseNotes": "Please leave your preferred pickup time in the order note.",
  "metadata": {},
  "privateMetadata": {},
  "allowCustomAmount": false,
  "reviewsEnabled": true,
  "primaryCategoryId": "pcat_example01",
  "options": [
    {
      "id": "opt_example01",
      "name": "Size",
      "position": 0,
      "values": [
        "6-inch",
        "8-inch"
      ]
    }
  ],
  "pricingTiers": [
    {
      "id": "tier_example01",
      "title": "6-inch",
      "optionValues": [
        "6-inch"
      ],
      "amount": 880,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 0,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    },
    {
      "id": "tier_example02",
      "title": "8-inch",
      "optionValues": [
        "8-inch"
      ],
      "amount": 1280,
      "compareAtAmount": null,
      "isPublished": true,
      "sortOrder": 1,
      "highlightBadge": null,
      "requiresShipping": true,
      "accessDays": null
    }
  ],
  "deliverables": [],
  "orderCount": 18,
  "createdAt": "2026-08-20T06:00:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Archive a product

`POST /sites/{id}/products/{productId}/archive`

- Stability: beta
- Permission: `commerce:manage`
- Rate limit: default (120/minute per token)
- MCP tool: `products_archive`

Archive a product: it disappears from the store but keeps its orders and can be restored in the console. Deleting products is not available to agents. Errors: FORBIDDEN, PRODUCT_NOT_FOUND, SITE_SUSPENDED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `productId` | path | string | yes | Product id. Get it from products.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `status` | "ARCHIVED" | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `PRODUCT_NOT_FOUND` | 404 | — |
| `SITE_SUSPENDED` | 403 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/products/{productId}/archive" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "id": "prod_example01",
  "status": "ARCHIVED"
}
```
