# Promotions · Management API

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

> Markdown version of https://kanorio.com/en/developers/management-api/promotions. 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 promotions

`GET /sites/{id}/promotions`

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

List promotions (automatic discounts and discount codes), newest first. Archived promotions are left out unless includeArchived is true. 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. |
| `includeArchived` | query | boolean | no | Also return archived promotions. |
| `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[].name` | string | yes | Shown to the seller and, for automatic promotions, as the line on the checkout page. |
| `data[].kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | Wizard type; drives the storefront badge and which conditions apply. |
| `data[].method` | "CODE" \| "AUTOMATIC" | yes | CODE = the buyer enters a code at checkout; AUTOMATIC = applied when conditions match. |
| `data[].discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | PERCENTAGE of the matching lines, FIXED_AMOUNT off the order once, or OVERRIDE_PRICE as the per-unit price of matching lines. |
| `data[].code` | string \| null | yes | Discount code (uppercase) when method is CODE. |
| `data[].percentage` | integer \| null | yes | 1–100 when discountType is PERCENTAGE. |
| `data[].amounts` | object[] | yes | Per-currency amount for FIXED_AMOUNT / OVERRIDE_PRICE. |
| `data[].amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `data[].amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `data[].minQuantity` | integer \| null | yes | Minimum quantity across matching lines. |
| `data[].minOrderAmount` | integer \| null | yes | Minimum discountable subtotal of the order, minor units. |
| `data[].startsAt` | string \| null | yes | ISO 8601 start; null = already started. |
| `data[].endsAt` | string \| null | yes | ISO 8601 end; null = no end. Required for FLASH_SALE. |
| `data[].dailyStartMinute` | integer \| null | yes | Daily window start, minutes from midnight in the site timezone. |
| `data[].dailyEndMinute` | integer \| null | yes | Daily window end, minutes from midnight in the site timezone. |
| `data[].usageLimit` | integer \| null | yes | Total paid orders allowed; null = unlimited. |
| `data[].perCustomerLimit` | integer \| null | yes | Paid orders per customer; null = unlimited. |
| `data[].usageCount` | integer | yes | Paid orders that redeemed this promotion (refunds are not subtracted). |
| `data[].selector` | object \| null | yes | — |
| `data[].selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `data[].selector.refs` | object[] | no | — |
| `data[].selector.categoryIds` | string[] | no | — |
| `data[].selector.tags` | string[] | no | — |
| `data[].selector.priceRange` | object | no | — |
| `data[].marketIds` | string[] | yes | Only these markets; empty = every market. |
| `data[].audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | yes | FIRST_PURCHASE = no paid order yet; RETURNING = at least minPriorPurchases paid orders. |
| `data[].minPriorPurchases` | integer \| null | yes | RETURNING threshold; null = 1. |
| `data[].badgeOverride` | string \| null | yes | Custom storefront badge text (automatic promotions only). |
| `data[].isActive` | boolean | yes | — |
| `data[].archivedAt` | string \| null | yes | Archived promotions are hidden from the dashboard list and never apply. |
| `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. |

**Errors**

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

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

```json
{
  "data": [
    {
      "id": "prm_example01",
      "name": "Autumn 10% Off",
      "kind": "DISCOUNT_CODE",
      "method": "CODE",
      "discountType": "PERCENTAGE",
      "code": "AUTUMN10",
      "percentage": 10,
      "amounts": [],
      "minQuantity": null,
      "minOrderAmount": null,
      "startsAt": "2026-10-01T02:30:00.000Z",
      "endsAt": null,
      "dailyStartMinute": null,
      "dailyEndMinute": null,
      "usageLimit": 100,
      "perCustomerLimit": 1,
      "usageCount": 14,
      "selector": null,
      "marketIds": [],
      "audienceType": "ALL",
      "minPriorPurchases": null,
      "badgeOverride": null,
      "isActive": true,
      "archivedAt": null,
      "createdAt": "2026-10-01T02:30:00.000Z",
      "updatedAt": "2026-10-06T08:00:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Create a promotion

`POST /sites/{id}/promotions`

- Stability: beta
- Permission: `marketing:manage`
- Rate limit: default (120/minute per token)
- Idempotent: send `Idempotency-Key`
- MCP tool: `promotions_create`

Create a promotion. It applies at Kanorio's hosted checkout only; a headless storefront can read it to show badges but cannot apply it. Same rules as the dashboard (code uniqueness, FLASH_SALE needs endsAt, selector references must belong to this site). Errors: FORBIDDEN, VALIDATION (details.code: CODE_REQUIRED, KIND_METHOD_MISMATCH, SCOPE_REQUIRED, START_MUST_BE_BEFORE_END, FLASH_SALE_ENDS_AT_REQUIRED, DAILY_WINDOW_INCOMPLETE, INVALID_PRODUCT, INVALID_VARIANT, INVALID_CATEGORY, INVALID_RESOURCE, INVALID_MARKET, UNSUPPORTED_LINE_KIND, TAGS_NOT_SUPPORTED), PROMOTION_CODE_CONFLICT, SITE_SUSPENDED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `Idempotency-Key` | header | string | no | Client-chosen key (1-200 chars). Retrying with the same key and the same body returns the original result; the same key with a different body is rejected with IDEMPOTENCY_CONFLICT (409). Keys expire after 24 hours. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | — |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | DISCOUNT_CODE must use method CODE; the other kinds use AUTOMATIC. |
| `method` | "CODE" \| "AUTOMATIC" | yes | — |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | — |
| `code` | string | no | Required when method is CODE; stored uppercase; unique per site. |
| `percentage` | integer | no | — |
| `amounts` | object[] | no | Required for FIXED_AMOUNT / OVERRIDE_PRICE; one entry per currency. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer | no | — |
| `minOrderAmount` | integer | no | Minor units. |
| `startsAt` | string | no | ISO 8601 with offset. |
| `endsAt` | string | no | ISO 8601 with offset; required for FLASH_SALE. |
| `dailyStartMinute` | integer | no | — |
| `dailyEndMinute` | integer | no | Must be set together with dailyStartMinute. |
| `usageLimit` | integer | no | — |
| `perCustomerLimit` | integer | no | — |
| `selector` | object \| null | no | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | no | Market ids of this site; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | no | — |
| `minPriorPurchases` | integer | no | Only with audienceType RETURNING. |
| `badgeOverride` | string | no | — |
| `isActive` | boolean | no | Default true on create. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `name` | string | yes | Shown to the seller and, for automatic promotions, as the line on the checkout page. |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | Wizard type; drives the storefront badge and which conditions apply. |
| `method` | "CODE" \| "AUTOMATIC" | yes | CODE = the buyer enters a code at checkout; AUTOMATIC = applied when conditions match. |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | PERCENTAGE of the matching lines, FIXED_AMOUNT off the order once, or OVERRIDE_PRICE as the per-unit price of matching lines. |
| `code` | string \| null | yes | Discount code (uppercase) when method is CODE. |
| `percentage` | integer \| null | yes | 1–100 when discountType is PERCENTAGE. |
| `amounts` | object[] | yes | Per-currency amount for FIXED_AMOUNT / OVERRIDE_PRICE. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer \| null | yes | Minimum quantity across matching lines. |
| `minOrderAmount` | integer \| null | yes | Minimum discountable subtotal of the order, minor units. |
| `startsAt` | string \| null | yes | ISO 8601 start; null = already started. |
| `endsAt` | string \| null | yes | ISO 8601 end; null = no end. Required for FLASH_SALE. |
| `dailyStartMinute` | integer \| null | yes | Daily window start, minutes from midnight in the site timezone. |
| `dailyEndMinute` | integer \| null | yes | Daily window end, minutes from midnight in the site timezone. |
| `usageLimit` | integer \| null | yes | Total paid orders allowed; null = unlimited. |
| `perCustomerLimit` | integer \| null | yes | Paid orders per customer; null = unlimited. |
| `usageCount` | integer | yes | Paid orders that redeemed this promotion (refunds are not subtracted). |
| `selector` | object \| null | yes | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | yes | Only these markets; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | yes | FIRST_PURCHASE = no paid order yet; RETURNING = at least minPriorPurchases paid orders. |
| `minPriorPurchases` | integer \| null | yes | RETURNING threshold; null = 1. |
| `badgeOverride` | string \| null | yes | Custom storefront badge text (automatic promotions only). |
| `isActive` | boolean | yes | — |
| `archivedAt` | string \| null | yes | Archived promotions are hidden from the dashboard list and never apply. |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | details.code: CODE_REQUIRED, KIND_METHOD_MISMATCH, SCOPE_REQUIRED, START_MUST_BE_BEFORE_END, FLASH_SALE_ENDS_AT_REQUIRED, DAILY_WINDOW_INCOMPLETE, INVALID_PRODUCT, INVALID_VARIANT, INVALID_CATEGORY, INVALID_RESOURCE, INVALID_MARKET, UNSUPPORTED_LINE_KIND, TAGS_NOT_SUPPORTED |
| `PROMOTION_CODE_CONFLICT` | 409 | — |
| `SITE_SUSPENDED` | 403 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/promotions" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"name":"Studio bookings 20% off","kind":"PRODUCT_DISCOUNT","method":"AUTOMATIC","discountType":"PERCENTAGE","percentage":20,"selector":{"kinds":["BOOKING"],"refs":[{"kind":"BOOKING","id":"res_example01"}]},"audienceType":"RETURNING","minPriorPurchases":2}'
```

```json
{
  "id": "prm_example02",
  "name": "Studio bookings 20% off",
  "kind": "PRODUCT_DISCOUNT",
  "method": "AUTOMATIC",
  "discountType": "PERCENTAGE",
  "code": null,
  "percentage": 20,
  "amounts": [],
  "minQuantity": null,
  "minOrderAmount": null,
  "startsAt": null,
  "endsAt": null,
  "dailyStartMinute": null,
  "dailyEndMinute": null,
  "usageLimit": 100,
  "perCustomerLimit": 1,
  "usageCount": 0,
  "selector": {
    "kinds": [
      "BOOKING"
    ],
    "refs": [
      {
        "kind": "BOOKING",
        "id": "res_example01"
      }
    ]
  },
  "marketIds": [],
  "audienceType": "RETURNING",
  "minPriorPurchases": 2,
  "badgeOverride": null,
  "isActive": true,
  "archivedAt": null,
  "createdAt": "2026-10-01T02:30:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Retrieve a promotion

`GET /sites/{id}/promotions/{promotionId}`

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

Read one promotion, including its selector and per-currency amounts. Errors: FORBIDDEN, PROMOTION_NOT_FOUND.

**Parameters**

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

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `name` | string | yes | Shown to the seller and, for automatic promotions, as the line on the checkout page. |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | Wizard type; drives the storefront badge and which conditions apply. |
| `method` | "CODE" \| "AUTOMATIC" | yes | CODE = the buyer enters a code at checkout; AUTOMATIC = applied when conditions match. |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | PERCENTAGE of the matching lines, FIXED_AMOUNT off the order once, or OVERRIDE_PRICE as the per-unit price of matching lines. |
| `code` | string \| null | yes | Discount code (uppercase) when method is CODE. |
| `percentage` | integer \| null | yes | 1–100 when discountType is PERCENTAGE. |
| `amounts` | object[] | yes | Per-currency amount for FIXED_AMOUNT / OVERRIDE_PRICE. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer \| null | yes | Minimum quantity across matching lines. |
| `minOrderAmount` | integer \| null | yes | Minimum discountable subtotal of the order, minor units. |
| `startsAt` | string \| null | yes | ISO 8601 start; null = already started. |
| `endsAt` | string \| null | yes | ISO 8601 end; null = no end. Required for FLASH_SALE. |
| `dailyStartMinute` | integer \| null | yes | Daily window start, minutes from midnight in the site timezone. |
| `dailyEndMinute` | integer \| null | yes | Daily window end, minutes from midnight in the site timezone. |
| `usageLimit` | integer \| null | yes | Total paid orders allowed; null = unlimited. |
| `perCustomerLimit` | integer \| null | yes | Paid orders per customer; null = unlimited. |
| `usageCount` | integer | yes | Paid orders that redeemed this promotion (refunds are not subtracted). |
| `selector` | object \| null | yes | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | yes | Only these markets; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | yes | FIRST_PURCHASE = no paid order yet; RETURNING = at least minPriorPurchases paid orders. |
| `minPriorPurchases` | integer \| null | yes | RETURNING threshold; null = 1. |
| `badgeOverride` | string \| null | yes | Custom storefront badge text (automatic promotions only). |
| `isActive` | boolean | yes | — |
| `archivedAt` | string \| null | yes | Archived promotions are hidden from the dashboard list and never apply. |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

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

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

```json
{
  "id": "prm_example01",
  "name": "Autumn 10% Off",
  "kind": "DISCOUNT_CODE",
  "method": "CODE",
  "discountType": "PERCENTAGE",
  "code": "AUTUMN10",
  "percentage": 10,
  "amounts": [],
  "minQuantity": null,
  "minOrderAmount": null,
  "startsAt": "2026-10-01T02:30:00.000Z",
  "endsAt": null,
  "dailyStartMinute": null,
  "dailyEndMinute": null,
  "usageLimit": 100,
  "perCustomerLimit": 1,
  "usageCount": 14,
  "selector": null,
  "marketIds": [],
  "audienceType": "ALL",
  "minPriorPurchases": null,
  "badgeOverride": null,
  "isActive": true,
  "archivedAt": null,
  "createdAt": "2026-10-01T02:30:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Update a promotion

`PATCH /sites/{id}/promotions/{promotionId}`

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

Update a promotion (PATCH: fields left out stay as they are; selector: null means "all lines"; isActive toggles it). Changing a promotion does not affect orders already placed. Errors: FORBIDDEN, VALIDATION (details.code: CODE_REQUIRED, KIND_METHOD_MISMATCH, SCOPE_REQUIRED, START_MUST_BE_BEFORE_END, FLASH_SALE_ENDS_AT_REQUIRED, DAILY_WINDOW_INCOMPLETE, INVALID_PRODUCT, INVALID_VARIANT, INVALID_CATEGORY, INVALID_RESOURCE, INVALID_MARKET, UNSUPPORTED_LINE_KIND, TAGS_NOT_SUPPORTED), PROMOTION_CODE_CONFLICT, SITE_SUSPENDED, PROMOTION_NOT_FOUND.

**Parameters**

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

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | — |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | no | DISCOUNT_CODE must use method CODE; the other kinds use AUTOMATIC. |
| `method` | "CODE" \| "AUTOMATIC" | no | — |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | no | — |
| `code` | string | no | Required when method is CODE; stored uppercase; unique per site. |
| `percentage` | integer | no | — |
| `amounts` | object[] | no | Required for FIXED_AMOUNT / OVERRIDE_PRICE; one entry per currency. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer | no | — |
| `minOrderAmount` | integer | no | Minor units. |
| `startsAt` | string | no | ISO 8601 with offset. |
| `endsAt` | string | no | ISO 8601 with offset; required for FLASH_SALE. |
| `dailyStartMinute` | integer | no | — |
| `dailyEndMinute` | integer | no | Must be set together with dailyStartMinute. |
| `usageLimit` | integer | no | — |
| `perCustomerLimit` | integer | no | — |
| `selector` | object \| null | no | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | no | Market ids of this site; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | no | — |
| `minPriorPurchases` | integer | no | Only with audienceType RETURNING. |
| `badgeOverride` | string | no | — |
| `isActive` | boolean | no | Default true on create. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `name` | string | yes | Shown to the seller and, for automatic promotions, as the line on the checkout page. |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | Wizard type; drives the storefront badge and which conditions apply. |
| `method` | "CODE" \| "AUTOMATIC" | yes | CODE = the buyer enters a code at checkout; AUTOMATIC = applied when conditions match. |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | PERCENTAGE of the matching lines, FIXED_AMOUNT off the order once, or OVERRIDE_PRICE as the per-unit price of matching lines. |
| `code` | string \| null | yes | Discount code (uppercase) when method is CODE. |
| `percentage` | integer \| null | yes | 1–100 when discountType is PERCENTAGE. |
| `amounts` | object[] | yes | Per-currency amount for FIXED_AMOUNT / OVERRIDE_PRICE. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer \| null | yes | Minimum quantity across matching lines. |
| `minOrderAmount` | integer \| null | yes | Minimum discountable subtotal of the order, minor units. |
| `startsAt` | string \| null | yes | ISO 8601 start; null = already started. |
| `endsAt` | string \| null | yes | ISO 8601 end; null = no end. Required for FLASH_SALE. |
| `dailyStartMinute` | integer \| null | yes | Daily window start, minutes from midnight in the site timezone. |
| `dailyEndMinute` | integer \| null | yes | Daily window end, minutes from midnight in the site timezone. |
| `usageLimit` | integer \| null | yes | Total paid orders allowed; null = unlimited. |
| `perCustomerLimit` | integer \| null | yes | Paid orders per customer; null = unlimited. |
| `usageCount` | integer | yes | Paid orders that redeemed this promotion (refunds are not subtracted). |
| `selector` | object \| null | yes | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | yes | Only these markets; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | yes | FIRST_PURCHASE = no paid order yet; RETURNING = at least minPriorPurchases paid orders. |
| `minPriorPurchases` | integer \| null | yes | RETURNING threshold; null = 1. |
| `badgeOverride` | string \| null | yes | Custom storefront badge text (automatic promotions only). |
| `isActive` | boolean | yes | — |
| `archivedAt` | string \| null | yes | Archived promotions are hidden from the dashboard list and never apply. |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | details.code: CODE_REQUIRED, KIND_METHOD_MISMATCH, SCOPE_REQUIRED, START_MUST_BE_BEFORE_END, FLASH_SALE_ENDS_AT_REQUIRED, DAILY_WINDOW_INCOMPLETE, INVALID_PRODUCT, INVALID_VARIANT, INVALID_CATEGORY, INVALID_RESOURCE, INVALID_MARKET, UNSUPPORTED_LINE_KIND, TAGS_NOT_SUPPORTED |
| `PROMOTION_CODE_CONFLICT` | 409 | — |
| `SITE_SUSPENDED` | 403 | — |
| `PROMOTION_NOT_FOUND` | 404 | — |

```bash
curl -X PATCH "https://app.kanorio.com/api/v1/sites/{id}/promotions/{promotionId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"selector":{"refs":[{"kind":"PRODUCT","id":"prod_example01","variantId":"tier_example01"}],"priceRange":{"min":50000}},"usageLimit":200}'
```

```json
{
  "id": "prm_example01",
  "name": "Autumn 10% Off",
  "kind": "DISCOUNT_CODE",
  "method": "CODE",
  "discountType": "PERCENTAGE",
  "code": "AUTUMN10",
  "percentage": 10,
  "amounts": [],
  "minQuantity": null,
  "minOrderAmount": null,
  "startsAt": "2026-10-01T02:30:00.000Z",
  "endsAt": null,
  "dailyStartMinute": null,
  "dailyEndMinute": null,
  "usageLimit": 200,
  "perCustomerLimit": 1,
  "usageCount": 14,
  "selector": {
    "refs": [
      {
        "kind": "PRODUCT",
        "id": "prod_example01",
        "variantId": "tier_example01"
      }
    ],
    "priceRange": {
      "min": 50000
    }
  },
  "marketIds": [],
  "audienceType": "ALL",
  "minPriorPurchases": null,
  "badgeOverride": null,
  "isActive": true,
  "archivedAt": null,
  "createdAt": "2026-10-01T02:30:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Delete a promotion

`DELETE /sites/{id}/promotions/{promotionId}`

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

Delete a promotion that was never redeemed. Once a paid order used it the redemption history is an accounting trail, so archive it instead. Errors: FORBIDDEN, PROMOTION_NOT_FOUND, PROMOTION_HAS_REDEMPTIONS.

**Parameters**

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

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `deleted` | true | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `PROMOTION_NOT_FOUND` | 404 | — |
| `PROMOTION_HAS_REDEMPTIONS` | 409 | — |

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

```json
{
  "id": "prm_example02",
  "deleted": true
}
```

### Archive a promotion

`POST /sites/{id}/promotions/{promotionId}/archive`

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

Archive a promotion: it stops applying, is hidden from the dashboard list and keeps its redemption history. Idempotent. Errors: FORBIDDEN, PROMOTION_NOT_FOUND.

**Parameters**

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

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `name` | string | yes | Shown to the seller and, for automatic promotions, as the line on the checkout page. |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | Wizard type; drives the storefront badge and which conditions apply. |
| `method` | "CODE" \| "AUTOMATIC" | yes | CODE = the buyer enters a code at checkout; AUTOMATIC = applied when conditions match. |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | PERCENTAGE of the matching lines, FIXED_AMOUNT off the order once, or OVERRIDE_PRICE as the per-unit price of matching lines. |
| `code` | string \| null | yes | Discount code (uppercase) when method is CODE. |
| `percentage` | integer \| null | yes | 1–100 when discountType is PERCENTAGE. |
| `amounts` | object[] | yes | Per-currency amount for FIXED_AMOUNT / OVERRIDE_PRICE. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer \| null | yes | Minimum quantity across matching lines. |
| `minOrderAmount` | integer \| null | yes | Minimum discountable subtotal of the order, minor units. |
| `startsAt` | string \| null | yes | ISO 8601 start; null = already started. |
| `endsAt` | string \| null | yes | ISO 8601 end; null = no end. Required for FLASH_SALE. |
| `dailyStartMinute` | integer \| null | yes | Daily window start, minutes from midnight in the site timezone. |
| `dailyEndMinute` | integer \| null | yes | Daily window end, minutes from midnight in the site timezone. |
| `usageLimit` | integer \| null | yes | Total paid orders allowed; null = unlimited. |
| `perCustomerLimit` | integer \| null | yes | Paid orders per customer; null = unlimited. |
| `usageCount` | integer | yes | Paid orders that redeemed this promotion (refunds are not subtracted). |
| `selector` | object \| null | yes | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | yes | Only these markets; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | yes | FIRST_PURCHASE = no paid order yet; RETURNING = at least minPriorPurchases paid orders. |
| `minPriorPurchases` | integer \| null | yes | RETURNING threshold; null = 1. |
| `badgeOverride` | string \| null | yes | Custom storefront badge text (automatic promotions only). |
| `isActive` | boolean | yes | — |
| `archivedAt` | string \| null | yes | Archived promotions are hidden from the dashboard list and never apply. |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

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

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

```json
{
  "id": "prm_example01",
  "name": "Autumn 10% Off",
  "kind": "DISCOUNT_CODE",
  "method": "CODE",
  "discountType": "PERCENTAGE",
  "code": "AUTUMN10",
  "percentage": 10,
  "amounts": [],
  "minQuantity": null,
  "minOrderAmount": null,
  "startsAt": "2026-10-01T02:30:00.000Z",
  "endsAt": null,
  "dailyStartMinute": null,
  "dailyEndMinute": null,
  "usageLimit": 100,
  "perCustomerLimit": 1,
  "usageCount": 14,
  "selector": null,
  "marketIds": [],
  "audienceType": "ALL",
  "minPriorPurchases": null,
  "badgeOverride": null,
  "isActive": false,
  "archivedAt": "2026-10-06T08:00:00.000Z",
  "createdAt": "2026-10-01T02:30:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```

### Promotion unarchive

`POST /sites/{id}/promotions/{promotionId}/unarchive`

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

Restore an archived promotion and enable it again. Errors: FORBIDDEN, PROMOTION_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. |
| `promotionId` | path | string | yes | Promotion id from promotions.list. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | — |
| `name` | string | yes | Shown to the seller and, for automatic promotions, as the line on the checkout page. |
| `kind` | "PRODUCT_DISCOUNT" \| "DISCOUNT_CODE" \| "QUANTITY_DISCOUNT" \| "FLASH_SALE" \| "MIN_ORDER" | yes | Wizard type; drives the storefront badge and which conditions apply. |
| `method` | "CODE" \| "AUTOMATIC" | yes | CODE = the buyer enters a code at checkout; AUTOMATIC = applied when conditions match. |
| `discountType` | "PERCENTAGE" \| "FIXED_AMOUNT" \| "OVERRIDE_PRICE" | yes | PERCENTAGE of the matching lines, FIXED_AMOUNT off the order once, or OVERRIDE_PRICE as the per-unit price of matching lines. |
| `code` | string \| null | yes | Discount code (uppercase) when method is CODE. |
| `percentage` | integer \| null | yes | 1–100 when discountType is PERCENTAGE. |
| `amounts` | object[] | yes | Per-currency amount for FIXED_AMOUNT / OVERRIDE_PRICE. |
| `amounts[].currency` | string | yes | ISO 4217, case-insensitive; stored lowercase. |
| `amounts[].amount` | integer | yes | Minor units (cents; TWD uses 100 = NT$1). |
| `minQuantity` | integer \| null | yes | Minimum quantity across matching lines. |
| `minOrderAmount` | integer \| null | yes | Minimum discountable subtotal of the order, minor units. |
| `startsAt` | string \| null | yes | ISO 8601 start; null = already started. |
| `endsAt` | string \| null | yes | ISO 8601 end; null = no end. Required for FLASH_SALE. |
| `dailyStartMinute` | integer \| null | yes | Daily window start, minutes from midnight in the site timezone. |
| `dailyEndMinute` | integer \| null | yes | Daily window end, minutes from midnight in the site timezone. |
| `usageLimit` | integer \| null | yes | Total paid orders allowed; null = unlimited. |
| `perCustomerLimit` | integer \| null | yes | Paid orders per customer; null = unlimited. |
| `usageCount` | integer | yes | Paid orders that redeemed this promotion (refunds are not subtracted). |
| `selector` | object \| null | yes | — |
| `selector.kinds` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM"[] | no | — |
| `selector.refs` | object[] | no | — |
| `selector.refs[].kind` | "PRODUCT" \| "BOOKING" \| "PREPAID" \| "CUSTOM" | yes | — |
| `selector.refs[].id` | string | yes | — |
| `selector.refs[].variantId` | string | no | — |
| `selector.categoryIds` | string[] | no | — |
| `selector.tags` | string[] | no | — |
| `selector.priceRange` | object | no | — |
| `selector.priceRange.min` | integer | no | — |
| `selector.priceRange.max` | integer | no | — |
| `marketIds` | string[] | yes | Only these markets; empty = every market. |
| `audienceType` | "ALL" \| "FIRST_PURCHASE" \| "RETURNING" | yes | FIRST_PURCHASE = no paid order yet; RETURNING = at least minPriorPurchases paid orders. |
| `minPriorPurchases` | integer \| null | yes | RETURNING threshold; null = 1. |
| `badgeOverride` | string \| null | yes | Custom storefront badge text (automatic promotions only). |
| `isActive` | boolean | yes | — |
| `archivedAt` | string \| null | yes | Archived promotions are hidden from the dashboard list and never apply. |
| `createdAt` | string | yes | — |
| `updatedAt` | string | yes | — |

**Errors**

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

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

```json
{
  "id": "prm_example01",
  "name": "Autumn 10% Off",
  "kind": "DISCOUNT_CODE",
  "method": "CODE",
  "discountType": "PERCENTAGE",
  "code": "AUTUMN10",
  "percentage": 10,
  "amounts": [],
  "minQuantity": null,
  "minOrderAmount": null,
  "startsAt": "2026-10-01T02:30:00.000Z",
  "endsAt": null,
  "dailyStartMinute": null,
  "dailyEndMinute": null,
  "usageLimit": 100,
  "perCustomerLimit": 1,
  "usageCount": 14,
  "selector": null,
  "marketIds": [],
  "audienceType": "ALL",
  "minPriorPurchases": null,
  "badgeOverride": null,
  "isActive": true,
  "archivedAt": null,
  "createdAt": "2026-10-01T02:30:00.000Z",
  "updatedAt": "2026-10-06T08:00:00.000Z"
}
```
