Browse and search products
/api/v1/productscatalog:readBrowse and search the catalogue, one platform at a time. Three things to know before you write any code: (1) platform is the only required parameter and there is no way to ask for every platform at once, so syncing the whole catalogue means looping platform by platform · (2) every product carries a type that decides how you order it — cdkey (a game key) and account1 (a never-used account) are ordered with product_id alone, while account2 (a pre-owned account) needs you to pick an individual account first and send its item_id too · (3) prices here are in Thai baht with your key's discount already taken off, but they are not final: this endpoint skips the per-product price floor, so a price here can be slightly lower than the real one — always read the price from a single-item endpoint before you order.
A missing available means not checked, not out of stock
This field only ever comes back as false (confirmed out of stock) or null (not checked against the source). There is no true — treat it as a value that does not exist rather than one you are waiting for. Catalogue endpoints answer from data the shop already holds; they do not query the source on every call, so most products come back as null, which is the truth: nobody checked that product's stock on this pass. Never test available === true, and never write a condition like not true means sold out — reading it that way hides nearly the whole catalogue, products that really are for sale, from your own storefront. Hide only the ones that are false. Real stock is checked against the source again when the order is placed, which is the only point at which an answer about availability is dependable.
The price you get back is already discounted — never discount it again
price is the number of baht this key pays, not the shop's shelf price, and discount_percent says how many percent already came off the shelf price. Taking your discount off it a second time produces a price the system will never accept, and then every order is refused on price with nothing to tell you why. Your discount is set as a single number, but each product type has a cap of its own; the discount actually applied is whichever of your tier and that cap is smaller, and you can read the already-resolved value per type from GET /api/v1/me in the discount.effective_percent field — do not compute prices on your side from the raw discount number. Prices also move all the time, both with what a product costs us and with the shop's own pricing: store them for as long as you like, but always re-read the price immediately before ordering.
Prices in this list are not final — read the price from a single-item endpoint before ordering
On top of the discount cap there is a per-product price floor: if the discounted price would fall below what that product costs us, we raise the price back up to that cost (but never above the shelf price) and report discount_percent as the discount you actually got, which is less than the cap. GET /api/v1/products does not apply that floor, so prices in the list are approximate and can be lower than the price the system will accept. Before ordering, confirm the price with GET /api/v1/products/{product_id} for cdkey and account1, or with GET /api/v1/products/{product_id}/items for account2, and send that value as expected_price with the order. A price that does not match what the system computes at order time is rejected, not silently adjusted.
A search has no page two — search and cursor together are refused
A starts-with search cannot use a position marker alongside an ordering on another field, so there is no paging within a search at all. Sending search and cursor in the same request is refused with 400 cursor_with_search rather than answered, precisely because the older behaviour — quietly ignoring the cursor — left a sync loop running for ever over the same set with no error status and no field telling it what had happened. A search therefore returns one page: everything that matched, up to the cap reported by search_capped. If that cap was hit, search again with a longer, more specific term; do not try to page past it. To sync the whole catalogue, walk cursor without search, one platform at a time.
The authentication refusals reach this endpoint too
The five refusals at the authentication gate — invalid_key (401) · key_revoked (401) · insufficient_scope (403) · quota_exceeded (429) · too_many_inflight (429) — can happen on every endpoint, this one included, because they are thrown before the request reaches a single line of this endpoint's own logic (the scope it needs is the one declared at the top of this page). The response tabs above therefore list only what this endpoint itself answers; that is not a claim that those five cannot happen here. All of the detail lives in one place, on the identity endpoint page at /developers/access/me: the HTTP status of each one, which of them spend your daily quota, which headers come back, and why the two 429s need opposite handling. It is deliberately not copied onto every page, because the second copy is the one nobody remembers to update the day the rules change.
Request parameters
Query string
| Name | Type | How to use it |
|---|---|---|
| platformRequired | string | The product's platform, matched exactly including case, e.g. Details and caveats
|
| typeOptional | string | Which of the three product types to list, defaulting to all, with an unrecognised value read as all too: cdkey is a game key, account1 is a game account that has never been used, and account2 is a pre-owned game accountDetails and caveats
|
| genreOptional | string | Filter by one value from the product's genres fieldDetails and caveats
|
| searchOptional | string | A starts-with match on the normalised name, which means you must normalise your term yourself first Details and caveats
|
| sortOptional | string | The ordering, defaulting to rating, with an unrecognised value read as rating tooDetails and caveats
|
| limitOptional | number | Items per page: 30 by default, 60 at mostDetails and caveats
|
| cursorOptional | string | Position marker for the next page, taken verbatim from the previous page's next_cursor, and **opaqueDetails and caveats
|
Header
| Name | Type | How to use it |
|---|---|---|
| AuthorizationRequired | string | Your key as Bearer <key>. Required on every request; there is no other way to authenticate. |
Response fields
| Name | Type | How to use it |
|---|---|---|
| products | array | The products on this page, ordered by the sort you asked for · It can always be shorter than limit, and it can be empty without that being an error |
| products.id | string or null | The product id · This is the product_id you order with and the one you ask for individual listings with — use it to de-duplicate while paging as well |
| products.game | string or null | The product name shown to customers |
| products.platform | string or null | The product's platform — the same value you pass as the platform parameter |
| products.type | string or null | The product type: cdkey · account1 · account2 — this is what decides how you order it; see the type parameter above |
| products.slug | string or null | The normalised name, used to build shop links and the same value the search parameter matches on |
| products.image | string or null | Portrait cover image URL · null when there is no usable image, which happens to real products often enough that you need a fallback image of your own |
| products.rating | number or null | The game's rating from our data source — not a shop review score, and not an indication that the product is in stock |
| products.genres | array of strings | The game's genres · any value in here works as the genre parameter · an empty array means no genres are recorded, not that no genre matched |
| products.available | false or null | Stock state, and it has only two values: false means confirmed out of stock, null means nobody checked this product on this passDetails and caveats
|
| products.price | number | What this key pays, in baht, already discounted Details and caveats
|
| products.discount_percent | number | The discount actually applied to this product, in percent · it is already taken off price; it is not a discount waiting for you to apply it |
| has_more | boolean | true = there is a further pageDetails and caveats
|
| next_cursor | string or null | Send this back as the cursor of the next requestDetails and caveats
|
| search_capped | boolean | true = the search hit the cap on how much we will read, and products matching your term were left out · always false when no search was sent |
Possible statuses
| Status | Code | Meaning |
|---|---|---|
| 200 | — | A page, with more to come has_more is true and next_cursor carries the marker for the following requestDetails and caveats
|
| 200 | — | The last page — stop here has_more is false and next_cursor is null together, so you can stopDetails and caveats
|
| 200 | — | Nothing matches the filters — not an error An empty list is the normal answer to filters that are too narrow (a genre with no products, a type that platform does not sell)Details and caveats
|
| 200 | — | Too many search matches — some were left out search_capped is true, meaning products that match your term exist but were left out, because the number of matches exceeded what we will read for one requestDetails and caveats
|
| 400 | bad_request | platform was not sentplatform is the only required parameter of this endpoint · Add it and send the request again — nothing was left in progress. |
| 400 | invalid_cursor | The cursor you sent cannot be used for this requestStart the walk again from the first page, without a cursorDetails and caveats
|
| 400 | cursor_with_search | cursor and search were sent together (they cannot be combined)Send one or the other, never both Details and caveats
|