Base URL: https://api.sellersidekick.ai/v1/api. Send the shop's key on every request as Authorization: Bearer ssk_….
How to use this API (from the spec)
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 /meshows both. The permissions arecatalog:read(always: shop, categories, products, promotions),catalog:write(create and update products; an openingstockon a new product also needsstock:write),stock:writeandpromotions:write. A call the key doesn't allow answers 403Y35naming 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#
- Call
GET /meto confirm which shop the key is for, and tell the owner the shop name. - Read before writing:
GET /categoriesfor the allowed category names;GET /products?sku=...for the current prices and stock.
- Send every write with
dry_run: truefirst. Show the owner the summary and every row with statuserror, and wait for a clear yes. - Send the same request with
dry_run: false. Report the summary and everyerrorrow.
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 throughPOST /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 theRetry-Afterseconds 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.
Shop
Which shop the key belongs to, and its categories.
GET/me#
Which shop this key belongs to.
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
{
"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.
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
{
"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.
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: setlimit(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
{
"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).
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,categoryandbrand, and may setstock(the opening count) andparent_sku(to make it a variant of a main product, which may be created in the same request). - Each row answers
created,updated(withchanged),unchangedorerror.
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 (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
{
"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
{
"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
{
"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
{
"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).
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) withvalue. A row whose on-hand moved meanwhile is refused instead of overwritten. - Send
dry_run: truefirst 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
deltacan't take on-hand below what open orders hold. A countedvalueis 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 (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
{
"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
{
"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
{
"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.
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
{
"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.
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 (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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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.
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 (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
{
"ends_at": "2026-12-15T23:59:59+07:00"
}Raise a discount to 15% raise
{
"value": 15
}Response 200
The promotion as saved.
ok
{
"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).
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 (required)
| Field | Type | Required | Description |
|---|---|---|---|
status | "active" | "inactive" | "archive" | yes |
Request examples
pause
{
"status": "inactive"
} end
{
"status": "archive"
}Response 200
The promotion with its new status.
ok
{
"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.