Skip to content

Build a sale page or website ​

A sale page usually does four things: show product cards, show one product with its options, take an order, and ship it from the right place. This guide maps each one to a call.

product/list  ──►  product/detail  ──►  order/create  ──►  order/detail
 (cards)            (variants,           (one line per      (tracking
                     prices, stock)       variant)           number)

1. Product cards: POST /v1/product/list ​

The list has one row per product, in the small shape a product card needs:

bash
curl -sS -X POST "$BASE_URL/v1/product/list" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 20, "offset": 0, "query": "หมวก", "sort_by": "update_time", "sort_order": "desc", "get_count": true}'
json
{
  "request_id": "req_01K5A9F3T7Q2WPRB8N0MZDXCV4",
  "products": [
    {
      "id": "569040",
      "name": "SS ชุดเด็ก 3 ชิ้น พร้อมหมวกสุดเท่",
      "image_url": "https://cf.shopee.co.th/file/sg-11134283-8259r-mti2amlllrlu1f"
    }
  ],
  "total": 975,
  "has_more": true
}
FieldValues
limit1–100. Default 20.
offsetDefault 0.
queryOnly products whose name contains this text, ignoring case.
sort_byupdate_time (default), create_time, name or id.
sort_orderdesc (default) or asc.
get_countAlso return total. Default false.

Variants, prices and stock are not in the list. A product can have a hundred variants, so the list stays one row per product.

Paging: keep adding limit to offset while has_more is true. Ask for get_count only once, on the first page, if you want to show a total: counting every match costs a query of its own. If the store edits products while you page, one can move between pages. Sort by id when you need every product exactly once, for example to sync a catalogue.

2. The product page: POST /v1/product/detail ​

When a buyer opens a product, read it in full:

bash
curl -sS -X POST "$BASE_URL/v1/product/detail" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"product_id": "569032"}'

What you will use from the response:

FieldUse it for
product.name, product.descriptionThe page text, as the store wrote it.
product.img_uris[]The gallery. thumbnail_uris[] and tiny_img_uris[] are the same pictures, smaller, in the same order.
product.variants[].nameThe option the buyer picks, e.g. "Yellow". Empty for a product sold in only one form. Then show the product's name alone.
product.variants[].product_variant_idWhat the order line sends.
product.variants[].prices[]{price_tier_id, price}: the variant's price in each price tier (ราคาขาย group).
product.variants[].available_qtyWhat can still be sold (พร้อมขาย). Can be negative when the store allows selling below zero.
product.variants[].img_urlThe variant's own picture, when it has one. If not, fall back to the product's.

A product from another store, a deleted one and an id that never existed all answer the same 404 not_found.

Stock on a busy page

Rather than reading product/detail on every page view to show stock, subscribe to the stock_available_updated webhook and keep your own copy of available_qty up to date.

3. The order: POST /v1/order/create ​

Each cart line becomes one entry in products, named by product_variant_id (or by sku, but not both):

json
{
  "external_order_id": "SALEPAGE-10231",
  "shipping_type": "spx_pickup",
  "channel": "sales_page",
  "recipient_address": {
    "name": "คุณทดสอบ ระบบ",
    "telephone": "0556789201",
    "address1": "51/102 บางปะกง",
    "sub_district": "บางปะกง",
    "district": "บางปะกง",
    "province": "ฉะเชิงเทรา",
    "postal_code": 24130
  },
  "products": [{ "product_variant_id": "1984307", "qty": 1, "price": 799 }],
  "shipping_fee": 30
}
  • Use your own cart or checkout id as external_order_id, so a double-click or a network retry never creates two orders.
  • Tag the order with "channel": "sales_page" (or another sales channel) so it shows under the right channel in the store's sales reports.
  • price is optional. Leave it out and the store's own price is used.

Amounts, COD and retries are covered in Create and track orders.

4. Shipping from the right place ​

If the store ships from one place, do nothing: leave sender_address_id out and the parcel is sent from the store's primary address (ที่อยู่หลัก).

If it ships from several warehouses or branches, call POST /v1/store/address/list once, and keep the ids you need on your side:

bash
curl -sS -X POST "$BASE_URL/v1/store/address/list" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' -d '{}'
json
{
  "request_id": "req_01K5A9F3T7Q2WPRB8N0MZDXCV4",
  "addresses": [
    { "id": "3653293", "is_primary": true,  "name": "คลังสินค้าหลัก", "province": "กทม", "postal_code": 10160 },
    { "id": "3710939", "is_primary": false, "name": "สาขาใหม่", "province": "ฉะเชิงเทรา", "postal_code": 24130 }
  ],
  "has_more": false
}

Then send the cached id as sender_address_id when you create an order.

Do not list addresses before every order

A store's own addresses are its branches and warehouses. They are set up once and rarely change, and their ids never change. Listing them before every order turns a one-call order into two, makes checkout slower and brings you closer to the rate limits. Refresh your cache only when the addresses change in XSelly: someone adds a warehouse, moves the primary tick, or an id you stored stops working.

Addresses come back primary first, then oldest first. That is the same order the sender fallback uses, so the first address of the first page is the one an order with no sender_address_id ships from.

Your customers' addresses are not in this list. They belong to the store's contacts. To reuse a customer's address, send the recipient_address_id of one of their earlier orders instead of recipient_address.

XSelly Open Platform API v1 · Webhooks v2