{
  "info": {
    "_postman_id": "7c1f0a6e-2f4d-4c5a-9c21-0b8d3a5f1e01",
    "name": "XSelly Open Platform",
    "description": "OAuth2 client_credentials auth for the XSelly Open Platform, plus the v1 API: POST /v1/order/create, POST /v1/order/detail, POST /v1/store/address/list, POST /v1/product/list and POST /v1/product/detail.\n\n## Setup\n\n1. Import XSelly_Open_Platform.postman_environment.json next to this collection and pick it. Fill in its base_url with your base URL, which is shown in the XSelly app (ask the XSelly team if you cannot find it).\n2. Put your channel's `client_id` and `client_secret` into the environment (they are issued per open platform channel; the secret is shown once at creation).\n3. Send **Create order**. The collection fetches an access token for you when there is none or it is about to expire, so a single click proves the whole auth path.\n\n## How auth works\n\n`POST /oauth/token` exchanges the client credentials for a JWT that is valid for 4 hours (`expires_in` = 14400). Send it as `Authorization: Bearer <token>` on every `/v1/...` call.\n\n**Cache the token and reuse it until it expires.** The token endpoint is rate limited (10 requests/minute per client_id, 60/minute per IP) and answers `429 slow_down` with a `Retry-After` header if you mint one per API call. Ten failed authentications in five minutes block the client_id and the IP for 15 minutes.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{access_token}}",
        "type": "string"
      }
    ]
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Fetches an access token when the stored one is missing or within a",
          "// minute of expiry, so every protected request just works. Requests that",
          "// must run unauthenticated opt out by setting their auth type to No Auth.",
          "const url = pm.request.url.toString();",
          "const optedOut = pm.request.auth && pm.request.auth.type === 'noauth';",
          "const needsToken = !optedOut && !url.includes('/oauth/token');",
          "",
          "const readVar = (key) => pm.environment.get(key) || pm.collectionVariables.get(key) || '';",
          "const writeVar = (key, value) => {",
          "    if (pm.environment.name) { pm.environment.set(key, value); } else { pm.collectionVariables.set(key, value); }",
          "};",
          "",
          "const expiresAt = Number(readVar('token_expires_at') || 0);",
          "const isFresh = readVar('access_token') && expiresAt - Date.now() > 60 * 1000;",
          "",
          "if (needsToken && !isFresh) {",
          "    const clientId = readVar('client_id');",
          "    const clientSecret = readVar('client_secret');",
          "    if (!clientId || !clientSecret) {",
          "        throw new Error('Set client_id and client_secret in the selected environment first.');",
          "    }",
          "    pm.sendRequest({",
          "        url: pm.variables.replaceIn('{{base_url}}/oauth/token'),",
          "        method: 'POST',",
          "        header: { 'Content-Type': 'application/x-www-form-urlencoded' },",
          "        body: {",
          "            mode: 'urlencoded',",
          "            urlencoded: [",
          "                { key: 'grant_type', value: 'client_credentials' },",
          "                { key: 'client_id', value: clientId },",
          "                { key: 'client_secret', value: clientSecret }",
          "            ]",
          "        }",
          "    }, (err, res) => {",
          "        if (err) { throw err; }",
          "        if (res.code !== 200) {",
          "            throw new Error('token request failed: ' + res.code + ' ' + res.text());",
          "        }",
          "        const body = res.json();",
          "        writeVar('access_token', body.access_token);",
          "        // Refresh a minute early rather than racing the expiry.",
          "        writeVar('token_expires_at', Date.now() + (body.expires_in - 60) * 1000);",
          "        console.log('fetched a new access token, valid for ' + body.expires_in + 's');",
          "    });",
          "}"
        ]
      }
    }
  ],
  "variable": [
    {
      "key": "base_url",
      "value": "",
      "type": "string"
    },
    {
      "key": "client_id",
      "value": "",
      "type": "string"
    },
    {
      "key": "client_secret",
      "value": "",
      "type": "string"
    },
    {
      "key": "access_token",
      "value": "",
      "type": "string"
    },
    {
      "key": "token_expires_at",
      "value": "0",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Get access token",
      "request": {
        "auth": {
          "type": "noauth"
        },
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/x-www-form-urlencoded"
          }
        ],
        "body": {
          "mode": "urlencoded",
          "urlencoded": [
            {
              "key": "grant_type",
              "value": "client_credentials",
              "type": "text"
            },
            {
              "key": "client_id",
              "value": "{{client_id}}",
              "type": "text"
            },
            {
              "key": "client_secret",
              "value": "{{client_secret}}",
              "type": "text"
            }
          ]
        },
        "url": {
          "raw": "{{base_url}}/oauth/token",
          "host": [
            "{{base_url}}"
          ],
          "path": [
            "oauth",
            "token"
          ]
        },
        "description": "OAuth2 client_credentials grant. Credentials go in the form body; HTTP Basic client authentication is not accepted.\n\nStores `access_token` in the active environment so the other requests can use it. Every call mints a new token — cache it for its full 4 hours instead of calling this before each request."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "const writeVar = (key, value) => {",
              "    if (pm.environment.name) { pm.environment.set(key, value); } else { pm.collectionVariables.set(key, value); }",
              "};",
              "",
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "if (pm.response.code === 200) {",
              "    const body = pm.response.json();",
              "    pm.test('returns a bearer token', () => {",
              "        pm.expect(body.token_type).to.eql('Bearer');",
              "        pm.expect(body.access_token).to.be.a('string').and.not.empty;",
              "    });",
              "    pm.test('valid for 4 hours', () => pm.expect(body.expires_in).to.eql(14400));",
              "    writeVar('access_token', body.access_token);",
              "    writeVar('token_expires_at', Date.now() + (body.expires_in - 60) * 1000);",
              "}"
            ]
          }
        }
      ]
    },
    {
      "name": "List store addresses",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{base_url}}/v1/store/address/list",
          "host": [
            "{{base_url}}"
          ],
          "path": [
            "v1",
            "store",
            "address",
            "list"
          ]
        },
        "description": "Your store's own addresses — the ตั้งค่าร้าน → ที่อยู่ร้าน list. These are the ids a sender_address_id can name, so run this once instead of hard-coding them. Your customers' addresses are not in it.\n\nDO NOT call this often from your own integration. A store's branches and warehouses barely change, and their ids never do: call it once, cache the sender_address_id you need, and refresh only when the addresses actually change in XSelly. Listing them before every order turns a one-call order into two, and is the most common way to make an otherwise good integration slow.\n\nThe body is optional: `{}`, nothing at all, or `{\"limit\": 50, \"offset\": 50}` (limit 1-200, default 100).\n\nAddresses come back primary-first then oldest-first, so the FIRST one is what an order with no sender_address_id is sent from. `is_primary` is the ที่อยู่หลัก tick the store owner sets in the app; several addresses may carry it, and then the oldest of those wins.\n\nIt stores that id in the `sender_address_id` environment variable, ready to paste into Create order — which omits the field by default, so orders go out from the store's default sender."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "if (pm.response.code === 200) {",
              "    const body = pm.response.json();",
              "    pm.test('returns addresses', () => pm.expect(body.addresses).to.be.an('array'));",
              "    pm.test('primary addresses come first', () => {",
              "        const flags = body.addresses.map((a) => a.is_primary);",
              "        pm.expect(flags.indexOf(true)).to.be.oneOf([-1, 0]);",
              "    });",
              "    if (body.addresses.length > 0) {",
              "        const first = body.addresses[0];",
              "        const write = (k, v) => { if (pm.environment.name) { pm.environment.set(k, v); } else { pm.collectionVariables.set(k, v); } };",
              "        write('sender_address_id', first.id);",
              "        console.log('default sender address', first.id, first.name);",
              "    }",
              "}"
            ]
          }
        }
      ]
    },
    {
      "name": "List products",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"limit\": 20,\n  \"offset\": 0,\n  \"sort_by\": \"update_time\",\n  \"sort_order\": \"desc\",\n  \"get_count\": true\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{base_url}}/v1/product/list",
          "host": [
            "{{base_url}}"
          ],
          "path": [
            "v1",
            "product",
            "list"
          ]
        },
        "description": "Your store's products, one row each — id, name and main picture — for a sale page's product cards. Variants, prices and stock are not in it: a product can carry a hundred variants, so those come from Product detail for the product a buyer opens.\n\nThe body is optional: `{}`, nothing at all, or any of `limit` (1-100, default 20), `offset`, `query` (name contains, ignoring case), `sort_by` (`update_time` default, `create_time`, `name`, `id`), `sort_order` (`desc` default, `asc`) and `get_count` (default false; true adds `total`, which costs a COUNT query — ask once, not per page). Page with `offset += limit` while `has_more` is true.\n\nIt stores the first product's id in the `product_id` environment variable, ready for Product detail."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "if (pm.response.code === 200) {",
              "    const body = pm.response.json();",
              "    pm.test('returns products', () => pm.expect(body.products).to.be.an('array'));",
              "    pm.test('one row per product, no variants', () => body.products.forEach((p) => pm.expect(p).to.not.have.property('variants')));",
              "    pm.test('total only when asked', () => pm.expect(body).to.have.property('total'));",
              "    if (body.products.length > 0) {",
              "        const write = (k, v) => { if (pm.environment.name) { pm.environment.set(k, v); } else { pm.collectionVariables.set(k, v); } };",
              "        write('product_id', body.products[0].id);",
              "    }",
              "}"
            ]
          }
        }
      ]
    },
    {
      "name": "Product detail",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"product_id\": \"{{product_id}}\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{base_url}}/v1/product/detail",
          "host": [
            "{{base_url}}"
          ],
          "path": [
            "v1",
            "product",
            "detail"
          ]
        },
        "description": "One product in full: pictures, description, category and every variant with its SKU, prices per price tier (`prices[].price_tier_id`) and stock (`available_qty`, `on_hand`, `reserved_qty`, per warehouse too). Run List products first — it fills `product_id`.\n\nThe id is looked up within your store; another store's product, a deleted one and an unknown id all answer 404 not_found.\n\nIt stores the first variant's `product_variant_id` in the environment, ready for Create order."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "if (pm.response.code === 200) {",
              "    const product = pm.response.json().product;",
              "    pm.test('returns variants', () => pm.expect(product.variants).to.be.an('array'));",
              "    pm.test('ids are strings', () => pm.expect(product.id).to.be.a('string'));",
              "    if (product.variants.length > 0) {",
              "        const write = (k, v) => { if (pm.environment.name) { pm.environment.set(k, v); } else { pm.collectionVariables.set(k, v); } };",
              "        write('product_variant_id', product.variants[0].product_variant_id);",
              "    }",
              "}"
            ]
          }
        }
      ]
    },
    {
      "name": "Create order",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"external_order_id\": \"SALEPAGE-{{$timestamp}}\",\n  \"shipping_type\": \"ems\",\n  \"is_cod\": false,\n  \"channel\": \"sales_page\",\n  \"recipient_address\": {\n    \"name\": \"คุณทดสอบ ระบบ\",\n    \"address1\": \"51/102 บางปะกง\",\n    \"sub_district\": \"บางปะกง\",\n    \"district\": \"บางปะกง\",\n    \"province\": \"ฉะเชิงเทรา\",\n    \"postal_code\": 24130,\n    \"telephone\": \"0556789201\"\n  },\n  \"products\": [\n    {\n      \"product_variant_id\": \"{{product_variant_id}}\",\n      \"qty\": 1\n    }\n  ],\n  \"shipping_fee\": 30,\n  \"discount\": 15,\n  \"shipping_label_note\": \"กรุณาโทรก่อนส่ง 15 นาที\",\n  \"private_note\": \"ลูกค้าประจำ\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{base_url}}/v1/order/create",
          "host": [
            "{{base_url}}"
          ],
          "path": [
            "v1",
            "order",
            "create"
          ]
        },
        "description": "Creates an order in the store your channel belongs to — there is no store_id in the body. Set `product_variant_id` in the environment to one of that store's variant ids first — or swap the line for `{\"sku\": \"YOUR-SKU\", \"qty\": 1}`, since a line names its product by either product_variant_id or sku (one of them, never both). A sku shared by several variants of the store is refused, with the ids that share it.\n\n`shipping_type` is a shipping_types.name — nearly every courier the app offers works here (ems, ecopost, kex, flash, jt, scg, spx_pickup, lalamove, store_front, ...). `is_cod: true` switches to that courier's COD row and then requires cod_fee and cod_amount.\n\n`discount` is positive and comes OFF the total: 15 means 15 baht off. shipping_fee and other_fee are added on.\n\nshipping_label_note prints on the shipping label; private_note is yours alone. ship_deadline_time (epoch ms) sets วันกำหนดส่ง.\n\n`external_order_id` is your own order id and makes retries safe: a second call with the same one returns the first order instead of creating a twin. This request stamps a timestamp into it so repeated clicks create separate orders — send a fixed value to see the idempotent answer.\n\nEvery id — product_variant_id, sender_address_id, recipient_address_id, order_id — is a JSON STRING: \"1984193\", never 1984193. A bare number is rejected with 400.\n\nsender_address_id is optional and left out here: with no value the parcel goes out from the store's primary address (ที่อยู่หลัก). Send it only when you ship from somewhere else.\n\nUnknown fields are rejected with 400: a misspelled key would otherwise create a wrong order silently. See xselly_open_platform_api_v1.md for the full field list, or xselly_open_platform_api_v1.openapi.yaml for the OpenAPI 3.2.1 description of it."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "if (pm.response.code === 200) {",
              "    const order = pm.response.json().order;",
              "    pm.test('returns the created order id', () => pm.expect(order.id).to.be.a('string'));",
              "    pm.test('echoes the shipping type key', () => pm.expect(order.shipping_type).to.eql('ems'));",
              "    pm.test('ids are strings', () => pm.expect(order.products[0].product_variant_id).to.be.a('string'));",
              "    console.log('created order', order.id, 'total', order.total_amount);",
              "    if (pm.environment.name) { pm.environment.set('order_id', order.id); } else { pm.collectionVariables.set('order_id', order.id); }",
              "}"
            ]
          }
        }
      ]
    },
    {
      "name": "Get order detail",
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"order_id\": \"{{order_id}}\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{base_url}}/v1/order/detail",
          "host": [
            "{{base_url}}"
          ],
          "path": [
            "v1",
            "order",
            "detail"
          ]
        },
        "description": "Reads one order. Send EITHER order_id or external_order_id in the body, never both — swap the line for {\"external_order_id\": \"YOUR-ID\"} to try the other form.\n\nA POST, not a GET, so the ids stay out of query strings, access logs and proxy history.\n\n`order_id` resolves within your channel's store, so it also reads orders keyed into the XSelly app. `external_order_id` resolves within your channel alone — another channel's order of the same name is invisible.\n\n**Create order** stores the id it created in the `order_id` environment variable, so this request works straight after it.\n\nEverything out of scope answers the same 404. Poll this for `shipments[].tracking_number` after creating an order: XShipping books the courier moments later, so a just-created order has none yet."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "",
              "if (pm.response.code === 200) {",
              "    const order = pm.response.json().order;",
              "    pm.test('returns an order', () => pm.expect(order.id).to.be.a('string'));",
              "    pm.test('states are numbers', () => {",
              "        pm.expect(order.state).to.be.a('number');",
              "        pm.expect(order.payment_state).to.be.a('number');",
              "        pm.expect(order.shipping_state).to.be.a('number');",
              "    });",
              "    const tracking = (order.shipments || []).map((s) => s.tracking_number).join(', ');",
              "    console.log('order', order.id, 'total', order.total_amount, 'tracking', tracking || '(none yet)');",
              "}"
            ]
          }
        }
      ]
    },
    {
      "name": "Error cases",
      "description": "The responses to get familiar with before going live. None of these need credentials.",
      "item": [
        {
          "name": "Order detail with an expired or forged token (401)",
          "request": {
            "auth": {
              "type": "bearer",
              "bearer": [
                {
                  "key": "token",
                  "value": "not-a-valid-token",
                  "type": "string"
                }
              ]
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\"order_id\": \"1\"}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/v1/order/detail",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "order",
                "detail"
              ]
            },
            "description": "A token that is not ours — forged, expired, or signed by another key — is rejected by the /v1 auth middleware before the handler runs, so nothing is read and nothing is created. Validation is stateless signature checking: there is no session to revoke and no database hit."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('401 Unauthorized', () => pm.response.to.have.status(401));",
                  "pm.test('invalid_token', () => pm.expect(pm.response.json().error).to.eql('invalid_token'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Token with a wrong secret (401)",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/x-www-form-urlencoded"
              }
            ],
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "grant_type",
                  "value": "client_credentials",
                  "type": "text"
                },
                {
                  "key": "client_id",
                  "value": "xs_does_not_exist",
                  "type": "text"
                },
                {
                  "key": "client_secret",
                  "value": "xss_wrong",
                  "type": "text"
                }
              ]
            },
            "url": {
              "raw": "{{base_url}}/oauth/token",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "oauth",
                "token"
              ]
            },
            "description": "An unknown client_id, a wrong secret and an expired channel all answer the same `invalid_client` — the endpoint never reveals which client_ids exist. Ten of these in five minutes block the client_id and the calling IP for 15 minutes, so do not retry a failing secret in a loop."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('401 Unauthorized', () => pm.response.to.have.status(401));",
                  "pm.test('invalid_client', () => pm.expect(pm.response.json().error).to.eql('invalid_client'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Token with an unsupported grant (400)",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/x-www-form-urlencoded"
              }
            ],
            "body": {
              "mode": "urlencoded",
              "urlencoded": [
                {
                  "key": "grant_type",
                  "value": "password",
                  "type": "text"
                },
                {
                  "key": "client_id",
                  "value": "{{client_id}}",
                  "type": "text"
                },
                {
                  "key": "client_secret",
                  "value": "{{client_secret}}",
                  "type": "text"
                }
              ]
            },
            "url": {
              "raw": "{{base_url}}/oauth/token",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "oauth",
                "token"
              ]
            },
            "description": "client_credentials is the only supported grant."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400 Bad Request', () => pm.response.to.have.status(400));",
                  "pm.test('unsupported_grant_type', () => pm.expect(pm.response.json().error).to.eql('unsupported_grant_type'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Create order without a token (401)",
          "request": {
            "auth": {
              "type": "noauth"
            },
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\"shipping_type\":\"ems\"}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/v1/order/create",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "order",
                "create"
              ]
            },
            "description": "Writes are behind the same bearer auth as every other /v1 route; nothing is created."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('401 Unauthorized', () => pm.response.to.have.status(401));",
                  "pm.test('invalid_token', () => pm.expect(pm.response.json().error).to.eql('invalid_token'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Order detail for someone else's order (404)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\"order_id\": \"1\"}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/v1/order/detail",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "order",
                "detail"
              ]
            },
            "description": "Order 1 is not your store's. Every miss — wrong store, wrong channel, deleted, never existed — is the same 404, so the endpoint reveals nothing about which orders exist."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('404 Not Found', () => pm.response.to.have.status(404));",
                  "pm.test('not_found', () => pm.expect(pm.response.json().error).to.eql('not_found'));"
                ]
              }
            }
          ]
        }
      ]
    }
  ]
}