# SellerSideKick Shop API reference

> Version 1.2.0. Generated from the OpenAPI spec (https://api.sellersidekick.ai/v1/api/openapi.json). Base URL: https://api.sellersidekick.ai/v1/api.

Read and update one SellerSideKick shop's **products, stock and promotions**. SellerSideKick is an AI chatbot and back office for online shops selling on LINE, Facebook, Instagram, Shopee, Lazada and TikTok.

The usual job: the shop owner gives you a spreadsheet (prices, stock counts, new products), and you apply it here.

## Access
- The shop owner creates a key under **Settings → API keys** in the SellerSideKick dashboard. Keys work on the monthly plans only.
- Send it on every request as `Authorization: Bearer ssk_...`.
- The key decides the shop: there is no shop id to pass. Never put the key in a URL, a file you share, or a reply to the owner.
- A key can be limited to some permissions and can expire. `GET /me` shows both. The permissions are `catalog:read` (always: shop, categories, products, promotions), `catalog:write` (create and update products; an opening `stock` on a new product also needs `stock:write`), `stock:write` and `promotions:write`. A call the key doesn't allow answers 403 `Y35` naming the permission; tell the owner.
- In an AI assistant app that supports connectors (MCP), the owner can connect the shop instead, with no key: the SellerSideKick connector at `https://mcp.sellersidekick.ai/mcp` (sign in, pick the shop and permissions).

## How to work
1. Call `GET /me` to confirm which shop the key is for, and tell the owner the shop name.
2. Read before writing:
   - `GET /categories` for the allowed category names;
   - `GET /products?sku=...` for the current prices and stock.
3. Send every write with `dry_run: true` first. Show the owner the summary and every row with status `error`, and wait for a clear yes.
4. Send the same request with `dry_run: false`. Report the summary and every `error` row.

## Rules
- At most **500 rows per request**: split bigger sheets. Each row succeeds or fails on its own, so read `results`.
- Products are matched by **SKU**. Stock changes only through `POST /stock`, never through `POST /products/upsert`.
- For stock, give each new change its own `idempotency_key`, and reuse it only to retry that same request.
- **Nothing can be deleted.** To end a promotion, set its status to `archive`.

## What reaches the marketplaces
- **Stock changes** reach the shop's linked Shopee, Lazada and TikTok listings, usually within about 10 minutes.
- **Prices, names, descriptions and promotions** change the SellerSideKick store and the shop's chat bot only, not the marketplace listings.
- Only products on the SellerSideKick store can be changed. A SKU that exists only on a marketplace is an error row.

## Limits and errors
- 120 requests a minute per shop. On `429`, wait the `Retry-After` seconds and retry.
- Errors are `{ code, message, rcode }`.
- 400 means fix the field the message names. 401 and 403 mean the key can't be used; tell the owner, because retrying won't help.

Thai text is welcome everywhere (names, descriptions, promotion names). Prices are in THB.

## Authentication

- `shopApiKey`: http (bearer). The shop's API key, `ssk_` + 40 characters, from Settings → API keys. One key acts on one shop.

## Shop

Which shop the key belongs to, and its categories.

### GET /me

Which shop this key belongs to. Operation id: `getShop`.

**Use first,** before any other call. It confirms the key works and names the shop so the owner can check it is the right one.

**Changes nothing.** Safe to call any time.

**Response 200:** The shop and the key's name.

`ok`

```json
{
  "code": 200,
  "message": "OK",
  "data": {
    "shop": {
      "id": "6650a1b2c3d4e5f607182930",
      "uuid": "3833993e-2bcd-41e6-af1b-b8d115aafae0",
      "name": "ร้านของเล่นนิชิ",
      "currency": "THB"
    },
    "key": {
      "name": "Catalogue update from sheet",
      "prefix": "ssk_AbCdEfGh",
      "scopes": [
        "catalog:read",
        "catalog:write",
        "stock:write",
        "promotions:write"
      ],
      "expires_at": "2027-01-23T03:00:00.000Z"
    }
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

### GET /categories

The category names a product may use. Operation id: `listCategories`.

**Use before creating products** or changing a category. Map the sheet's categories onto these names; matching ignores case. A name that isn't here is an error row.

**Changes nothing.**

**Response 200:** Category names.

`ok`

```json
{
  "code": 200,
  "message": "OK",
  "data": {
    "categories": [
      "Toys",
      "Books",
      "Clothing"
    ]
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

## Products

Read, create and update products by SKU. Prices and content stay on the SellerSideKick store.

### GET /products

The shop's products with price and stock, by SKU or page by page. Operation id: `listProducts`.

**Use to read** current prices, names and stock before you change them. Read before a stock count you plan to guard with `expected_from`, and to check which SKUs already exist.

- Lists only products on the shop's SellerSideKick store.
- Pass `sku` (comma-separated) for specific products, or omit it to page through everything. Results are paged either way: set `limit` (up to 500) when asking for many SKUs.

**Changes nothing.**

**Parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `sku (query)` | string | no | Comma-separated SKUs, e.g. `TM-001,TM-002`. |
| `page (query)` | integer | no | (min 1) (default 1) |
| `limit (query)` | integer | no | (min 1) (max 500) (default 100) |

**Response 200:** A page of products.

`ok`

```json
{
  "code": 200,
  "message": "OK",
  "data": {
    "products": [
      {
        "sku": "TM-001",
        "name": {
          "th": "รถเหล็กโทมิก้า รุ่นรถตำรวจ",
          "en": "Tomica police car"
        },
        "description": {
          "th": "รถเหล็กสเกล 1:64 ประตูเปิดได้",
          "en": "1:64 die-cast car, opening doors"
        },
        "usage_information": {
          "th": "เหมาะสำหรับเด็กอายุ 3 ปีขึ้นไป",
          "en": "Ages 3 and up"
        },
        "price": 129,
        "currency": "THB",
        "category": "Toys",
        "brand": "Tomica",
        "parent_sku": null,
        "stock": {
          "on_hand": 40,
          "available": 37
        },
        "archived": false,
        "updated_at": "2026-10-24T09:15:00.000Z"
      }
    ],
    "page": 1,
    "limit": 100,
    "total": 1,
    "total_pages": 1
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

### POST /products/upsert

Create new products and update existing ones by SKU (up to 500 rows). Operation id: `upsertProducts`.

**Use when** the owner wants prices, names, descriptions, categories or brands changed, or new products added.

**Don't use for** stock: an existing product's stock changes only through `POST /stock`. Sending `stock` for an existing SKU is an error row.

**Before calling:**
- Get category names from `GET /categories`.
- Send with `dry_run: true`, show the owner the summary and the error rows, and only send it for real after they agree.

**How rows are handled:**
- A SKU the shop has is updated with only the fields you send; the rest stay.
- A new SKU is created. It needs `name`, `price`, `category` and `brand`, and may set `stock` (the opening count) and `parent_sku` (to make it a variant of a main product, which may be created in the same request).
- Each row answers `created`, `updated` (with `changed`), `unchanged` or `error`.

**Effect:** changes the SellerSideKick store and the shop's chat bot. Marketplace listings keep their own price and content.

**Safe to retry:** yes. Sending the same rows again leaves them `unchanged`.

**Request body** (JSON, required)

| Field | Type | Required | Description |
|---|---|---|---|
| `dry_run` | boolean | no | true: check every row and report what would happen, changing nothing. (default false) |
| `products` | array of object {sku, name, description, usage_information, price, category, brand, stock, parent_sku} | yes | (at least 1 items) (at most 500 items) |
| `products[].sku` | string | yes |  |
| `products[].name` | string or object {th, en} | no | A string is the Thai text (the saved English text is kept), or send { th, en } to set either or both. |
| `products[].description` | string or object {th, en} | no | A string is the Thai text (the saved English text is kept), or send { th, en } to set either or both. |
| `products[].usage_information` | string or object {th, en} | no | How to use or care for the product. A string is the Thai text (the saved English text is kept), or send { th, en } to set either or both. |
| `products[].price` | number | no | THB. (min 0) |
| `products[].category` | string | no | A name from GET /categories. |
| `products[].brand` | string | no | A new brand is added to the shop's brand list. |
| `products[].stock` | integer | no | New products only: the opening count on the shelf. (min 0) |
| `products[].parent_sku` | string | no | New products only: the main product this one is a variant of. A new variant that gives no category or brand takes the main product's. |

**Request examples**

*Update two prices (dry run first)* (`price_update`)

```json
{
  "dry_run": true,
  "products": [
    {
      "sku": "TM-001",
      "price": 139
    },
    {
      "sku": "TM-002",
      "price": 159
    }
  ]
}
```

*Add a new product with Thai and English names* (`new_product`)

```json
{
  "dry_run": true,
  "products": [
    {
      "sku": "TM-010",
      "name": {
        "th": "รถดับเพลิงโทมิก้า",
        "en": "Tomica fire engine"
      },
      "price": 189,
      "category": "Toys",
      "brand": "Tomica",
      "stock": 12
    }
  ]
}
```

*A main product and two variants in one request* (`variants`)

```json
{
  "products": [
    {
      "sku": "SHIRT-01",
      "name": "เสื้อยืดลายแมว",
      "price": 290,
      "category": "Clothing",
      "brand": "Nichi"
    },
    {
      "sku": "SHIRT-01-M",
      "name": "เสื้อยืดลายแมว ไซซ์ M",
      "price": 290,
      "category": "Clothing",
      "brand": "Nichi",
      "parent_sku": "SHIRT-01",
      "stock": 20
    },
    {
      "sku": "SHIRT-01-L",
      "name": "เสื้อยืดลายแมว ไซซ์ L",
      "price": 290,
      "category": "Clothing",
      "brand": "Nichi",
      "parent_sku": "SHIRT-01",
      "stock": 15
    }
  ]
}
```

**Response 200:** Each row's result. Report the summary and every `error` row to the owner.

`ok`

```json
{
  "code": 200,
  "message": "Dry run: nothing was changed",
  "data": {
    "dry_run": true,
    "results": [
      {
        "sku": "TM-001",
        "status": "updated",
        "changed": [
          "price"
        ]
      },
      {
        "sku": "TM-002",
        "status": "unchanged"
      },
      {
        "sku": "TM-099",
        "status": "error",
        "error": "Unknown category \"Gadgets\". Use one of the shop's top-level categories (GET /v1/api/categories)."
      }
    ],
    "summary": {
      "total": 3,
      "updated": 1,
      "unchanged": 1,
      "error": 1
    }
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

## Stock

Count or adjust on-hand units. Reaches linked marketplace listings.

### POST /stock

Set counted stock or adjust it by SKU (up to 500 rows). Operation id: `updateStock`.

**Use when** the owner has counted stock (send `value`, the units now on the shelf) or wants to add or remove units (send `delta`, e.g. `-3` for damaged goods). Never both.

**Before calling:**
- To be safe against changes made since you read, send `expected_from` (the on-hand you read) with `value`. A row whose on-hand moved meanwhile is refused instead of overwritten.
- Send `dry_run: true` first and confirm with the owner.
- A dry run doesn't check holds by open orders, a delta on a never-counted SKU, or a replay, so the real call can still answer some rows differently.

**Effect:**
- Recorded in the shop's stock history.
- Sent to the SKU's linked Shopee, Lazada and TikTok listings, usually within about 10 minutes.
- Only SKUs on the SellerSideKick store; a marketplace-only SKU is an error row.
- A `delta` can't take on-hand below what open orders hold. A counted `value` is recorded as counted, even below that.

**Safe to retry:** yes, with the same `idempotency_key`. A row already applied answers `already_applied`. Use a new key for each new change: a row with different numbers under a reused key is applied.

**Request body** (JSON, required)

| Field | Type | Required | Description |
|---|---|---|---|
| `dry_run` | boolean | no | (default false) |
| `idempotency_key` | string | no | Your id for this change, e.g. `count-2026-10-25-sheet1`. Resending with it never applies a row twice. (min length 8) (max length 100) (pattern `^[A-Za-z0-9._:-]+$`) |
| `items` | array of object {sku, value, delta, expected_from, reason} | yes | (at least 1 items) (at most 500 items) |
| `items[].sku` | string | yes |  |
| `items[].value` | integer | no | The units now on the shelf (a count). (min 0) |
| `items[].delta` | integer | no | Units to add (positive) or remove (negative). Not 0. |
| `items[].expected_from` | integer | no | With `value` only: the on-hand you read. If it has changed, the row is refused. (min 0) |
| `items[].reason` | string | no | Shown in the stock history, e.g. 'monthly count'. (max length 200) |

**Request examples**

*A stock count, guarded against changes since it was read* (`count`)

```json
{
  "idempotency_key": "count-2026-10-25-sheet1",
  "dry_run": true,
  "items": [
    {
      "sku": "TM-001",
      "value": 40,
      "expected_from": 37,
      "reason": "monthly count"
    },
    {
      "sku": "TM-002",
      "value": 0,
      "reason": "monthly count"
    }
  ]
}
```

*Remove 3 damaged units* (`adjust`)

```json
{
  "idempotency_key": "damaged-2026-10-25",
  "items": [
    {
      "sku": "TM-001",
      "delta": -3,
      "reason": "damaged in transit"
    }
  ]
}
```

**Response 200:** Each row's result. Report the summary and every `error` row to the owner.

`ok`

```json
{
  "code": 200,
  "message": "Stock processed",
  "data": {
    "dry_run": false,
    "results": [
      {
        "sku": "TM-001",
        "status": "updated",
        "from": 37,
        "to": 40,
        "available": 38
      },
      {
        "sku": "TM-002",
        "status": "already_applied"
      },
      {
        "sku": "ZZ-9",
        "status": "error",
        "error": "This SKU is not a product on your SellerSideKick store (it may be only on a marketplace channel)."
      }
    ],
    "summary": {
      "total": 3,
      "updated": 1,
      "already_applied": 1,
      "error": 1
    }
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

## Promotions

The shop's own coupons, discounts, bundles and buy-X-get-Y offers. Never deleted, only archived.

### GET /promotions

The shop's own promotions, optionally by type and status. Operation id: `listPromotions`.

**Use to find a promotion's id** before updating or archiving it, and to check a code is free before creating one.

Marketplace promotions (Shopee, Lazada, TikTok) are not listed: they are managed in each Seller Center.

**Changes nothing.**

**Parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `type (query)` | "coupon" \| "discount" \| "bundle" \| "bogo" | no | Only this type. |
| `status (query)` | "active" \| "inactive" \| "archive" | no | Only this status. |

**Response 200:** Promotions, newest first within each type.

`ok`

```json
{
  "code": 200,
  "message": "OK",
  "data": {
    "promotions": [
      {
        "id": "6650f0c2a1b2c3d4e5f60718",
        "type": "coupon",
        "status": "active",
        "times_used": 0,
        "code": "WELCOME10",
        "name": "ส่วนลด 10% สำหรับลูกค้าใหม่",
        "discount_type": "percentage",
        "value": 10,
        "max_discount_amount": 100,
        "applies_to": {
          "all_products": true
        },
        "starts_at": "2026-11-01T00:00:00+07:00",
        "ends_at": "2026-11-30T23:59:59+07:00",
        "max_uses_per_customer": 1
      }
    ]
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

### POST /promotions/{type}

Create a coupon, discount, bundle or BOGO promotion. Operation id: `createPromotion`.

**Use when** the owner asks for a new promotion.

**Before calling:**
- Confirm the code, dates, amount and the products it covers with the owner.
- SKUs must be on the shop's SellerSideKick store; an unknown SKU is a 400.
- A code the shop already uses is refused (400).

**Effect:** live in the shop's SellerSideKick store and chat bot from `starts_at` (status `active` unless you send `inactive`). Not pushed to marketplaces.

**Safe to retry:** no. Check with `GET /promotions` whether it was created before sending again.

**Parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `type (path)` | "coupon" \| "discount" \| "bundle" \| "bogo" | yes | The promotion's type. |

**Request body** (JSON, required)

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | no | Upper-case letters, digits, - and _; lower case is upper-cased. Unique in the shop. |
| `name` | string | no |  |
| `description` | string | no |  |
| `starts_at` | string | no | (format date-time) |
| `ends_at` | string | no | Must be after starts_at. (format date-time) |
| `status` | "active" \| "inactive" | no | Create only. Default active. |
| `max_uses` | integer \| null | no | Total uses allowed; null for no limit. |
| `max_uses_per_customer` | integer \| null | no |  |
| `discount_type` | "percentage" \| "fixed" | no |  |
| `value` | number | no | Percent (at most 100) or THB, by discount_type. |
| `min_order_amount` | number | no | THB. |
| `max_discount_amount` | number \| null | no | THB cap on a percentage discount. |
| `can_stack` | boolean | no | Coupon only: usable together with other promotions. |
| `applies_to` | object {all_products, categories, brands, skus} | no | Which products the discount covers: `all_products: true`, or any mix of categories, brands and SKUs (not both). |
| `applies_to.all_products` | boolean | no |  |
| `applies_to.categories` | array of string | no | Category names from GET /categories. |
| `applies_to.brands` | array of string | no |  |
| `applies_to.skus` | array of string | no | Must be SKUs on the shop's SellerSideKick store. |
| `product_discounts` | array of object {sku, discount_type, value} | no | Discount only: a rate per SKU, off every unit of it, instead of one rate for the listed products. Leave out discount_type, value, max_discount_amount and applies_to. On an update the list replaces the saved one. (at most 500 items) |
| `product_discounts[].sku` | string | yes |  |
| `product_discounts[].discount_type` | "percentage" \| "fixed" | yes |  |
| `product_discounts[].value` | number | yes | Percent (at most 100), or THB off every unit. |
| `products` | array of object {sku, quantity, role} | no |  |
| `products[].sku` | string | yes |  |
| `products[].quantity` | integer | yes | (min 1) |
| `products[].role` | "buy" \| "get" | no | BOGO only. |
| `bundle_discount_type` | "fixed" \| "percentage" | no |  |
| `bundle_discount` | number | no | THB off the bundle, when bundle_discount_type is fixed. |
| `bundle_discount_percentage` | number | no | Percent off, when bundle_discount_type is percentage. |

**Request examples**

*Coupon: 10% off the whole shop, at most 100 THB* (`coupon`)

```json
{
  "code": "WELCOME10",
  "name": "ส่วนลด 10% สำหรับลูกค้าใหม่",
  "discount_type": "percentage",
  "value": 10,
  "max_discount_amount": 100,
  "applies_to": {
    "all_products": true
  },
  "starts_at": "2026-11-01T00:00:00+07:00",
  "ends_at": "2026-11-30T23:59:59+07:00",
  "max_uses_per_customer": 1
}
```

*Discount: a rate per SKU* (`discount_per_sku`)

```json
{
  "code": "WHSALE",
  "name": "Warehouse sale",
  "product_discounts": [
    {
      "sku": "TM-001",
      "discount_type": "fixed",
      "value": 120
    },
    {
      "sku": "TM-002",
      "discount_type": "percentage",
      "value": 25
    }
  ],
  "starts_at": "2026-11-01T00:00:00+07:00",
  "ends_at": "2026-11-15T23:59:59+07:00"
}
```

*Discount: 50 THB off two SKUs* (`discount`)

```json
{
  "code": "TOMICA50",
  "name": "Tomica ลด 50 บาท",
  "discount_type": "fixed",
  "value": 50,
  "applies_to": {
    "skus": [
      "TM-001",
      "TM-002"
    ]
  },
  "starts_at": "2026-11-01T00:00:00+07:00",
  "ends_at": "2026-11-15T23:59:59+07:00"
}
```

*Bundle: two cars for 20% off* (`bundle`)

```json
{
  "code": "PAIR20",
  "name": "ซื้อคู่ลด 20%",
  "products": [
    {
      "sku": "TM-001",
      "quantity": 1
    },
    {
      "sku": "TM-002",
      "quantity": 1
    }
  ],
  "bundle_discount_type": "percentage",
  "bundle_discount_percentage": 20,
  "starts_at": "2026-11-01T00:00:00+07:00",
  "ends_at": "2026-12-31T23:59:59+07:00"
}
```

*BOGO: buy 2 cars, get a track free* (`bogo`)

```json
{
  "code": "B2G1TRACK",
  "name": "ซื้อ 2 แถมราง 1",
  "products": [
    {
      "sku": "TM-001",
      "quantity": 2,
      "role": "buy"
    },
    {
      "sku": "TR-100",
      "quantity": 1,
      "role": "get"
    }
  ],
  "starts_at": "2026-11-01T00:00:00+07:00",
  "ends_at": "2026-11-30T23:59:59+07:00"
}
```

**Response 201:** The new promotion.

`ok`

```json
{
  "code": 200,
  "message": "Promotion created",
  "data": {
    "promotion": {
      "id": "6650f0c2a1b2c3d4e5f60718",
      "type": "coupon",
      "status": "active",
      "times_used": 0,
      "code": "WELCOME10",
      "name": "ส่วนลด 10% สำหรับลูกค้าใหม่",
      "discount_type": "percentage",
      "value": 10,
      "max_discount_amount": 100,
      "applies_to": {
        "all_products": true
      },
      "starts_at": "2026-11-01T00:00:00+07:00",
      "ends_at": "2026-11-30T23:59:59+07:00",
      "max_uses_per_customer": 1
    }
  }
}
```

**Errors**

- **400**: The request is invalid (the message names the field), a SKU isn't on the shop's store, or the code is already used. Fix it and send again.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

### PATCH /promotions/{type}/{id}

Change a promotion: send only the fields that change. Operation id: `updatePromotion`.

**Use when** the owner wants a promotion's dates, amount, products, limits, name or code changed.

**Before calling:** get the id from `GET /promotions` and confirm the change with the owner. The type in the path must match the promotion's own.

**Checked as the promotion will be afterwards.** For example, `ends_at` must stay after the saved `starts_at`, a percentage stays at 100 or below, and a BOGO keeps a buy and a get product.

**Don't use to** switch it on or off or end it: use `POST /promotions/{type}/{id}/status`. Marketplace promotions can't be changed here.

**Safe to retry:** yes. Sending the same fields again gives the same result.

**Parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `type (path)` | "coupon" \| "discount" \| "bundle" \| "bogo" | yes | The promotion's type. |
| `id (path)` | string | yes | The promotion's id from GET /promotions. |

**Request body** (JSON, required)

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | string | no | Upper-case letters, digits, - and _; lower case is upper-cased. Unique in the shop. |
| `name` | string | no |  |
| `description` | string | no |  |
| `starts_at` | string | no | (format date-time) |
| `ends_at` | string | no | Must be after starts_at. (format date-time) |
| `status` | "active" \| "inactive" | no | Create only. Default active. |
| `max_uses` | integer \| null | no | Total uses allowed; null for no limit. |
| `max_uses_per_customer` | integer \| null | no |  |
| `discount_type` | "percentage" \| "fixed" | no |  |
| `value` | number | no | Percent (at most 100) or THB, by discount_type. |
| `min_order_amount` | number | no | THB. |
| `max_discount_amount` | number \| null | no | THB cap on a percentage discount. |
| `can_stack` | boolean | no | Coupon only: usable together with other promotions. |
| `applies_to` | object {all_products, categories, brands, skus} | no | Which products the discount covers: `all_products: true`, or any mix of categories, brands and SKUs (not both). |
| `applies_to.all_products` | boolean | no |  |
| `applies_to.categories` | array of string | no | Category names from GET /categories. |
| `applies_to.brands` | array of string | no |  |
| `applies_to.skus` | array of string | no | Must be SKUs on the shop's SellerSideKick store. |
| `product_discounts` | array of object {sku, discount_type, value} | no | Discount only: a rate per SKU, off every unit of it, instead of one rate for the listed products. Leave out discount_type, value, max_discount_amount and applies_to. On an update the list replaces the saved one. (at most 500 items) |
| `product_discounts[].sku` | string | yes |  |
| `product_discounts[].discount_type` | "percentage" \| "fixed" | yes |  |
| `product_discounts[].value` | number | yes | Percent (at most 100), or THB off every unit. |
| `products` | array of object {sku, quantity, role} | no |  |
| `products[].sku` | string | yes |  |
| `products[].quantity` | integer | yes | (min 1) |
| `products[].role` | "buy" \| "get" | no | BOGO only. |
| `bundle_discount_type` | "fixed" \| "percentage" | no |  |
| `bundle_discount` | number | no | THB off the bundle, when bundle_discount_type is fixed. |
| `bundle_discount_percentage` | number | no | Percent off, when bundle_discount_type is percentage. |

**Request examples**

*Extend the end date* (`extend`)

```json
{
  "ends_at": "2026-12-15T23:59:59+07:00"
}
```

*Raise a discount to 15%* (`raise`)

```json
{
  "value": 15
}
```

**Response 200:** The promotion as saved.

`ok`

```json
{
  "code": 200,
  "message": "Promotion updated",
  "data": {
    "promotion": {
      "id": "6650f0c2a1b2c3d4e5f60718",
      "type": "coupon",
      "status": "active",
      "times_used": 0,
      "code": "WELCOME10",
      "name": "ส่วนลด 10% สำหรับลูกค้าใหม่",
      "discount_type": "percentage",
      "value": 10,
      "max_discount_amount": 100,
      "applies_to": {
        "all_products": true
      },
      "starts_at": "2026-11-01T00:00:00+07:00",
      "ends_at": "2026-11-30T23:59:59+07:00",
      "max_uses_per_customer": 1
    }
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **404**: No promotion with this id and type in this shop. List promotions to get a valid id.
- **409**: The new code is already used by another promotion of this type in the shop. Pick another code.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.

### POST /promotions/{type}/{id}/status

Switch a promotion on or off, or end it (archive). Operation id: `setPromotionStatus`.

**Use when** the owner wants to pause a promotion (`inactive`), resume it (`active`) or end it for good (`archive`). Archive is how a promotion is removed: nothing is deleted, and it stays in the history.

**Before calling:** confirm with the owner, especially before `archive`.

**Safe to retry:** yes.

**Parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `type (path)` | "coupon" \| "discount" \| "bundle" \| "bogo" | yes | The promotion's type. |
| `id (path)` | string | yes | The promotion's id from GET /promotions. |

**Request body** (JSON, required)

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | "active" \| "inactive" \| "archive" | yes |  |

**Request examples**

`pause`

```json
{
  "status": "inactive"
}
```

`end`

```json
{
  "status": "archive"
}
```

**Response 200:** The promotion with its new status.

`ok`

```json
{
  "code": 200,
  "message": "Promotion archive",
  "data": {
    "promotion": {
      "id": "6650f0c2a1b2c3d4e5f60718",
      "type": "coupon",
      "status": "active",
      "times_used": 0,
      "code": "WELCOME10",
      "name": "ส่วนลด 10% สำหรับลูกค้าใหม่",
      "discount_type": "percentage",
      "value": 10,
      "max_discount_amount": 100,
      "applies_to": {
        "all_products": true
      },
      "starts_at": "2026-11-01T00:00:00+07:00",
      "ends_at": "2026-11-30T23:59:59+07:00",
      "max_uses_per_customer": 1
    }
  }
}
```

**Errors**

- **400**: The request is invalid. The message names the field; fix it and send again. Unknown fields are refused by name.
- **401** (Y20, Y21, Y22): No key, or the key is unknown, revoked or expired. Ask the shop owner for a valid key (Settings → API keys). Do not retry with the same key.
- **403** (Y31, Y32, Y34, Y35): The key is real but can't be used now. Tell the owner why; retrying won't help.
- **404**: No promotion with this id and type in this shop. List promotions to get a valid id.
- **429** (R01): Too many requests: 120 a minute per shop. Wait the number of seconds in the Retry-After header, then send the same request again.
