# SellerSideKick developer docs (full text) > SellerSideKick is an AI chatbot and back office for online shops that sell on LINE, Facebook (Messenger), Instagram, TikTok, Shopee and Lazada. Every English guide, then the API reference. Each part starts with its source URL. --- # SellerSideKick developer docs > Let an AI assistant or a script read and update your shop's products, stock and promotions. SellerSideKick is an AI chatbot and back office for online shops that sell on LINE, Facebook (Messenger), Instagram, TikTok, Shopee and Lazada. These docs cover one job: letting an AI assistant or a script work on a shop's catalogue for you. The usual case: you have an Excel sheet of new prices, a stock count or a list of new products. You hand it to Claude, ChatGPT or Gemini, the assistant shows you what it is about to change, you say yes, and it applies the sheet. ## Two ways in | | AI-assistant connector | Shop API key | |---|---|---| | For | Claude, ChatGPT, Gemini Enterprise, Gemini CLI | coding tools, scripts, your own integrations | | Address | `https://mcp.sellersidekick.ai/mcp` | `https://api.sellersidekick.ai/v1/api` | | How it signs in | you sign in with SellerSideKick, pick the shop and the permissions | a key (`ssk_…`) you create in **Settings → API keys** | | Revoke | **Settings → API keys → Connected AI assistants** (Disconnect) | **Settings → API keys** (Revoke) | Start with the connector if you use a chat assistant. Use a key only when a program needs one. See [Connect ChatGPT, Claude or Gemini](https://docs.sellersidekick.ai/connect-ai-assistant). ## What you can do - **Prices and product details:** change prices, names, descriptions, categories and brands by SKU. See [Update prices](https://docs.sellersidekick.ai/update-prices). - **Stock:** set counted stock or add and remove units. See [Update stock counts](https://docs.sellersidekick.ai/update-stock). - **New products and variants:** add products, with sizes or colours as variants. See [Add new products and variants](https://docs.sellersidekick.ai/add-products). - **Promotions:** create coupons, discounts, bundles and buy-X-get-Y offers, change them, pause them or end them. See [Promotions](https://docs.sellersidekick.ai/promotions). ## Rules that apply everywhere - **Monthly plans only.** The connector and API keys work while the shop is on a monthly plan (such as Pro or Pro Plus), not on Basic (pay-as-you-go coins). - **Preview first.** Every write can run as a dry run that changes nothing. A good assistant always shows you the preview and waits for your yes. - **Up to 500 rows per request.** Bigger sheets are split. Each row succeeds or fails on its own. - **Nothing is deleted.** Products are created and updated; promotions are archived, not deleted. - **SellerSideKick products only.** A SKU that exists only on a marketplace has to be added to your SellerSideKick store from the dashboard first. ## What reaches the marketplaces - **Stock counts** reach your linked Shopee, Lazada and TikTok listings, usually within about 10 minutes. - **Prices, names, descriptions and promotions** change your SellerSideKick store and chat bot only. Marketplace prices and listings are still edited in each marketplace's seller center. ## For AI agents - Every page has a Markdown copy: add `.md` to its address, for example [/docs/update-stock.md](https://docs.sellersidekick.ai/update-stock.md). - [llms.txt](https://docs.sellersidekick.ai/llms.txt) lists every page; [llms-full.txt](https://docs.sellersidekick.ai/llms-full.txt) has them all in one file. - The [API reference](https://docs.sellersidekick.ai/reference) is generated from the OpenAPI spec at `https://api.sellersidekick.ai/v1/api/openapi.json`. --- # Quickstart: update your catalogue from Excel with an AI assistant > Connect Claude, ChatGPT or Gemini to your shop, give it a spreadsheet, check its preview and apply it. About ten minutes. This is for shop owners. You need a SellerSideKick shop on a monthly plan (such as Pro or Pro Plus) and an AI assistant that can use connectors: Claude, ChatGPT or Gemini. ## 1. Prepare the sheet One row per product, matched by **SKU** (your own product code). Use the columns you need: | Column | Example | Notes | |---|---|---| | SKU | `TM-001` | Required on every row. | | Name | `รถเหล็กโทมิก้า รุ่นรถตำรวจ` | Thai, English or both. | | Price | `139` | In THB. | | Stock | `40` | Units on the shelf now. | | Category | `Toys` | Must be one of your shop's categories. | | Brand | `Tomica` | A new brand is added for you. | | Parent SKU | `SHIRT-01` | Only for a variant (a size or colour of another product). | Any layout works; the assistant maps your columns. Keep one SKU per row. ## 2. Connect your assistant Add the SellerSideKick connector at `https://mcp.sellersidekick.ai/mcp`, sign in with your SellerSideKick account, pick the shop and tick the permissions: - `catalog:read`, reading your shop, categories, products and promotions, is always on. - `catalog:write` for prices, product details and new products. - `stock:write` for stock counts. - `promotions:write` only if you want the assistant to change promotions. Step-by-step for each assistant: [Connect ChatGPT, Claude or Gemini](https://docs.sellersidekick.ai/connect-ai-assistant). ## 3. Give it the sheet Attach the file and say what you want. For example: > Here is my price list. Update the prices in my SellerSideKick shop. Show me the preview first and wait for my OK. > This is today's stock count. Set the stock in SellerSideKick to these numbers. Preview first. > Add the new products in this sheet. The "Size" column makes variants of the main product. ## 4. Check the preview The assistant runs a **dry run** first: nothing changes yet. It should tell you which shop it is working on and show: - how many rows would be created, updated or left unchanged; - every row with an **error** and why, for example an unknown category or a SKU that exists only on a marketplace. Fix the sheet or tell the assistant how to handle the error rows. When the preview looks right, say yes. ## 5. Apply and check The assistant sends the same rows for real and reports the result. Open the dashboard at [app.sellersidekick.ai](https://app.sellersidekick.ai) to see the changes. Every change made through the connector is listed with its before and after values in **Settings → API keys**. ## What happens on the marketplaces - **Stock** reaches your linked Shopee, Lazada and TikTok listings, usually within about 10 minutes. - **Prices and product details** change your SellerSideKick store and chat bot only. Change marketplace prices in each marketplace's seller center. ## If something goes wrong - **"Not on a monthly plan":** the connector works on Pro and Pro Plus. See current plans in the app. - **A row says the SKU is not on your SellerSideKick store:** the product exists only on a marketplace. Add it to your store from the dashboard first. - **"Missing permission":** reconnect and tick the permission the assistant asked for. - More: [Errors and limits](https://docs.sellersidekick.ai/errors-and-limits). --- # Connect ChatGPT, Claude or Gemini > Add the SellerSideKick connector to your AI assistant, or create an API key for coding tools and scripts. There are two ways to let an AI work on your shop. Use the **connector** for chat assistants. Use an **API key** for coding tools, scripts and your own programs. Both work only while your shop is on a monthly plan (such as Pro or Pro Plus), and only an owner or an admin who can manage API keys can set them up. ## The connector The connector is an MCP server (Streamable HTTP) at: ``` https://mcp.sellersidekick.ai/mcp ``` When you add it, your assistant opens a SellerSideKick sign-in page. You sign in, choose **one shop**, tick the permissions and approve. The assistant never sees your password, and you can disconnect it at any time in **Settings → API keys → Connected AI assistants**. ### Permissions | Permission | Lets the assistant | Default | |---|---|---| | `catalog:read` | see the shop, its categories, products with price and stock, and promotions | always on | | `catalog:write` | create products and change prices, names, descriptions, categories and brands | ticked if the assistant asks for it | | `stock:write` | set or adjust stock | ticked if the assistant asks for it | | `promotions:write` | create, change, pause and archive promotions | off until you tick it | The sign-in page lists only what the assistant asked for. Untick what the job doesn't need; you can reconnect later with more. ### Tools the assistant gets | Tool | What it does | Needs | |---|---|---| | `whoami` | names the shop it is connected to | `catalog:read` | | `find_products` | looks up products by SKU, or page by page | `catalog:read` | | `list_categories` | lists the category names products may use | `catalog:read` | | `preview_catalog_update` | dry run of a product update: changes nothing | `catalog:write` | | `apply_catalog_update` | creates and updates products | `catalog:write` | | `preview_stock_change` | dry run of a stock change | `stock:write` | | `apply_stock_change` | sets or adjusts stock | `stock:write` | | `list_promotions` | lists the shop's own promotions | `catalog:read` | | `create_promotion` | creates a coupon, discount, bundle or BOGO | `promotions:write` | | `update_promotion` | changes a promotion's fields | `promotions:write` | | `set_promotion_status` | sets a promotion active, inactive or archived | `promotions:write` | The read tools are marked read-only and the preview tools change nothing. Most assistants ask you before they run a write tool; either way, ask yours to show you the preview and wait for your yes. ### Claude - **Claude (web and desktop):** open **Settings → Connectors**, choose **Add custom connector**, paste `https://mcp.sellersidekick.ai/mcp`, then **Connect** and sign in. On Team and Enterprise plans an owner may need to add it for the organisation first. Once SellerSideKick is listed in the Claude connectors directory you can add it from there instead. - **Claude Code:** run `claude mcp add --transport http sellersidekick https://mcp.sellersidekick.ai/mcp`, then type `/mcp` in Claude Code and choose **Authenticate**. ### ChatGPT - Turn on **developer mode** (in **Settings → Apps & Connectors → Advanced settings**), then create a connector with the URL `https://mcp.sellersidekick.ai/mcp` and OAuth sign-in. Developer mode is available on some ChatGPT plans only, and menu names can change; search ChatGPT's help for "developer mode" if you can't find it. - Once SellerSideKick is listed in the ChatGPT apps directory, you can add it from there without developer mode. ### Gemini Enterprise An administrator adds SellerSideKick as a custom MCP server: | Setting | Value | |---|---| | Transport | Streamable HTTP | | Server URL | `https://mcp.sellersidekick.ai/mcp` | | Authorization URL | `https://api.sellersidekick.ai/v1/oauth/authorize` | | Token URL | `https://api.sellersidekick.ai/v1/oauth/token` | | Scopes | `catalog:read catalog:write stock:write promotions:write` (drop the ones you don't want) | | Client ID and secret | issued by us: email team@kuztom.ai with the redirect URL Gemini Enterprise shows you | Each person then signs in with their own SellerSideKick account the first time they use it. ### Gemini CLI Add the server to `~/.gemini/settings.json`: ```json { "mcpServers": { "sellersidekick": { "httpUrl": "https://mcp.sellersidekick.ai/mcp" } } } ``` Start Gemini CLI and run `/mcp auth sellersidekick` to sign in. ### Other MCP clients Any client that supports remote MCP servers over Streamable HTTP with OAuth sign-in can connect. The server publishes its sign-in details at `https://mcp.sellersidekick.ai/.well-known/oauth-protected-resource/mcp`. ## An API key, for coding tools and scripts 1. In the dashboard ([app.sellersidekick.ai](https://app.sellersidekick.ai)), open **Settings → API keys** and choose **Create key**. 2. Name it after its job (for example "Price sheet script"), choose its **access** (full access, read only, or pick the permissions above) and when it **expires** (never, or after 30, 90 or 365 days). 3. Copy the key. It starts with `ssk_` and is shown **once**. Keep it in your tool's secret settings or an environment variable, never in a chat message or a shared file. 4. Give your tool the instructions shown on the same page. They point it at the [OpenAPI spec](https://api.sellersidekick.ai/v1/api/openapi.json) and the rules: confirm the shop, preview first, at most 500 rows per request. Check the key works: ```bash curl -H "Authorization: Bearer $SSK_API_KEY" https://api.sellersidekick.ai/v1/api/me ``` The answer names the shop the key belongs to. A key acts for one shop only and in the name of the person who created it. See [Security](https://docs.sellersidekick.ai/security) for what happens when that person leaves the shop. --- # Update prices > Change prices, names, descriptions, categories and brands of existing products by SKU, with a preview first. Prices and product details are changed by **SKU**. Only the fields you send change; everything else stays as it is. - Connector: `preview_catalog_update`, then `apply_catalog_update` (permission `catalog:write`). - API: `POST /products/upsert` with `dry_run: true`, then the same body with `dry_run: false`. ## Steps 1. **Confirm the shop.** Connector: `whoami`. API: `GET /me`. Tell the owner the shop name. 2. **Read the current values** for the SKUs in the sheet: `find_products` or `GET /products?sku=TM-001,TM-002` (up to 500 per page with `limit`). This shows what will change and which SKUs don't exist yet. 3. **Preview.** Send the rows as a dry run. Show the owner the summary and every `error` row. 4. **Apply** after a clear yes. Report the summary and any `error` rows. ## Request ```json { "dry_run": true, "products": [ { "sku": "TM-001", "price": 139 }, { "sku": "TM-002", "price": 159, "name": { "th": "รถบัสโทมิก้า", "en": "Tomica bus" } } ] } ``` - `price` is in THB, 0 or more. - `name`, `description` and `usage_information` take a string (the Thai text; the English text is kept) or `{ "th": "…", "en": "…" }` to set either or both. - `category` must be one of the names from `list_categories` / `GET /categories` (any case). `brand` may be new: it is added to the shop's brand list. - Don't send `stock` or `parent_sku` for an existing SKU: that row comes back as an error. Stock has [its own call](https://docs.sellersidekick.ai/update-stock). - At most 500 rows per request. Each SKU once per request. ## Result Each row gets a status: | Status | Meaning | |---|---| | `updated` | changed; `changed` lists the fields, e.g. `["price"]` | | `unchanged` | the values were already the same | | `created` | the SKU was new and has been created (see [Add new products](https://docs.sellersidekick.ai/add-products)) | | `error` | not applied; `error` says why | In a dry run the statuses say what would happen and nothing is written. Sending the same rows again is safe: they come back `unchanged`. ## What changes where The new price shows on your SellerSideKick store and the chat bot answers with it. **Marketplace listings keep their own prices**: change those in the Shopee, Lazada or TikTok seller center. When an owner asks for marketplace prices, say so plainly. --- # Update stock counts > Set counted stock or add and remove units by SKU, safely retried with an idempotency key. Reaches linked marketplace listings. Stock is the units **on hand** (on the shelf). **Available** is on hand minus what open orders hold: what can still be sold. - Connector: `preview_stock_change`, then `apply_stock_change` (permission `stock:write`). - API: `POST /stock`. Stock changes only through this call, never through the product update. ## Count or adjust Each row sends **one** of: - `value`: the units now on the shelf, from a count. 0 or more. - `delta`: units to add (positive) or remove (negative), for example `-3` for damaged goods. Not 0. Optional per row: - `expected_from` (with `value` only): the on-hand you read before. If it has changed since, the row is refused instead of overwritten. Use it when time passes between reading and writing. - `reason`: up to 200 characters, shown in the shop's stock history, e.g. `monthly count`. ## Request ```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", "delta": -3, "reason": "damaged in transit" } ] } ``` ## Retrying safely Give each new change its own `idempotency_key` (8–100 characters: letters, digits and `. _ : -`). If the call times out, send the **same request with the same key**: rows already applied come back `already_applied` and are not applied twice. Never reuse a key for a different change: a row with different numbers under a reused key is applied. ## Result | Status | Meaning | |---|---| | `would_update` | dry run: `from`, `to` and `available` show what would happen | | `updated` | applied: `from` (on hand before), `to` (on hand after), `available` | | `already_applied` | this row was already applied under this idempotency key | | `error` | not applied; `error` says why | A dry run checks each row's shape, that the SKU is on your SellerSideKick store and `expected_from`. It does **not** check holds by open orders, a `delta` on a SKU that was never counted, or a replay, so the real call can still answer some rows with `error` or `already_applied`. - A `delta` can't take on hand below what open orders hold. A counted `value` is recorded as counted, even below that. - Only SKUs on your SellerSideKick store: a SKU that is only on a marketplace is an error row and is never sent anywhere. ## What reaches the marketplaces Stock changes are recorded in the shop's stock history and sent to the SKU's linked Shopee, Lazada and TikTok listings, usually within about 10 minutes. --- # Add new products and variants > Create products by SKU with names, price, category, brand and opening stock, and make sizes or colours variants of a main product. New products go through the same call as updates: a SKU the shop doesn't have yet is created. - Connector: `preview_catalog_update`, then `apply_catalog_update` (permission `catalog:write`). - API: `POST /products/upsert`. ## Required fields for a new SKU | Field | Notes | |---|---| | `sku` | your product code, up to 100 characters | | `name` | a string (Thai) or `{ "th": "…", "en": "…" }` | | `price` | THB | | `category` | a name from `list_categories` / `GET /categories` | | `brand` | a new brand is added to the shop's list | Optional: `description`, `usage_information` (how to use or care for it), `stock` (the opening count on the shelf; it needs the `stock:write` permission as well as `catalog:write`), `parent_sku` (makes it a variant). ## Example ```json { "dry_run": true, "products": [ { "sku": "TM-010", "name": { "th": "รถดับเพลิงโทมิก้า", "en": "Tomica fire engine" }, "price": 189, "category": "Toys", "brand": "Tomica", "stock": 12 } ] } ``` ## Variants A variant is a size, colour or other option of a main product. Create the main product and give each variant `parent_sku`. The main product may be created in the same request: ```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 } ] } ``` - `parent_sku` must name a main product on your SellerSideKick store, or one created in the same request. It can't be another variant or a product that exists only on a marketplace. - `stock` and `parent_sku` are for **new** products only. Sending them for an existing SKU is an error row; change stock with [Update stock counts](https://docs.sellersidekick.ai/update-stock). ## Common error rows - **Unknown category:** map the sheet's category to one of the shop's names. - **The SKU exists only on a marketplace:** add it to the SellerSideKick store from the dashboard first. - **The same SKU twice** in one request: keep one row per SKU. - **Missing required field** on a new SKU: add `name`, `price`, `category` and `brand`. New products appear on the SellerSideKick store and the chat bot can recommend them. A new product's opening stock reaches a marketplace listing once a listing with the same SKU is linked to it. --- # Promotions > Create coupons, discounts, bundles and buy-X-get-Y offers, change them, pause them or end them by archiving. These are the shop's own SellerSideKick promotions. They apply on the SellerSideKick store and in the chat bot. Marketplace promotions (Shopee, Lazada, TikTok) are not listed or changed here: manage them in each seller center. | Job | Connector tool | API | |---|---|---| | List, find an id, check a code is free | `list_promotions` | `GET /promotions?type=&status=` | | Create | `create_promotion` | `POST /promotions/{type}` | | Change fields | `update_promotion` | `PATCH /promotions/{type}/{id}` | | Pause, resume, end | `set_promotion_status` | `POST /promotions/{type}/{id}/status` | Listing needs `catalog:read`; the other three need `promotions:write`. ## The four types - **`coupon`**: a code the buyer enters. `discount_type` (`percentage` or `fixed`), `value`, `applies_to`; optional `min_order_amount`, `max_discount_amount` (a THB cap on a percentage) and `can_stack` (usable with other promotions). - **`discount`**: the same fields as a coupon, without `can_stack`. - **`bundle`**: buy these products together for less. `products` (2 or more, each `sku` and `quantity`) and `bundle_discount_type`: `fixed` with `bundle_discount` (THB) or `percentage` with `bundle_discount_percentage`. - **`bogo`**: buy X, get Y. `products` with `role` `buy` or `get`, at least one of each. Every type also has `code`, `name`, `starts_at` and `ends_at` (required on create), and optional `description`, `max_uses`, `max_uses_per_customer` and, on create, `status` (`active` by default, or `inactive`). - `code`: letters, digits, `-` and `_`, up to 64 characters; lower case is upper-cased. Unique in the shop. - `applies_to`: `{ "all_products": true }`, or any mix of `categories`, `brands` and `skus`, not both. - Dates are ISO 8601. Send Thai time with its offset, e.g. `2026-11-01T00:00:00+07:00`. `ends_at` must be after `starts_at`. - A percentage can't be more than 100. SKUs must be on your SellerSideKick store. ## Create `POST /promotions/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 } ``` Confirm the code, dates, amount and products with the owner first. **Creating is not safe to retry**: if a call fails or times out, list the promotions to see whether it was created before sending it again. A code the shop already uses is refused. ## Change Send only the fields that change, for example `{ "ends_at": "2026-12-15T23:59:59+07:00" }`. The type in the path must match the promotion's type. The change is checked against the promotion as it will be afterwards: the dates stay in order, a percentage stays at 100 or below, a BOGO keeps a buy and a get product, a bundle keeps 2 or more products. A code that another promotion of the shop uses is refused (`409`). ## Pause, resume or end `{ "status": "inactive" }` pauses, `active` resumes, `archive` ends it for good. Nothing is deleted: an archived promotion stays in the history. Confirm with the owner before archiving. --- # Errors and limits > Every error code with its cause and fix, the per-row errors, rate limits, Retry-After, idempotency keys and the 500-row limit. Every error answers `{ "code": , "message": "…", "rcode": "…" }`. Branch on `rcode` (stable) and show `message` to people. Some errors have no `rcode`; use the HTTP status for those. The connector returns the backend's message and `rcode` inside the tool result, marked as an error. A permission the connection lacks is refused by the connector itself before any call, with a message naming the permission. ## Error codes | HTTP | rcode | Cause | Fix | |---|---|---|---| | 400 | none | The body is invalid. The message names the field, e.g. `"products[0].prize" is not allowed`. Unknown fields are refused by name. | Fix the field and send again. | | 401 | `Y20` | No `Authorization: Bearer` header. | Send `Authorization: Bearer ssk_…`. | | 401 | `Y21` | The key is unknown or revoked. | Ask the owner for a valid key. Don't retry with the same key. | | 401 | `Y22` | The key has expired. | The owner creates a new key in **Settings → API keys**. | | 403 | `Y31` | The shop is not on a monthly plan. | The owner moves the shop to a monthly plan. Retrying won't help. | | 403 | `Y32` | The shop is not active (deactivated or archived). | Tell the owner. Retrying won't help. | | 403 | `Y34` | The person who created the key, or approved the connection, left the shop or can no longer manage API keys. | An owner or admin creates a new key or connects again. | | 403 | `Y35` | The key or connection doesn't have the permission this call needs (`catalog:write`, `stock:write` or `promotions:write`). | Create a key with that permission, or reconnect and tick it. | | 401 | `Y41` | Connector only: the assistant's sign-in expired or isn't valid. | The assistant signs in again (most do this on their own). | | 401 | `Y42` | Connector only: the owner disconnected the assistant. | The owner connects it again. | | 403 | none | A marketplace promotion: read-only here. | Change it in the marketplace's seller center. | | 404 | none | No promotion with this id and type in this shop. | List promotions to get a valid id. | | 409 | none | On a promotion update: the new code is already used by another promotion of the shop. | Pick another code. | | 429 | `R01` | Too many requests. | Wait the `Retry-After` seconds, then send the same request again. | On creating a promotion, an unknown SKU or a code the shop already uses is a `400` whose message says which. ### Key management in the dashboard | HTTP | rcode | Cause | |---|---|---| | 403 | `Y31` | Creating a key while the shop is not on a monthly plan. | | 409 | `Y33` | The shop already has 10 live keys: revoke one first. | | 404 | `Y30` | Revoking a key this shop doesn't have. | ## Row errors Write calls answer `200` with one result per row, in the order sent, and a `summary` with the count per status plus `total`. A bad row never stops the others. A row with status `error` carries an `error` message. Report every one to the owner. Common row errors: - an unknown category; - a SKU that exists only on a marketplace, not on the SellerSideKick store; - `stock` or `parent_sku` sent for an existing product; - a new product missing `name`, `price`, `category` or `brand`; - a `parent_sku` that is a variant, a marketplace-only product or doesn't exist; - the same SKU twice in one request; - stock: `expected_from` no longer matches on hand, or a `delta` would go below what open orders hold, or a `delta` on a SKU that was never counted. ## Limits | Limit | Value | |---|---| | Rows per write request | 500 (products, stock items). Split bigger sheets. | | Requests per shop | 120 a minute, however many keys or connections | | Requests per caller (IP) | 600 a minute, counted before the key is checked | | Products per page | `limit` up to 500, default 100 | | `idempotency_key` | 8–100 characters: letters, digits, `.` `_` `:` `-` | | Stock `reason` | 200 characters | | Product `name` / `description` | 500 / 10,000 characters per language | | Promotion `code` / `name` / `description` | 64 / 200 / 2,000 characters | | Products in a bundle or BOGO | up to 50 (a bundle needs 2 or more) | | Live API keys per shop | 10 | ## Retrying - **429:** wait the number of seconds in the `Retry-After` header, then resend the same request. - **Product updates** (`POST /products/upsert`): safe to resend; rows already applied come back `unchanged`. - **Stock** (`POST /stock`): safe to resend **with the same `idempotency_key`**; applied rows come back `already_applied`. Use a new key for each new change. - **Promotion create:** not safe. List promotions first to see whether it was created. - **Promotion update and status:** safe to resend. - **401 and 403:** don't retry; tell the owner what the message says. --- # Glossary > The terms used in these docs and the API, each with the Thai word sellers use for it. When a shop owner writes in Thai, these are the words they are likely to use. | Term | Thai | Meaning | |---|---|---| | SKU | รหัสสินค้า (SKU) | The shop's own code for one product or variant. Products are matched by it. | | Product | สินค้า | One item on the SellerSideKick store. | | Variant | ตัวเลือกสินค้า | A size, colour or other option of a main product. Set with `parent_sku`. | | Main product (parent) | สินค้าหลัก | The product a variant belongs to. | | Category | หมวดหมู่ | One of the shop's top-level product groups. | | Brand | แบรนด์ | The product's brand. | | Price | ราคา | The selling price on the SellerSideKick store, in THB (บาท). | | Stock | สต็อก | Units of a product. | | On hand | สต็อกคงเหลือ | Units on the shelf now (`stock.on_hand`). | | Available | พร้อมขาย | On hand minus what open orders hold: what can still be sold (`stock.available`). | | Stock count | นับสต็อก | Setting on hand to a counted number (`value`). | | Adjustment | ปรับสต็อก | Adding or removing units (`delta`). | | Promotion | โปรโมชัน | Any of the shop's offers below. | | Coupon | คูปอง | A code the buyer enters for a discount. | | Discount | ส่วนลด | A price cut on chosen products, by percent or a fixed amount. | | Bundle | เซ็ตสินค้า / ซื้อเป็นชุด | Products bought together for less. | | BOGO | ซื้อ 1 แถม 1 / ซื้อ X แถม Y | Buy these products, get those free. | | Code | โค้ด | A promotion's code, e.g. `WELCOME10`. | | Active / inactive | เปิดใช้งาน / ปิดชั่วคราว | A promotion that is running or paused. | | Archive | เก็บถาวร / ปิดถาวร | Ending a promotion for good. It stays in the history; nothing is deleted. | | Dry run / preview | ทดลองก่อน (ยังไม่บันทึก) | A request that reports what would happen and changes nothing. | | Idempotency key | รหัสกันทำซ้ำ | Your id for one stock change, so a retry is never applied twice. | | Marketplace | มาร์เก็ตเพลส | Shopee, Lazada or TikTok Shop. | | Seller center | Seller Center / ศูนย์ผู้ขาย | Each marketplace's own back office, where its prices and listings are edited. | | Listing | รายการสินค้าบนมาร์เก็ตเพลส | A product's page on a marketplace, linked to the SKU. | | API key | คีย์ API | A secret (`ssk_…`) that lets a program work on one shop. | | Connector | ตัวเชื่อมต่อ (Connector) | The way an AI assistant connects to SellerSideKick, at `https://mcp.sellersidekick.ai/mcp`. | | Permission (scope) | สิทธิ์ | What a key or connection may do: `catalog:read`, `catalog:write`, `stock:write`, `promotions:write`. | | Revoke | ยกเลิกสิทธิ์ | Switching a key or a connection off for good. | | Monthly plan | แพ็กเกจรายเดือน | A monthly subscription, such as Pro or Pro Plus. Needed for API keys and the connector. | | Coins | เหรียญ | Basic's pay-as-you-go credit. | | Chat bot | แชทบอท | SellerSideKick's AI that answers customers. | | LINE OA | LINE OA (LINE Official Account) | A shop's business account on LINE. | --- # Security > How API keys and assistant connections are stored, limited and revoked, what they can never do, and what reaches the marketplaces. ## API keys - **Shown once.** A key (`ssk_` and 40 characters) is shown only when it is created. We store a one-way hash of it and its first 12 characters, so we can tell keys apart but can never show the key again. Lost a key? Revoke it and create a new one. - **One key, one shop.** The key decides the shop; a request can't name another shop. - **Up to 10 live keys per shop.** Give each tool its own key, so you can revoke one without breaking the others. - **Permissions.** A key can have full access (the default), read only, or chosen permissions: `catalog:read` (always included), `catalog:write`, `stock:write`, `promotions:write`. A call outside them is refused with `Y35`. - **Expiry.** A key can expire after 30, 90 or 365 days, or never. The person who created it gets a reminder email 7 days before. An expired key is refused with `Y22`. - **Revoke at any time** in **Settings → API keys**. It stops working at once; the record is kept for the history. - **Keep it secret.** Only in the `Authorization: Bearer` header. Never in a URL, a shared file, a spreadsheet or a chat message. ## Assistant connections - **You sign in with SellerSideKick**, on our own page, and choose one shop and its permissions. The assistant never sees your password or an API key. - The assistant gets **short-lived tokens** limited to that shop and those permissions. We check the connection on every call. - **Disconnect at any time** in **Settings → API keys → Connected AI assistants**. The next call is refused. - The sign-in page says who is asking: the assistant's name, its web address, and a **Verified** badge only for assistants we know. ## Who can do it - Only a shop owner, or an admin with permission to manage API keys, can create keys or connect an assistant. - Keys and connections act **in the name of the person who created them**. Changes show that person in the history. - **If that person leaves the shop**, or loses the permission to manage API keys, their keys and connections stop working (`Y34`). Someone who still manages keys creates new ones. - Everything works only while the shop is on a **monthly plan** (such as Pro or Pro Plus). On Basic (pay-as-you-go coins), existing keys are kept but refused with `Y31`, and work again when the shop is back on a monthly plan. ## What they can never do - **Delete anything.** Products are created and updated, never deleted. Promotions are archived, never deleted. - **Touch marketplace data.** Only products on your SellerSideKick store and your own SellerSideKick promotions can be changed. A SKU that exists only on a marketplace, and every marketplace promotion, is refused. - **Reach another shop.** - **See customers, chats or orders.** The API and the connector cover the catalogue, stock and promotions only. ## What reaches the marketplaces - **Stock** reaches your linked Shopee, Lazada and TikTok listings, usually within about 10 minutes, exactly as a change in the dashboard does. - **Prices, names, descriptions and promotions** stay on your SellerSideKick store and chat bot. Marketplace prices and listings are edited in each seller center. ## Seeing what changed **Settings → API keys** shows how many calls each key made today and in the last 30 days (calls, errors, rate-limited calls, dry runs, rows written), and a log of every change made through a key or an assistant: when, which key or assistant, the SKU or promotion code, the field, and its value before and after. ## Report a problem Email team@kuztom.ai. If a key may have leaked, revoke it first. --- # Changelog > Changes to the shop API, the AI-assistant connector and these docs, newest first. ## Next release: a discount with a rate per product - **New:** `POST /v1/api/promotions/discount` and `PATCH /v1/api/promotions/discount/{id}` take `product_discounts: [{ sku, discount_type, value }]`, a rate per SKU off every unit of it (up to 500 SKUs), instead of `discount_type`, `value` and `applies_to`. One promotion holds the whole sale, so it switches on and off at once. It reads back the same way it is written. ## Next release: stock read-back and simpler variants - **Fixed:** `GET /v1/api/products` answered `stock: { on_hand: null, available: null }` for every product. It now returns the counted and sellable numbers. - **Variants:** a new variant (`parent_sku`) that gives no `category` or `brand` takes the main product's. Anything the row gives still wins. - **Clearer errors:** a new product missing several required fields is told all of them at once, for example `A new product needs name, price and brand.` ## Permissions, key expiry and the AI-assistant connector - **Connector** at `https://mcp.sellersidekick.ai/mcp` for Claude, ChatGPT, Gemini Enterprise and Gemini CLI. You sign in with SellerSideKick and choose the shop and permissions. See [Connect ChatGPT, Claude or Gemini](https://docs.sellersidekick.ai/connect-ai-assistant). - **Permissions on API keys:** `catalog:read`, `catalog:write`, `stock:write`, `promotions:write`. Existing keys and new keys default to full access. A call outside a key's permissions answers `403 Y35`. - **Key expiry:** never, 30, 90 or 365 days, with a reminder email 7 days before. An expired key answers `401 Y22`. - **Connected AI assistants** are listed in **Settings → API keys**, each with a Disconnect button. **Recent API changes** there shows what keys and assistants changed, before and after. - **Change log** in **Settings → API keys**: every change made through a key or an assistant, with the value before and after. - These docs, `llms.txt` and a Markdown copy of every page. ## 2026-10-25: OpenAPI spec 1.1.0 and usage counters - The OpenAPI spec is published at `https://api.sellersidekick.ai/v1/api/openapi.json`, written for AI agents: when to call each operation, what to check first, whether a retry is safe, with examples. - **Usage counters** per key and per shop in **Settings → API keys**: calls, errors, rate-limited calls, dry runs and rows written, today and over the last 30 days. --- # 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.