openapi: 3.2.1
info:
  title: XSelly Open Platform API
  version: 1.0.0
  summary: Create and read XSelly orders from your own system.
  description: |
    Work with your XSelly store from your own system — a sale page, a chat bot,
    an ERP.

    An order created through this API is created exactly as if it had been keyed
    into the XSelly app: stock is reserved, the shipping label can be printed,
    and — unless you opt out — a tracking number is requested from the courier
    automatically.

    New here? Start with the [Tutorial](/tutorial/), which walks through a first order end to end. This reference lists every endpoint and field, and the **Webhooks** section at the end describes the requests XSelly sends to your server.

    ## Conventions that hold everywhere

    - **Identifiers are always JSON strings**, in requests as well as responses.
      Order ids, product variant ids and address ids are all written
      `"4536645"`, never `4536645`. A bare number is rejected with a `400`. The
      value is digits, but treat it as an opaque string: store it as text, and
      send back exactly what we gave you. One spelling in both directions means
      an id read from one response can go straight into the next request, and no
      JavaScript client ever rounds a large one.
    - **Timestamps are unix epoch milliseconds**, on every field suffixed
      `_time`. Never a formatted date, never seconds — a seconds-precision value
      is rejected rather than silently read as a date in 1970.
    - **Money is a decimal string in responses** (`"1280.50"`), so that no JSON
      parser rounds it. In requests either a number or a numeric string is
      accepted.
    - **An absent field and a `null` one mean the same thing.**
    - **Unknown fields are rejected** with a `400`. A misspelled key that was
      quietly ignored would create a *wrong* order that then ships — one missing
      the COD amount you thought you sent, say — so we would rather tell you.
    - **Every response carries a `request_id`**, also returned as the
      `X-Request-Id` header. Quote it when you ask us about a call. Send your
      own `X-Request-Id` and we will use yours.
    - **No request body has a `store_id`.** Your access token identifies your
      channel, and your channel decides which store you are working in, so there
      is no way to reach another store and no way to get it wrong.
  contact:
    name: XSelly Open Platform support
    url: https://www.xselly.com
  license:
    name: Proprietary — for XSelly Open Platform partners
servers:
  - url: "{base_url}"
    description: Your base URL, shown in the XSelly app. Ask the XSelly team if you cannot find it.
    variables:
      base_url:
        default: https://your-base-url
        description: Your base URL, shown in the XSelly app.
security:
  - BearerAuth: []
tags:
  - name: Authentication
    description: Exchange your channel's credentials for an access token.
  - name: Orders
    description: Create an order and follow its payment and shipping progress.
  - name: Store
    description: Your store's own settings — today, the addresses you ship from.
  - name: Products
    description: Your store's products, in the shape a sale page draws them.
  - name: Webhooks
    description: |
      Requests XSelly sends to **your** webhook URL. Acknowledge each with a
      2xx within one second; there are no retries.
paths:
  /oauth/token:
    post:
      tags:
        - Authentication
      operationId: createAccessToken
      summary: Get an access token
      description: |
        Every `/v1` call carries an access token. Get one with the OAuth2
        **client credentials** grant, using the `client_id` and `client_secret`
        issued for your channel. Credentials go in the form body; HTTP Basic
        client authentication is not accepted.

        **Cache the token and reuse it for its full four hours**
        (`expires_in` is 14400 seconds), refreshing shortly before it expires or
        when a call answers `401`. A correct integration needs about six token
        calls a day.

        The endpoint is rate limited to 10 requests per minute per `client_id`
        and 60 per minute per IP; over that it answers `429` with `slow_down`
        and a `Retry-After` header. Minting a token before every API call will
        hit this. Ten failed authentications within five minutes block that
        `client_id` and that IP for 15 minutes, so never retry a rejected secret
        in a loop.

        Treat the token itself as opaque — a string with a lifetime. Two calls
        never return the same value.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/TokenRequest"
      responses:
        "200":
          description: A fresh access token.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
              examples:
                token:
                  summary: A newly minted token
                  value:
                    access_token: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
                    token_type: Bearer
                    expires_in: 14400
        "400":
          description: |
            `grant_type` was not `client_credentials`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                unsupportedGrantType:
                  value:
                    error: unsupported_grant_type
                    error_description: only client_credentials is supported
        "401":
          description: |
            Credentials rejected. Every authentication failure answers the same
            `invalid_client`, whether the `client_id` is unknown, the secret is
            wrong, or the channel has expired — by design, so the endpoint
            reveals nothing about which clients exist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalidClient:
                  value:
                    error: invalid_client
                    error_description: client authentication failed
        "429":
          description: Rate limited. Wait out the `Retry-After` header.
          headers:
            Retry-After:
              description: Seconds to wait before trying again.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                slowDown:
                  value:
                    error: slow_down
                    error_description: too many token requests; retry after the Retry-After header
  /v1/order/create:
    post:
      tags:
        - Orders
      operationId: createOrder
      summary: Create one order
      description: |
        Creates one order in the store your channel belongs to.

        Send an `external_order_id` — your own order id — and retries become
        safe: the first call creates the order, and every later call carrying
        the same `external_order_id` returns *that same order* rather than
        creating a second one. Without it there is nothing to recognise a retry
        by, and a repeated request creates a second order.

        A freshly created order has no `shipments` yet, even one that asked for
        a tracking number: XShipping books it moments later. Poll
        `POST /v1/order/detail` for it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
            examples:
              codOrder:
                summary: A cash-on-delivery order from a sale page
                description: |
                  `sender_address_id` is left out, so the parcel is sent from
                  the store's primary address.
                value:
                  external_order_id: SALEPAGE-10231
                  shipping_type: spx_pickup
                  is_cod: true
                  cod_fee: 30
                  cod_amount: 1250.5
                  channel: sales_page
                  recipient_address:
                    name: คุณทดสอบ ระบบ
                    telephone: "0556789201"
                    address1: 51/102 บางปะกง
                    sub_district: บางปะกง
                    district: บางปะกง
                    province: ฉะเชิงเทรา
                    postal_code: 24130
                  products:
                    - product_variant_id: "1984193"
                      qty: 1
                      price: 1220.5
                  shipping_fee: 30
                  order_time: 1789463270000
              bySkuAndCachedAddresses:
                summary: Lines named by SKU, shipped from a cached branch address
                description: |
                  `sender_address_id` and `recipient_address_id` are both ids
                  this integration already had on file, so the order is one
                  call.
                value:
                  external_order_id: ERP-2026-0009
                  shipping_type: ems
                  sender_address_id: "3710939"
                  recipient_address_id: "3753465"
                  products:
                    - sku: FID3-000
                      qty: 2
                  shipping_fee: 40
                  discount: 15
      responses:
        "200":
          description: The order, as created.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderEnvelope"
              examples:
                created:
                  $ref: "#/components/examples/CreatedOrder"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/order/detail:
    post:
      tags:
        - Orders
      operationId: getOrderDetail
      summary: Read one order
      description: |
        Reads one order. Use it to follow an order's payment and shipping
        progress, and to pick up the tracking number once the courier has issued
        one.

        Name the order by **exactly one** of `order_id` and `external_order_id`.
        Sending both, or neither, is a `400`.

        The two ids differ in scope on purpose. `order_id` is store-wide, so you
        can also read orders keyed into the XSelly app — which is what makes
        this endpoint useful for reconciling a whole day rather than only your
        own orders. `external_order_id` is resolved within your channel alone,
        because two channels of one store may each have an order "1001".

        It is a `POST` rather than a `GET` so that the ids travel in a body: an
        `external_order_id` is a string you chose, and putting it in a query
        string would spread it through access logs, proxies and browser history.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderQuery"
            examples:
              byXSellyId:
                summary: By the XSelly order id
                value:
                  order_id: "4536645"
              byYourOwnId:
                summary: By your own order id
                value:
                  external_order_id: SALEPAGE-10231
      responses:
        "200":
          description: The order as it stands now.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderEnvelope"
              examples:
                shipped:
                  $ref: "#/components/examples/ShippedOrder"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: |
            No such order, for you. Anything outside your scope — another
            store's order, another channel's external id, a deleted order, an id
            that never existed — answers this same `404`, so the endpoint cannot
            be used to discover which orders exist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                notFound:
                  value:
                    error: not_found
                    error_description: no such order
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/store/address/list:
    post:
      tags:
        - Store
      operationId: listStoreAddresses
      summary: List your store's own addresses
      description: |
        Lists **your store's own addresses** — the ตั้งค่าร้าน → ที่อยู่ร้าน list
        in the XSelly app. These are the addresses a `sender_address_id` can
        name, so call this to learn your ids rather than hard-coding them.

        **Do not call this often.** A store's own addresses are its branches and
        warehouses: they are set up once and then barely change, and the ids
        they carry never change at all. Call it **once**, cache the
        `sender_address_id` you need on your side, and use the cached value from
        then on — refreshing only when the addresses actually change in XSelly
        (someone adds a warehouse, moves the primary tick, or a stored id stops
        working). Creating an order then costs one call instead of two, which is
        measurably faster and keeps you well clear of the rate limits; listing
        the addresses before every order is the most common way to make an
        otherwise good integration slow. If you ship from a single place you can
        skip this endpoint altogether and simply leave `sender_address_id` out
        of the create call.

        Your customers' addresses are not in this list: those belong to the
        store's contacts, and this endpoint is about where parcels are sent
        *from*.

        Addresses come back primary-first, then oldest-first — 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` is sent from.
      requestBody:
        required: false
        description: |
          Optional. Send nothing at all, `{}`, or a page.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ListStoreAddressesRequest"
            examples:
              firstPage:
                summary: The usual call — no arguments
                value: {}
              secondPage:
                summary: A store with more addresses than one page holds
                value:
                  limit: 50
                  offset: 50
      responses:
        "200":
          description: One page of the store's own addresses.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListStoreAddressesResponse"
              examples:
                twoAddresses:
                  value:
                    request_id: req_01K5A9F3T7Q2WPRB8N0MZDXCV4
                    addresses:
                      - id: "3653293"
                        is_primary: true
                        name: คลังสินค้าหลัก
                        telephone: "0898765432"
                        address1: 142/108 ถ.กาญจนาภิเษก
                        sub_district: บางแค
                        district: บางแค
                        province: กทม
                        postal_code: 10160
                      - id: "3710939"
                        is_primary: false
                        name: สาขาใหม่
                        telephone: "0599777882"
                        address1: 11/11 บ้านนี้ดี อยู่แล้วรวย
                        sub_district: บางปะกง
                        district: บางปะกง
                        province: ฉะเชิงเทรา
                        postal_code: 24130
                        legal_entity_type: 11
                        legal_entity_id: "1011544012007"
                        branch_type: h
                    has_more: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/product/list:
    post:
      tags:
        - Products
      operationId: listProducts
      summary: List your store's products
      description: |
        Lists **your store's products** — the same list as สินค้า in the XSelly
        app — in the small shape a sale page needs to draw a product card: an
        id, a name and a picture.

        **Variants, prices and stock are not in it.** A product can carry a
        hundred variants, so the list stays one row per product; call
        `POST /v1/product/detail` for the product a buyer opens.

        To walk the whole catalogue, keep adding `limit` to `offset` while
        `has_more` is `true`; send `get_count: true` once, on the first page, if
        you want a total. If the store edits its products while you page, one
        can move between pages; sort by `id` when you need every product
        exactly once.
      requestBody:
        required: false
        description: |
          Optional. Send nothing at all, `{}`, or any of the fields below.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ListProductsRequest"
            examples:
              firstPage:
                summary: The first page, most recently updated first
                value: {}
              search:
                summary: A name search, alphabetical, with a total
                value:
                  limit: 20
                  offset: 0
                  query: หมวก
                  sort_by: name
                  sort_order: asc
                  get_count: true
      responses:
        "200":
          description: One page of the store's products.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListProductsResponse"
              examples:
                withCount:
                  value:
                    request_id: req_01K5A9F3T7Q2WPRB8N0MZDXCV4
                    products:
                      - id: "569041"
                        name: ชุดนอน เนื้อผ้านุ่มลื่น ใส่สบาย
                        image_url: https://p16-oec-sg.ibyteimg.com/tos-alisg-i-aphluv4xwc-sg/df89e619900848eda999920401dfc210~tplv-aphluv4xwc-origin-jpeg.jpeg
                      - id: "569040"
                        name: SS ชุดเด็ก 3 ชิ้น พร้อมหมวกสุดเท่
                        image_url: https://cf.shopee.co.th/file/sg-11134283-8259r-mti2amlllrlu1f
                    total: 975
                    has_more: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/ServerError"
  /v1/product/detail:
    post:
      tags:
        - Products
      operationId: getProductDetail
      summary: Read one product in full
      description: |
        Reads **one product in full** — pictures, description, category, and
        every variant with its SKU, prices per price tier and stock per
        warehouse. Each variant's `product_variant_id` is what
        `products[].product_variant_id` takes when creating an order.

        The id is looked up within your store; anything outside it answers the
        same `404`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductQuery"
            examples:
              byId:
                value:
                  product_id: "569032"
      responses:
        "200":
          description: The product.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductDetailEnvelope"
              examples:
                oneVariant:
                  value:
                    request_id: req_01K5A9F3T7Q2WPRB8N0MZDXCV4
                    product:
                      id: "569032"
                      name: หมวกกันแดด ผ้าฝ้ายโพลีเอสเตอร์ ระบายอากาศดี
                      description: หมวกกันแดด ผลิตจากผ้าฝ้ายโพลีเอสเตอร์ ...
                      store_id: "3"
                      img_uris:
                        - https://cf.shopee.co.th/file/th-11134207-81ztf-mtho943byolfed
                      thumbnail_uris:
                        - https://cf.shopee.co.th/file/th-11134207-81ztf-mtho943byolfed
                      tiny_img_uris:
                        - https://cf.shopee.co.th/file/th-11134207-81ztf-mtho943byolfed
                      create_time: 1790226380000
                      update_time: 1790226523000
                      price_tier_ids:
                        - "1138"
                      warehouse_ids:
                        - "30629"
                      shipping_rates: []
                      min: "799.00"
                      max: "799.00"
                      variants:
                        - product_variant_id: "1984307"
                          name: Yellow
                          create_time: 1790226380000
                          update_time: 1790226380000
                          img_url: https://cf.shopee.co.th/file/sg-11134253-8260g-mk3zylt21wclcf
                          sku: AB20-01
                          prices:
                            - id: "2833534"
                              price_tier_id: "1138"
                              price: "799.00"
                          cost: "0.00"
                          available_qty: 12
                          on_hand: 15
                          reserved_qty: 3
                          warehouses:
                            - wh_id: "30629"
                              on_hand: 15
                              available_qty: 12
                              reserved_qty: 3
                          weight: 250
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: |
            No such product in your store. Another store's product, a deleted
            one and an id that never existed all answer this same `404`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                notFound:
                  value:
                    error: not_found
                    error_description: no such product
        "500":
          $ref: "#/components/responses/ServerError"
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        The access token from `POST /oauth/token`, sent on every `/v1` call as
        `Authorization: Bearer <access token>`. It identifies your channel, and
        your channel decides which store you are working in.
    OAuth2ClientCredentials:
      type: oauth2
      description: |
        The same token, described as an OAuth2 flow for tooling that can fetch
        it for you. Credentials go in the form body; HTTP Basic client
        authentication is not accepted.
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes: {}
  headers:
    XRequestId:
      description: |
        The id of this call, echoed in the body as `request_id`. Quote it when
        you ask us about a call. Send your own and we will use yours.
      schema:
        type: string
        examples:
          - req_01K5A7QW8ZP3RN4MB6C0YEXV2D
  responses:
    BadRequest:
      description: |
        Something in the request is wrong. `error_description` names the
        offending field, for example `products[1].qty`. Sending an id as a
        number rather than a string lands here.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            unknownCourier:
              summary: An unsupported shipping type
              value:
                error: invalid_request
                error_description: 'shipping_type: "pigeon" is not a supported courier'
            numericId:
              summary: An id sent as a number
              value:
                error: invalid_request
                error_description: '1984193 is not an id: ids are sent as a JSON string, e.g. "1984193"'
            duplicateSku:
              summary: A SKU worn by more than one variant
              value:
                error: invalid_request
                error_description: 'products[0].sku: sku "FID3-000" is shared by 2 products (product_variant_id "1984193", "1984210"); send one of those product_variant_id values instead'
    Unauthorized:
      description: The access token is missing, malformed or expired. Get a new one.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            invalidToken:
              value:
                error: invalid_token
                error_description: the access token is invalid or has expired
    Forbidden:
      description: |
        Either your channel may not do that for its store (`access_denied`), or
        the store's subscription order quota is exceeded (`quota_exceeded`),
        which only the store owner can resolve.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            accessDenied:
              value:
                error: access_denied
                error_description: this channel may not create orders for its store
            quotaExceeded:
              value:
                error: quota_exceeded
                error_description: the store's subscription order quota is exceeded
    PayloadTooLarge:
      description: The request body is over the 1 MB limit.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            tooLarge:
              value:
                error: invalid_request
                error_description: the request body is too large
    ServerError:
      description: |
        Our problem. Retry — with an `external_order_id`, retrying a create is
        safe.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            serverError:
              value:
                error: server_error
                error_description: the order could not be created
  schemas:
    Id:
      type: string
      pattern: ^[0-9]+$
      description: |
        An identifier — an order id, a product variant id, an address id.

        **Always a JSON string, in requests as well as responses**: `"4536645"`,
        never `4536645`. A bare number is rejected with a `400`. The value is
        digits, but treat it as opaque: store it as text and send back exactly
        what we gave you.
      examples:
        - "4536645"
    Money:
      description: |
        An amount in baht. Responses always send a decimal **string**
        (`"1280.50"`) so that no JSON parser rounds it; requests accept either a
        number or a numeric string.
      oneOf:
        - type: string
          pattern: ^-?[0-9]+(\.[0-9]+)?$
        - type: number
      examples:
        - "1280.50"
    MoneyOut:
      type: string
      pattern: ^-?[0-9]+(\.[0-9]+)?$
      description: An amount in baht, always a decimal string in responses.
      examples:
        - "1280.50"
    EpochMillis:
      type: integer
      format: int64
      description: |
        A unix timestamp in **milliseconds**. Never a formatted date and never
        seconds — a seconds-precision value is rejected rather than silently
        read as a date in 1970.
      examples:
        - 1789463270000
    RequestId:
      type: string
      description: |
        The id of this call, also returned as the `X-Request-Id` header. Quote
        it when you ask us about a call.
      examples:
        - req_01K5A7QW8ZP3RN4MB6C0YEXV2D
    Error:
      type: object
      description: |
        The one error shape, shared by the OAuth and the `/v1` endpoints. Branch
        on `error`, never on the prose in `error_description`: the wording may
        improve, the codes will not change.
      required:
        - error
      properties:
        error:
          type: string
          description: The machine-readable code to branch on.
          enum:
            - invalid_request
            - invalid_client
            - invalid_token
            - unsupported_grant_type
            - slow_down
            - access_denied
            - quota_exceeded
            - not_found
            - server_error
        error_description:
          type: string
          description: |
            A human-readable explanation, naming the offending field where there
            is one. For people reading logs, not for code to match on.
    TokenRequest:
      type: object
      required:
        - grant_type
        - client_id
        - client_secret
      properties:
        grant_type:
          type: string
          const: client_credentials
          description: Always `client_credentials`.
        client_id:
          type: string
          description: Your channel's client id, `xs_...`.
          examples:
            - xs_9f2c41b8d7e04a15
        client_secret:
          type: string
          format: password
          description: |
            Your channel's secret, `xss_...`. It is shown once when issued and
            cannot be read back, only reset.
    TokenResponse:
      type: object
      required:
        - access_token
        - token_type
        - expires_in
      properties:
        access_token:
          type: string
          description: |
            Send it on every `/v1` call as `Authorization: Bearer <token>`.
            Treat it as opaque and cache it for its full lifetime.
        token_type:
          type: string
          const: Bearer
        expires_in:
          type: integer
          description: |
            Seconds the token stays valid — four hours. Refresh shortly before
            it runs out, or when a call answers `401`.
          examples:
            - 14400
    CreateOrderRequest:
      type: object
      additionalProperties: false
      required:
        - shipping_type
        - products
      description: |
        The body of `POST /v1/order/create`. Exactly one of `recipient_address`
        and `recipient_address_id` is required. Any key not listed here is
        rejected with a `400`.
      properties:
        shipping_type:
          $ref: "#/components/schemas/ShippingType"
        products:
          type: array
          minItems: 1
          description: The order lines — at least one.
          items:
            $ref: "#/components/schemas/CreateOrderProduct"
        recipient_address:
          allOf:
            - $ref: "#/components/schemas/Address"
          description: |
            Where the parcel goes, created with the order. Use this *or*
            `recipient_address_id`, never both.
        recipient_address_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            An address your store already has, instead of `recipient_address` —
            the `recipient_address_id` of an earlier order for that customer.
        external_order_id:
          type: string
          maxLength: 128
          description: |
            Your own order id. Stored with the order, and doubles as the
            idempotency key: re-posting one returns the order created the first
            time rather than a second one. It only has to be unique within your
            channel.
          examples:
            - SALEPAGE-10231
        is_cod:
          type: boolean
          default: false
          description: Cash on delivery.
        cod_fee:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: |
            The COD service fee you charge the customer. Required when `is_cod`
            is true, and only accepted then.
        cod_amount:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: |
            The amount the courier collects on delivery. Required when `is_cod`
            is true, and only accepted then.
        sender_name:
          type: string
          description: |
            Sender shown on the shipping label. Defaults to your store's name.
        sender_address_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            **Optional. If not sent, the primary address will be used as
            default.**

            Where the parcel is sent from — one of your store's own addresses,
            whose id comes from `POST /v1/store/address/list`. Omit it and the
            address marked ที่อยู่หลัก (primary) is used; if the store has ticked
            none, your oldest address is. Ship from a single place and you never
            need to send this field at all.

            When you do send it, cache the id rather than listing the addresses
            before every order.
        channel:
          $ref: "#/components/schemas/SalesChannel"
        discount:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: |
            Discount given to the customer, as a **positive** number taken
            *off* the order: `15` means ฿15 off, not ฿15 added. A negative value
            is rejected. Default `0`.
        shipping_fee:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: |
            Shipping charged to the customer, added on. Default `0`; a negative
            value is rejected.
        other_fee:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: |
            Any other charge, added on. Default `0`; a negative value is
            rejected.
        ship_before_pay:
          type: boolean
          default: false
          description: |
            Allow the order to ship before it is paid (จัดส่งก่อนชำระ).
        auto_request_xshipping:
          type: boolean
          default: true
          description: |
            Request a tracking number from the courier as soon as the order is
            ready to ship (payment has been confirmed and completed or flag
            `ship_before_pay` is true), where your store has XShipping set up
            for that courier.
            Send `false` to opt out and supply the tracking number yourself.
        shipping_label_note:
          type: string
          maxLength: 1000
          description: |
            หมายเหตุบนใบปะหน้า — printed on the shipping label, so the courier and
            the customer both see it.
        private_note:
          type: string
          maxLength: 1000
          description: |
            บันทึกช่วยจำ — only your store sees it, never the customer and never
            the label.
        order_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: |
            When the customer placed the order on your side. Defaults to the
            creation time.
        expiration_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: When an unpaid order expires.
        ship_deadline_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: วันกำหนดส่ง — the date the order must be shipped by.
    CreateOrderProduct:
      type: object
      additionalProperties: false
      required:
        - qty
      description: |
        One order line. Name the product by **either** `product_variant_id`
        **or** `sku` — one of the two is required, and sending both is a `400`,
        because if they disagree we will not guess which product you meant.
      properties:
        product_variant_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The variant id in your store, as a string (`"1984193"`, not
            `1984193`) — the same id this API returns in
            `products[].product_variant_id` and the stock webhook reports as
            `data.items[].id`.
        sku:
          type: string
          description: |
            That variant's SKU, instead of `product_variant_id`. Matched
            **exactly** — case, spacing and punctuation included — among your
            store's live products. XSelly does not force SKUs to be unique
            within a store, so a SKU worn by more than one variant is refused
            with a `400` listing the ids that share it; send one of those
            instead.
          examples:
            - FID3-000
        qty:
          type: integer
          minimum: 1
          description: How many, at least 1.
        price:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: |
            Unit price to charge. Defaults to your store's own price for that
            variant. A negative value is rejected.
    Address:
      type: object
      additionalProperties: false
      required:
        - name
      description: |
        An address. Only `name` is required, but a shipping label needs
        `telephone`, `address1`, `sub_district`, `district`, `province` and
        `postal_code`. The legal-entity fields are only for a tax invoice.
      properties:
        name:
          type: string
          description: Recipient name. **Required.**
          examples:
            - คุณทดสอบ ระบบ
        telephone:
          type: string
          description: Contact number, as the courier should see it.
          examples:
            - "0556789201"
        address1:
          type: string
          description: House number and street.
        address2:
          type: string
          description: Second line, if any.
        address3:
          type: string
          description: Third line, if any.
        sub_district:
          type: string
          description: ตำบล / แขวง
        district:
          type: string
          description: อำเภอ / เขต
        province:
          type: string
          description: จังหวัด
        postal_code:
          type: integer
          description: Five digits, as a number (`24130`) — not a string.
          examples:
            - 24130
        email:
          type: string
          format: email
          description: Customer email.
        note:
          type: string
          description: Free note kept with the address.
        legal_entity_id:
          type: string
          description: |
            The number on a tax invoice — a national id or a tax id, depending
            on `legal_entity_type`.
          examples:
            - "1011544012007"
        legal_entity_type:
          $ref: "#/components/schemas/LegalEntityType"
        branch_type:
          type: string
          enum:
            - h
            - b
          description: |
            `h` = head office (สำนักงานใหญ่), `b` = a branch.
        branch_number:
          type: string
          description: The branch number, when `branch_type` is `b`.
    OrderAddress:
      allOf:
        - type: object
          required:
            - id
          properties:
            id:
              allOf:
                - $ref: "#/components/schemas/Id"
              description: |
                Reusable as `recipient_address_id` on a later order for the same
                customer.
        - $ref: "#/components/schemas/Address"
      description: A stored address — an address plus the id it can be reused by.
    StoreAddress:
      allOf:
        - type: object
          required:
            - id
            - is_primary
          properties:
            id:
              allOf:
                - $ref: "#/components/schemas/Id"
              description: |
                Use it as `sender_address_id` when creating an order — and cache
                it, rather than listing the addresses again.
            is_primary:
              type: boolean
              description: |
                The ที่อยู่หลัก tick the store owner sets in the XSelly app. The
                app allows several addresses to be ticked, so what really
                decides the default is the list order: primary first, then
                oldest first.
        - $ref: "#/components/schemas/Address"
      description: |
        One of the store's own addresses, in the shape a `sender_address_id`
        names. Every other field is the same `Address` shape the create request
        takes, so an address you list can be posted back verbatim; fields the
        store never filled in are absent rather than `null`.
    OrderQuery:
      type: object
      additionalProperties: false
      description: |
        The body of `POST /v1/order/detail`. Exactly one of the two ids —
        sending both, or neither, is a `400`.
      minProperties: 1
      maxProperties: 1
      properties:
        order_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The XSelly order id, as a string (`"4536645"`) — exactly what create
            returned as `order.id`. Resolved **within your store**, so you can
            also read orders keyed into the XSelly app.
        external_order_id:
          type: string
          description: |
            Your own id, the one you sent at creation. Resolved **within your
            channel** — two channels of one store may each have an order "1001",
            and each sees only its own.
          examples:
            - SALEPAGE-10231
    ListStoreAddressesRequest:
      type: object
      additionalProperties: false
      description: |
        Both fields are optional; an absent body lists the first page.
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
          description: |
            How many to return. The default already holds every address of a
            normal store.
        offset:
          type: integer
          minimum: 0
          default: 0
          description: How many to skip.
    ListStoreAddressesResponse:
      type: object
      required:
        - request_id
        - addresses
        - has_more
      properties:
        request_id:
          $ref: "#/components/schemas/RequestId"
        addresses:
          type: array
          description: |
            Primary first, then oldest first — so the first address of the first
            page is the one an order with no `sender_address_id` is sent from.
          items:
            $ref: "#/components/schemas/StoreAddress"
        has_more:
          type: boolean
          description: Whether another page exists past this one.
    ListProductsRequest:
      type: object
      additionalProperties: false
      description: |
        Every field is optional; an absent body lists the first page, most
        recently updated first.
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
          description: How many products to return.
        offset:
          type: integer
          minimum: 0
          default: 0
          description: How many to skip.
        query:
          type: string
          description: Only products whose name contains this, ignoring case.
          examples:
            - หมวก
        sort_by:
          type: string
          enum:
            - update_time
            - create_time
            - name
            - id
          default: update_time
        sort_order:
          type: string
          enum:
            - desc
            - asc
          default: desc
        get_count:
          type: boolean
          default: false
          description: |
            Also return `total`. Counting every match is a query of its own, and
            `has_more` is all you need to page, so ask only when you show it.
    ListProductsResponse:
      type: object
      required:
        - request_id
        - products
        - has_more
      properties:
        request_id:
          $ref: "#/components/schemas/RequestId"
        products:
          type: array
          items:
            $ref: "#/components/schemas/Product"
        total:
          type: integer
          description: |
            How many products match the request, across every page. Only
            present when the request sent `get_count: true`.
        has_more:
          type: boolean
          description: Whether another page exists past this one.
    Product:
      type: object
      required:
        - id
        - name
      description: |
        One product, as a sale page needs it to draw a card. Variants, prices
        and stock are in `POST /v1/product/detail`.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        name:
          type: string
        image_url:
          type: string
          format: uri
          description: The product's main picture. Absent when it has none.
    ProductQuery:
      type: object
      additionalProperties: false
      required:
        - product_id
      properties:
        product_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: A product id from `POST /v1/product/list`.
    ProductDetailEnvelope:
      type: object
      required:
        - request_id
        - product
      properties:
        request_id:
          $ref: "#/components/schemas/RequestId"
        product:
          $ref: "#/components/schemas/ProductDetail"
    ProductDetail:
      type: object
      required:
        - id
        - name
        - description
        - store_id
        - img_uris
        - thumbnail_uris
        - tiny_img_uris
        - variants
      description: |
        One product in full. Optional fields the product does not have are
        absent rather than `null`.

        A **pulled product** (สินค้าจากร้านค้าส่ง) is one your store pulled
        from a seller store to resell: it carries `parent_id`, and each of its
        variants carries `pulled_from_product_variant_id`.
      properties:
        id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: The product's id.
        name:
          type: string
          description: The product's name, as the store wrote it.
        description:
          type: string
          description: The product's description, as the store wrote it. Empty when it has none.
        store_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: The store the product belongs to — always your own store.
        img_uris:
          type: array
          description: |
            The product's pictures at full size. The first one is the main
            picture. Empty when it has none.
          items:
            type: string
            format: uri
        thumbnail_uris:
          type: array
          description: The same pictures as `img_uris`, thumbnail size, in the same order.
          items:
            type: string
            format: uri
        tiny_img_uris:
          type: array
          description: The same pictures as `img_uris`, smallest size, in the same order.
          items:
            type: string
            format: uri
        create_time:
          type: integer
          format: int64
          description: When the product was created, in epoch milliseconds.
        update_time:
          type: integer
          format: int64
          description: When the product was last changed, in epoch milliseconds.
        category:
          type: object
          required:
            - id
            - name
          description: The product's category (หมวดหมู่). Absent when it has none.
          properties:
            id:
              allOf:
                - $ref: "#/components/schemas/Id"
              description: The category's id.
            name:
              type: string
              description: The category's name.
            color:
              type: string
              description: The colour the XSelly app shows the category in, e.g. `"#F5A623"`.
        price_tier_ids:
          type: array
          description: |
            The price tiers (ราคาขาย groups) the product is sold in;
            `variants[].prices[].price_tier_id` names one of these.
          items:
            $ref: "#/components/schemas/Id"
        pg_vd_ids:
          type: array
          description: |
            The volume discounts (ส่วนลดขายส่ง) set on the product, one entry
            per price tier a discount applies to. Absent when there is none.
          items:
            type: object
            required:
              - price_tier_id
              - vd_id
            properties:
              price_tier_id:
                allOf:
                  - $ref: "#/components/schemas/Id"
                description: The price tier the discount applies to — one of `price_tier_ids`.
              vd_id:
                allOf:
                  - $ref: "#/components/schemas/Id"
                description: The volume discount's id.
        warehouse_ids:
          type: array
          description: |
            The warehouses the product is stocked in;
            `variants[].warehouses[].wh_id` names one of these.
          items:
            $ref: "#/components/schemas/Id"
        shipping_rates:
          type: array
          description: |
            The shipping rates the store has set for this product in
            particular. Absent or empty when it has none.
          items:
            $ref: "#/components/schemas/ProductShippingRate"
        seller_shipping_rates:
          type: array
          description: |
            On a pulled product, the seller store's shipping rates for it.
            Absent when the seller charges no shipping.
          items:
            $ref: "#/components/schemas/ProductShippingRate"
        min:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: The lowest variant price across all price tiers.
        max:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: The highest variant price across all price tiers.
        parent_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            On a pulled product, the seller store's product it was pulled from.
            Absent on your store's own products.
        has_child:
          type: boolean
          description: |
            `true` if the product has been pulled by a reseller — at least one
            other store has pulled it to resell. Absent otherwise.
        variants:
          type: array
          description: The product's variants. A product sold in a single form has exactly one.
          items:
            $ref: "#/components/schemas/ProductVariantDetail"
    ProductShippingRate:
      type: object
      required:
        - id
        - shipping_type
        - is_cod
        - init_qty
        - init_price
        - next_price
      description: |
        `init_price` for the first `init_qty` units, `next_price` for each unit
        after.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        shipping_type:
          type: string
          description: A shipping type key, the same vocabulary order create takes.
        is_cod:
          type: boolean
        init_qty:
          type: integer
        init_price:
          $ref: "#/components/schemas/MoneyOut"
        next_price:
          $ref: "#/components/schemas/MoneyOut"
        create_time:
          type: integer
          format: int64
        update_time:
          type: integer
          format: int64
    ProductVariantDetail:
      type: object
      required:
        - product_variant_id
        - name
        - available_qty
        - weight
      description: |
        One variant — the thing a buyer actually orders. Optional fields the
        variant does not have are absent rather than `null`.

        A variant can be a **set** (สินค้าเซ็ต, `product_bundle_id`): sold as a
        fixed combination of other variants, its `available_qty` worked out
        from its components' stock. Or it can be
        an **assembled product** (สินค้าประกอบ, `product_assembly_id`): built
        from other variants and holding stock of its own; assembling it uses
        up the components, disassembling returns them.
      properties:
        product_variant_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The variant's id. Send it as `products[].product_variant_id` when
            creating an order.
        name:
          type: string
          description: |
            The option the buyer picks, e.g. `"Yellow"`. **Empty for a product
            sold in a single form** — show the product's name alone.
        create_time:
          type: integer
          format: int64
          description: When the variant was created, in epoch milliseconds.
        update_time:
          type: integer
          format: int64
          description: When the variant was last changed, in epoch milliseconds.
        img_url:
          type: string
          format: uri
          description: |
            The variant's own picture at full size. Absent when it has none —
            fall back to the product's `img_uris`.
        thumbnail_url:
          type: string
          format: uri
          description: The variant's picture, thumbnail size.
        tiny_img_url:
          type: string
          format: uri
          description: The variant's picture, smallest size.
        sku:
          type: string
          description: |
            The store's own code for the variant. It must be unique within the
            store; older data can still hold a SKU shared by two variants, and
            `POST /v1/order/create` refuses such a SKU (send
            `product_variant_id` instead). Absent when not set.
        upc:
          type: string
          description: |
            The variant's barcode (UPC / EAN). Unlike `sku` it can be
            duplicated within the store — several variants may carry the same
            one. Absent when not set.
        pulled_from_product_variant_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            On a pulled product, the seller store's variant this one was pulled
            from. Absent on your store's own products.
        product_bundle_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            Present when the variant is a set (สินค้าเซ็ต) — the id of the set
            definition listing its components. Absent otherwise.
        bundled_pp_count:
          type: integer
          description: |
            On a set, how many different variants it is made of. E.g. a set of
            2 shirts and 1 cap has `bundled_pp_count: 2`.
        bundled_sum_qty:
          type: integer
          description: |
            On a set, the total number of units across all its components.
            E.g. a set of 2 shirts and 1 cap has `bundled_sum_qty: 3`.
        bundled_by_count:
          type: integer
          description: |
            How many sets use this variant as a component. Selling any of those
            sets also takes stock from this variant.
        product_assembly_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            Present when the variant is an assembled product (สินค้าประกอบ) —
            the id of the assembly definition listing its components. Absent
            otherwise.
        assembled_pp_count:
          type: integer
          description: |
            On an assembled product, how many different variants one unit is
            built from.
        assembled_sum_qty:
          type: integer
          description: |
            On an assembled product, the total number of component units one
            unit is built from.
        allow_negative_stock:
          type: boolean
          description: |
            If `true`, the variant can still be reserved (ordered) when its
            `available_qty` is zero, and `available_qty` then goes negative.
            Absent means `false`.
        prices:
          type: array
          description: The variant's price in each price tier.
          items:
            type: object
            required:
              - id
              - price_tier_id
              - price
            properties:
              id:
                allOf:
                  - $ref: "#/components/schemas/Id"
                description: The id of this price entry.
              price_tier_id:
                allOf:
                  - $ref: "#/components/schemas/Id"
                description: The price tier — one of the product's `price_tier_ids`.
              price:
                allOf:
                  - $ref: "#/components/schemas/MoneyOut"
                description: The variant's selling price in that tier.
        cost:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: The store's cost for one unit of the variant.
        available_qty:
          type: integer
          description: |
            Units that can still be sold (พร้อมขาย): `on_hand` minus
            `reserved_qty`. Can be negative when `allow_negative_stock` is
            `true`. On a pulled product it is the seller's stock.
        on_hand:
          type: integer
          description: |
            Units physically in stock, across all warehouses. Only on the
            store's own products.
        reserved_qty:
          type: integer
          description: |
            Units on the store's orders that are waiting to ship (รอส่ง). Only
            on the store's own products.
        warehouses:
          type: array
          description: Stock per warehouse. Only on the store's own products.
          items:
            type: object
            required:
              - wh_id
              - on_hand
              - available_qty
              - reserved_qty
            properties:
              wh_id:
                allOf:
                  - $ref: "#/components/schemas/Id"
                description: The warehouse — one of the product's `warehouse_ids`.
              on_hand:
                type: integer
                description: Units physically in this warehouse.
              available_qty:
                type: integer
                description: Units in this warehouse that can still be sold.
              reserved_qty:
                type: integer
                description: Units in this warehouse waiting to ship.
        weight:
          type: integer
          description: The weight of one unit, in grams.
    OrderEnvelope:
      type: object
      required:
        - request_id
        - order
      description: |
        Create and detail return the same envelope and the same `order` object,
        so you write one parser and use it for both.
      properties:
        request_id:
          $ref: "#/components/schemas/RequestId"
        order:
          $ref: "#/components/schemas/Order"
    Order:
      type: object
      description: |
        One order. It has no `store_id`: your access token already decides the
        store, so an order could never come back from another one. Optional
        fields are absent rather than `null` when the order has nothing to say.
      required:
        - id
        - order_state
        - payment_state
        - shipping_state
        - shipping_type
        - is_cod
        - sender_name
        - total_amount
        - discount
        - shipping_fee
        - other_fee
        - products
        - create_time
      properties:
        id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: The XSelly order id. Send it back as `order_id` to read the order again.
        open_platform_channel_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The channel that created the order through this API. Absent on an
            order keyed into the XSelly app or pulled from a marketplace — which
            is how you tell your own orders from the rest.
        external_order_id:
          type: string
          description: Your own id, if you sent one.
        order_state:
          $ref: "#/components/schemas/OrderState"
        payment_state:
          $ref: "#/components/schemas/PaymentState"
        shipping_state:
          $ref: "#/components/schemas/ShippingState"
        shipping_type:
          type: string
          description: |
            The courier key. Empty if the order was later switched, in the app,
            to a courier this API does not offer.
        is_cod:
          type: boolean
          description: Whether the COD variant of that courier is in use.
        channel:
          $ref: "#/components/schemas/SalesChannel"
        sender_name:
          type: string
          description: Sender on the shipping label.
        sender_address_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The store address the parcel is sent from — the primary one, if you
            did not name one.
        recipient_address_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The customer's address id, reusable as `recipient_address_id` on a
            later order.
        recipient_address:
          $ref: "#/components/schemas/OrderAddress"
        total_amount:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: |
            What the customer owes: the lines, plus shipping, COD fee and other
            costs, minus discounts.
        discount:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: |
            Order-level discount, positive, already subtracted from
            `total_amount`.
        shipping_fee:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: Shipping charged to the customer.
        other_fee:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: Other charges.
        cod_fee:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: The COD service fee. COD orders only.
        cod_amount:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: What the courier collects. COD orders only.
        products:
          type: array
          description: The order lines.
          items:
            $ref: "#/components/schemas/OrderProduct"
        shipments:
          type: array
          description: |
            Shipping records, once there are any. A freshly created order has
            none, even one that asked for a tracking number — XShipping books it
            moments later, so poll for it.
          items:
            $ref: "#/components/schemas/Shipment"
        shipping_label_note:
          type: string
          description: หมายเหตุบนใบปะหน้า, if the order has one.
        private_note:
          type: string
          description: บันทึกช่วยจำ, if the order has one.
        create_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: When XSelly created the order.
        order_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: When the customer placed it on your side.
        expiration_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: When an unpaid order expires.
        ship_deadline_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: วันกำหนดส่ง, if the order has one.
        ready_to_ship_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: |
            พร้อมส่งเมื่อ — when the order entered the packing queue. Absent until
            it does.
        payment_complete_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: Set once the order is fully paid.
        shipping_complete_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: Set once everything has shipped.
        complete_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: Set once the order is closed.
        cancel_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: |
            Set if the order was cancelled; `order_state` is then 181-184.
    OrderProduct:
      type: object
      required:
        - product_variant_id
        - name
        - variant
        - qty
        - price
        - price_after_discount
      description: One line of the order, as stored.
      properties:
        product_variant_id:
          allOf:
            - $ref: "#/components/schemas/Id"
          description: |
            The variant id, as a string — send this exact value back when you
            order the same variant again.
        name:
          type: string
          description: Product name.
          examples:
            - รองเท้าผ้าใบ
        variant:
          type: string
          description: Variant name (colour, size, …).
          examples:
            - ชมพู
            - เบอร์ 28
        sku:
          type: string
          description: The variant's SKU, if it has one.
        qty:
          type: integer
          description: Quantity ordered.
        price:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: Unit price charged.
        price_after_discount:
          allOf:
            - $ref: "#/components/schemas/MoneyOut"
          description: Unit price after per-unit discounts.
    Shipment:
      type: object
      required:
        - id
        - tracking_number
        - create_time
      description: |
        One shipping record. An order ships in more than one when it goes out in
        several parcels. A cancelled shipment is kept, with its `cancel_time`,
        because the tracking number it burned may still appear on a courier's
        report.
      properties:
        id:
          $ref: "#/components/schemas/Id"
        tracking_number:
          type: string
          description: The courier tracking number.
          examples:
            - SPXTH046123456789
        shipping_type:
          type: string
          description: |
            Courier key for this shipment, when it is one this API offers.
        products:
          type: array
          description: |
            What went out in it. A `qty` can be less than the order line's when
            the order ships in parts.
          items:
            $ref: "#/components/schemas/ShipmentProduct"
        create_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: When the shipment was recorded.
        cancel_time:
          allOf:
            - $ref: "#/components/schemas/EpochMillis"
          description: Set if the shipment was cancelled.
    ShipmentProduct:
      type: object
      required:
        - product_variant_id
        - qty
      properties:
        product_variant_id:
          $ref: "#/components/schemas/Id"
        qty:
          type: integer
          description: How many of that variant went out in this shipment.
    ShippingType:
      type: string
      description: |
        The courier, named by key — never a number. Combine it with `is_cod`:
        `is_cod: false` (the default) uses the plain key, and `is_cod: true`
        switches to that courier's cash-on-delivery key (`ems` becomes
        `ems_cod`). Sending a `_cod` key directly works too, but then `is_cod`
        must be `true`; a plain key with `is_cod: true` is a `400` when the
        courier has no COD form.

        Responses use these same keys plus `is_cod`. An order the store later
        switched, in the app, to a courier not on this list reads back under
        that courier's key — you just cannot ask for one.

        Not every courier here can be *booked* automatically: XShipping issues
        tracking numbers for the major ones (`ems`, `ecopost`, `kex`, `flash`,
        `jt`, `spx_pickup`, `spx_dropoff`), and for the rest the order is
        created with that shipping type and you supply the tracking number
        yourself. `store_front`, `buyer_pickup` and `other` are not couriers at
        all: they record that the customer collects the parcel.

        Each value below shows the name the XSelly app gives it under
        รูปแบบจัดส่ง, and the name of its cash-on-delivery form where it has one.
      examples:
        - spx_pickup
      enum:
        - best
        - best_pickup
        - buyer_pickup
        - dhl
        - dhl_bulky
        - dhl_pickup
        - ecopost
        - ems
        - ems_world
        - fast_instant_delivery_pack_2_hrs
        - fast_instant_delivery_pack_30_mins
        - fedex
        - flash
        - flash_bulky
        - flash_pickup
        - fuze
        - grab
        - inter
        - ittransport
        - jt
        - jt_cod_pickup
        - jt_pickup
        - kex
        - kex_dropoff
        - kex_pickup
        - lalamove
        - lex
        - lineman
        - makesend
        - nim
        - normal
        - other
        - register
        - scg
        - shopee_std_delivery
        - shopee_std_delivery_bulky
        - shopee_xpress
        - shopee_xpress_bulky
        - skootar
        - slow_instant_delivery
        - speedd
        - spx_dropoff
        - spx_express_bulky_cod
        - spx_pickup
        - store_front
        - tp
        - "true"
        - undefined
        - ups
        - zto
      x-enumDescriptions:
        best: "BEST Express · with `is_cod: true`: BEST Express [COD]"
        best_pickup: BEST Express [นัดรับ]
        buyer_pickup: ผู้ซื้อรับด้วยตนเอง
        dhl: "DHL Express · with `is_cod: true`: DHL Express [COD]"
        dhl_bulky: "DHL Bulky · with `is_cod: true`: DHL Bulky [COD]"
        dhl_pickup: DHL Express [นัดรับ]
        ecopost: "ไปรษณีย์ eCo-Post · with `is_cod: true`: ไปรษณีย์ eCo-Post [COD]"
        ems: "ไปรษณีย์ด่วนพิเศษ (EMS) · with `is_cod: true`: ไปรษณีย์ด่วนพิเศษ (EMS) [COD]"
        ems_world: EMS World (ระหว่างประเทศ)
        fast_instant_delivery_pack_2_hrs: ส่งทันที (แพ็ค 2 ชั่วโมง)
        fast_instant_delivery_pack_30_mins: ส่งทันที (แพ็ค 30 นาที)
        fedex: FedEx
        flash: "Flash Express · with `is_cod: true`: Flash Express [COD]"
        flash_bulky: "Flash Bulky · with `is_cod: true`: Flash Bulky [COD]"
        flash_pickup: Flash Express [นัดรับ]
        fuze: "FUZE · with `is_cod: true`: FUZE [COD]"
        grab: Grab
        inter: Inter Express Logistics
        ittransport: IT Transport
        jt: "J&T Express · with `is_cod: true`: J&T Express [COD]"
        jt_cod_pickup: J&T Express [COD นัดรับ]
        jt_pickup: J&T Express [นัดรับ]
        kex: "Kerry Express · with `is_cod: true`: Kerry Express [COD]"
        kex_dropoff: Kerry Express [Drop-Off]
        kex_pickup: Kerry Express [นัดรับ]
        lalamove: Lalamove
        lex: "Lazada Express · with `is_cod: true`: Lazada Express [COD]"
        lineman: Lineman
        makesend: "MAKESEND · with `is_cod: true`: MAKESEND [COD]"
        nim: "NiM Express · with `is_cod: true`: NiM Express [COD]"
        normal: ไปรษณีย์ธรรมดา
        other: อื่นๆ
        register: "ไปรษณีย์ลงทะเบียน · with `is_cod: true`: ไปรษณีย์ลงทะเบียน [COD]"
        scg: "SCG Express · with `is_cod: true`: SCG Express [COD]"
        shopee_std_delivery: SPX Standard Delivery
        shopee_std_delivery_bulky: SPX Standard Delivery Bulky
        shopee_xpress: "SPX Express · with `is_cod: true`: SPX Express [COD]"
        shopee_xpress_bulky: SPX Bulky
        skootar: Skootar
        slow_instant_delivery: ส่งด่วน
        speedd: "SPEED-D · with `is_cod: true`: SPEED-D [COD]"
        spx_dropoff: "SPX Express [Dropoff] · with `is_cod: true`: SPX Express [Dropoff COD]"
        spx_express_bulky_cod: "SPX Bulky [COD] · COD only, send it with `is_cod: true`"
        spx_pickup: "SPX Express [Pickup] · with `is_cod: true`: SPX Express [Pickup COD]"
        store_front: ขายหน้าร้าน
        tp: "TP Logistics · with `is_cod: true`: TP Logistics [COD]"
        "true": "True e-Logistics · with `is_cod: true`: True e-Logistics [COD]"
        undefined: ไม่ระบุ
        ups: ups (United Parcel Service)
        zto: "ZTO Express · with `is_cod: true`: ZTO Express [COD]"
    SalesChannel:
      type: string
      description: |
        Optional. Tags the order with where the sale came from — the
        ช่องทางการขาย shown in the XSelly app and used in its sales reports.
      examples:
        - sales_page
      enum:
        - ai_chat
        - branch
        - claim
        - consignment
        - dealer
        - distributor
        - eleven_street
        - event
        - facebook
        - facebook_page
        - foodpanda
        - friend
        - grab
        - influencer
        - instagram
        - japanesepartner
        - lazada
        - line
        - line_add
        - line_man
        - line_shopping
        - marketing
        - others
        - repurchase
        - resellers_not_yet_use_app
        - robinhood
        - sales_page
        - shop
        - shopee
        - shopee_food
        - shopify
        - telesales
        - tiktok
        - twitter
        - vrich
        - website
    LegalEntityType:
      type: integer
      description: |
        What the customer is, and with it what `legal_entity_id` holds. Only
        needed for a tax invoice, and paired with `branch_type`.

        An **individual**, whose `legal_entity_id` is a national id
        (เลขประจำตัวประชาชน): `1` บุคคลธรรมดา (a person), `3` ห้างหุ้นส่วนสามัญ
        (ordinary partnership), `4` ร้านค้า (a shop), `5` คณะบุคคล (group of
        persons).

        A **juristic person**, whose `legal_entity_id` is a tax id
        (เลขประจำตัวผู้เสียภาษี): `11` บริษัทจำกัด (limited company — the common
        one), `12` บริษัทมหาชนจำกัด (public limited company), `13` ห้างหุ้นส่วนจำกัด
        (limited partnership), `14` มูลนิธิ (foundation), `15` สมาคม
        (association), `16` กิจการร่วมค้า (joint venture), `19` อื่นๆ (other), and
        `2` นิติบุคคล — a legacy catch-all still stored on older addresses.
      enum:
        - 1
        - 2
        - 3
        - 4
        - 5
        - 11
        - 12
        - 13
        - 14
        - 15
        - 16
        - 19
      examples:
        - 11
    OrderState:
      type: integer
      description: |
        Where the order sits in its own lifecycle: `101` waiting to be
        confirmed; `102` confirmed by the buyer, waiting on the seller (reseller
        chains); `103` confirmed by the seller, waiting on the buyer; `109`
        **confirmed** — where an order created through this API starts; `181`
        cancelled by the seller; `182` cancelled by the buyer; `183` cancelled
        by the system; `184` cancelled after expiring unpaid.

        Any value from `181` up means cancelled, and `cancel_time` is then set.
      enum:
        - 101
        - 102
        - 103
        - 109
        - 181
        - 182
        - 183
        - 184
      examples:
        - 109
    PaymentState:
      type: integer
      description: |
        `111` awaiting payment — where a new order starts; `112` overpaid, more
        was received than the order is worth; `113` both a payment and a refund
        are open; `114` a payment is recorded and waiting to be confirmed; `115`
        a refund is waiting to be confirmed; `119` **paid in full**, with
        `payment_complete_time` set.
      enum:
        - 111
        - 112
        - 113
        - 114
        - 115
        - 119
      examples:
        - 111
    ShippingState:
      type: integer
      description: |
        `121` nothing shipped yet — where a new order starts; `122` only lines
        sourced from a supplier are left to ship; `129` **everything shipped**,
        with `shipping_complete_time` set.
      enum:
        - 121
        - 122
        - 129
      examples:
        - 121
    StockAvailableUpdatedEvent:
      type: object
      required:
        - request_id
        - event_type
        - request_time
        - data
      description: |
        The webhook envelope, carrying a `stock_available_updated` payload in
        `data`. Timestamp fields use the `*_time` suffix and are unix epoch
        **milliseconds**.
      properties:
        request_id:
          type: string
          description: Unique id of this delivery.
          examples:
            - evt_018f4b3c2a7e7d3ab1c9d2e4f5a6b7c8
        event_type:
          type: string
          const: stock_available_updated
          description: The event.
        request_time:
          type: integer
          format: int64
          description: When the request was sent (epoch ms).
          examples:
            - 1718385160415
        data:
          type: object
          required:
            - items
          properties:
            items:
              type: array
              description: One or more changes — changes are batched.
              items:
                $ref: "#/components/schemas/StockAvailableUpdatedItem"
    StockAvailableUpdatedItem:
      type: object
      required:
        - id
        - sku
        - old
        - new
        - warehouse_id
        - update_time
        - reason
      description: |
        One change of one variant's available quantity in one warehouse.
        `order_id` and `user_id` are mutually exclusive; which one accompanies a
        reason is listed under `reason`, and either may be absent.
      properties:
        id:
          type: string
          description: |
            Your product variant id — the same id the REST API calls
            `product_variant_id`.
          examples:
            - "456313132"
        sku:
          type: string
          description: Variant SKU. May be `""` when the variant has no SKU.
          examples:
            - SHIRT-RED-M
        old:
          type: number
          description: Available quantity before the change.
          examples:
            - 12
        new:
          type: number
          description: Available quantity after the change.
          examples:
            - 11
        warehouse_id:
          type: string
          description: Warehouse the change applies to.
          examples:
            - "12345"
        update_time:
          type: integer
          format: int64
          description: When the change happened (epoch ms). Sequence deliveries by it.
          examples:
            - 1718385160123
        reason:
          $ref: "#/components/schemas/StockChangeReason"
        order_id:
          type: string
          description: |
            Present only when an order caused the change. Mutually exclusive
            with `user_id`.
          examples:
            - "178465431"
        user_id:
          type: string
          description: |
            Present only when a user made the change. Mutually exclusive with
            `order_id`.
          examples:
            - "45431"
    StockChangeReason:
      type: string
      description: |
        Why the quantity changed. New reasons may appear at any time — accept
        unknown values.

        | reason | cause | actor field |
        |---|---|---|
        | `order_reserved` | new order reserved stock | `order_id` |
        | `order_edited` | product edited in an order | `order_id` |
        | `order_canceled` | order canceled by the buyer/reseller | `order_id` |
        | `order_canceled_by_system` | order canceled by the system | `order_id` |
        | `available_qty_reconciled` | available qty reconciled to remaining stock | `order_id` |
        | `shipping_canceled` | shipment canceled | `order_id` |
        | `variant_created` | variant created | `user_id` |
        | `user_adjusted` | user edited remaining quantity | `user_id` |
        | `user_added` | user manual add | `user_id` |
        | `returned` | return received | `user_id` |
        | `purchased` | purchase received | `user_id` |
        | `deposited` | deposit/refill | `user_id` |
        | `user_deducted` | user manual deduct | `user_id` |
        | `damaged` | damaged stock | `user_id` |
        | `lost` | lost stock | `user_id` |
        | `withdrawn` | withdrawn | `user_id` |
        | `user_set` | user set warehouse quantity | `user_id` |
        | `stock_counted` | stock count | `user_id` |
        | `system_init` | system initialization | — |
        | `admin_edited` | edited by administrator | — |
        | `system_corrected` | system correction | — |
        | `command_edited` | edited by system command | — |
        | `fullfilment_updated` | fulfillment service update | — |
        | `assemble_added` / `assemble_deducted` | product assembly | — |
        | `disassemble_added` / `disassemble_deducted` | product disassembly | — |
        | `bundle_converted` / `bundle_edited` | bundle operations | — |
        | `unknown` | other internal adjustment | — |
      examples:
        - order_reserved
      enum:
        - order_reserved
        - order_edited
        - order_canceled
        - order_canceled_by_system
        - available_qty_reconciled
        - shipping_canceled
        - variant_created
        - user_adjusted
        - user_added
        - returned
        - purchased
        - deposited
        - user_deducted
        - damaged
        - lost
        - withdrawn
        - user_set
        - stock_counted
        - system_init
        - admin_edited
        - system_corrected
        - command_edited
        - fullfilment_updated
        - assemble_added
        - assemble_deducted
        - disassemble_added
        - disassemble_deducted
        - bundle_converted
        - bundle_edited
        - unknown
  examples:
    CreatedOrder:
      summary: A COD order, moments after creation
      description: |
        No `shipments` yet, even though the order asked for a tracking number —
        XShipping books it moments later.
      value:
        request_id: req_01K5A7QW8ZP3RN4MB6C0YEXV2D
        order:
          id: "4536645"
          open_platform_channel_id: "12"
          external_order_id: SALEPAGE-10231
          order_state: 109
          payment_state: 111
          shipping_state: 121
          shipping_type: spx_pickup
          is_cod: true
          channel: sales_page
          sender_name: ร้านตัวอย่าง
          sender_address_id: "3653293"
          recipient_address_id: "3753465"
          recipient_address:
            id: "3753465"
            name: คุณทดสอบ ระบบ
            telephone: "0556789201"
            address1: 51/102 บางปะกง
            sub_district: บางปะกง
            district: บางปะกง
            province: ฉะเชิงเทรา
            postal_code: 24130
          total_amount: "1280.50"
          discount: "0.00"
          shipping_fee: "30.00"
          other_fee: "0.00"
          cod_fee: "30.00"
          cod_amount: "1250.50"
          products:
            - product_variant_id: "1984193"
              name: รองเท้าผ้าใบ
              variant: ชมพู,เบอร์ 28
              sku: SHOE-PINK-28
              qty: 1
              price: "1220.50"
              price_after_discount: "1220.50"
          create_time: 1789463270000
          order_time: 1789463270000
    ShippedOrder:
      summary: The same order once it is paid and shipped
      description: |
        `shipments[]` now carries the courier tracking number, and the payment
        and shipping milestones are set.
      value:
        request_id: req_01K5A8B2M4V7T0XKD9RHFGQ3NZ
        order:
          id: "4536645"
          open_platform_channel_id: "12"
          external_order_id: SALEPAGE-10231
          order_state: 109
          payment_state: 119
          shipping_state: 129
          shipping_type: spx_pickup
          is_cod: true
          channel: sales_page
          sender_name: ร้านตัวอย่าง
          sender_address_id: "3653293"
          recipient_address_id: "3753465"
          recipient_address:
            id: "3753465"
            name: คุณทดสอบ ระบบ
            telephone: "0556789201"
            address1: 51/102 บางปะกง
            sub_district: บางปะกง
            district: บางปะกง
            province: ฉะเชิงเทรา
            postal_code: 24130
          total_amount: "1280.50"
          discount: "0.00"
          shipping_fee: "30.00"
          other_fee: "0.00"
          cod_fee: "30.00"
          cod_amount: "1250.50"
          products:
            - product_variant_id: "1984193"
              name: รองเท้าผ้าใบ
              variant: ชมพู,เบอร์ 28
              sku: SHOE-PINK-28
              qty: 1
              price: "1220.50"
              price_after_discount: "1220.50"
          shipments:
            - id: "912233"
              tracking_number: SPXTH046123456789
              shipping_type: spx_pickup
              products:
                - product_variant_id: "1984193"
                  qty: 1
              create_time: 1789470000000
          create_time: 1789463270000
          order_time: 1789463270000
          payment_complete_time: 1789480000000
          shipping_complete_time: 1789479000000
          complete_time: 1789480000000
  parameters:
    XXSellySignature:
      name: X-XSelly-Signature
      in: header
      required: true
      description: |
        Lowercase hex HMAC-SHA256 of the **raw request body**, keyed with your
        webhook secret. Recompute it over the exact bytes you received, before
        any JSON parsing, and compare in constant time. Reject the request if
        they differ.
      schema:
        type: string
        pattern: ^[0-9a-f]{64}$
        examples:
          - 3f1c9a0d2b7e4f5a6c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
webhooks:
  stock_available_updated:
    post:
      tags:
        - Webhooks
      operationId: stockAvailableUpdated
      summary: Stock available quantity changed
      description: |
        Fires when a product variant's **available quantity** (พร้อมขาย)
        changes — an order reserving stock, a manual adjustment, a return, a
        cancelled shipment, and so on. `data.items[].reason` says which.

        Changes are batched, so one request may carry several items. Items are
        not guaranteed to arrive in order across requests; sequence them by
        `update_time`.

        `data.items[].id` is the same id the REST API calls
        `product_variant_id`, so it can go straight into
        `POST /v1/order/create`.
      parameters:
        - $ref: "#/components/parameters/XXSellySignature"
        - name: User-Agent
          in: header
          required: true
          description: Always `xselly-webhook/1.0`.
          schema:
            type: string
            examples:
              - xselly-webhook/1.0
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StockAvailableUpdatedEvent"
            examples:
              orderAndAdjustment:
                summary: One order reservation and one manual adjustment
                value:
                  request_id: evt_018f4b3c2a7e7d3ab1c9d2e4f5a6b7c8
                  event_type: stock_available_updated
                  request_time: 1718385160415
                  data:
                    items:
                      - id: "456313132"
                        sku: SHIRT-RED-M
                        old: 12
                        new: 11
                        warehouse_id: "12345"
                        update_time: 1718385160123
                        reason: order_reserved
                        order_id: "178465431"
                      - id: "456313134"
                        sku: SHIRT-BLUE-L
                        old: 14
                        new: 50
                        warehouse_id: "12345"
                        update_time: 1718385160123
                        reason: user_adjusted
                        user_id: "45431"
      responses:
        2XX:
          description: |
            Acknowledged. Any 2xx status, returned within **1 second**. The body
            is ignored. Do heavy processing after responding, not before.
        default:
          description: |
            Anything else — a non-2xx status or no answer within one second —
            means the delivery failed. It is **not** retried.
      security: []
