Browse and search products

GET/api/v1/products
Required scope:catalog:read

Browse 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

NameTypeHow to use it
platformRequiredstring
The product's platform, matched exactly including case, e.g.
Details and caveats
  • Steam
  • EA
  • Uplay
  • Minecraft
  • omitting it returns 400 bad_request. That is deliberate rather than returning an empty list, because an empty list is indistinguishable from this shop sells nothing.
  • To sync the whole catalogue, walk one platform at a time.
typeOptionalstring
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 account
Details and caveats
  • The first two are ordered with product_id alone, while account2 needs an item_id as well, because every pre-owned account is different (different games, level, price) and you pick the one you want from GET /api/v1/products/{product_id}/items
  • That split runs through everything: it decides which endpoint gives you the price to send as expected_price, and it decides which of the two shapes a delivered order comes back in.
genreOptionalstring
Filter by one value from the product's genres field
Details and caveats
  • Omit it for no filter, and the value all means no filter as well (so you can always send the parameter instead of removing it from the query)
  • all is therefore not a genre name: even if a genre by that name existed in the data, you could not filter on it
  • Any other value is matched exactly, including case, and a genre with no products returns an empty list, which is not an error.
searchOptionalstring
A starts-with match on the normalised name, which means you must normalise your term yourself first
Details and caveats
  • all we do to it is trim the surrounding whitespace and lower-case it, so a term still carrying spaces or punctuation (counter-strike, counter strike) matches nothing at all and returns an empty list with no error to show for it
  • The normalised name is the same value as the slug field: lower case only, a-z and 0-9 only, no spaces or punctuation
  • counter matches counterstrike2, while strike does not, because it is not at the start
  • ⚠️ search cannot be combined with cursor: sending both is refused with 400 cursor_with_search, so use search for one-off lookups and walk cursor without search when you sync
  • The result is not a relevance ranking; it is simply the set that matched, with no judgement about which is the better answer.
sortOptionalstring
The ordering, defaulting to rating, with an unrecognised value read as rating too
Details and caveats
  • The accepted values are rating
  • price (high to low)
  • price-asc
  • recommendations
  • visitor
  • newest
  • Do not change it midway through a cursor walk: a cursor is only valid for the sort and platform it was issued under, and using it with another one is refused with 400 invalid_cursor.
limitOptionalnumber
Items per page: 30 by default, 60 at most
Details and caveats
  • An unreadable value (not a whole number, or below 1) falls back to the default, and a value above the ceiling is clamped down to it
  • neither makes the request fail.
cursorOptionalstring
Position marker for the next page, taken verbatim from the previous page's next_cursor, and **opaque
Details and caveats
  • never decode one or build one yourself**, because its internal shape can change without notice
  • ⚠️ A cursor we cannot use is refused with 400 invalid_cursor, not answered with the first page: that covers one that is too long, one that cannot be decoded, one that was truncated in transit, and one issued under a different sort or platform
  • Answering with the first page instead would silently send a sync back to the start of the catalogue, so the refusal is deliberate
  • handle the 400, do not assume a bad cursor is harmless.

Header

NameTypeHow to use it
AuthorizationRequiredstring
Your key as Bearer <key>. Required on every request; there is no other way to authenticate.

Response fields

NameTypeHow to use it
productsarray
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.idstring 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.gamestring or null
The product name shown to customers
products.platformstring or null
The product's platform — the same value you pass as the platform parameter
products.typestring or null
The product type: cdkey · account1 · account2 — this is what decides how you order it; see the type parameter above
products.slugstring or null
The normalised name, used to build shop links and the same value the search parameter matches on
products.imagestring 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.ratingnumber 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.genresarray 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.availablefalse or null
Stock state, and it has only two values: false means confirmed out of stock, null means nobody checked this product on this pass
Details and caveats
  • there is no true, so hide only the products that are false and never test for true
products.pricenumber
What this key pays, in baht, already discounted
Details and caveats
  • the price in this list has not been through the per-product price floor, so it can be lower than the price the system will accept
  • Confirm it on a single-item endpoint before ordering
products.discount_percentnumber
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_moreboolean
true = there is a further page
Details and caveats
  • false = you can stop
  • We also set it to false (together with next_cursor as null) when we detect that the position marker cannot advance because some documents hold a wrong value type
  • we stop you on purpose rather than let you loop over the same page forever
  • From your side the two cases look exactly the same, so if you end up with far fewer products than you expected, tell the shop
next_cursorstring or null
Send this back as the cursor of the next request
Details and caveats
  • it is null when we cannot go further, which always comes with has_more as false
  • it is opaque: do not decode one or build one yourself
  • ⚠️ the catalogue can change while you are paging, so products added or delisted midway can make an item appear twice or be skipped
  • always de-duplicate on id on your side
search_cappedboolean
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

StatusCodeMeaning
200
A page, with more to come
has_more is true and next_cursor carries the marker for the following request
Details and caveats
  • Every product always carries the full set of fields: id
  • game
  • platform
  • type
  • slug
  • image
  • rating
  • genres
  • available
  • price
  • discount_percent
  • a field that is null means that value could not be read or does not exist, not that it is a meaningful empty value
  • The third entry deliberately shows values that could not be read (image and rating are null, genres is an empty array); all three happen to real products routinely and are not a special case
  • Every example on this page is the answer given to a fictional key whose discount is set to 12%, which is why every entry reports discount_percent as 12
  • Each product type also has a cap of its own that limits how much of a tier is actually applied, and the value it resolves to per type is readable from GET /api/v1/me
  • Product ids, images, game names and all figures are made up; none of them is real shop data.
200
The last page — stop here
has_more is false and next_cursor is null together, so you can stop
Details and caveats
  • The whole paging routine is: make the first request without a cursor, then send each next_cursor you receive as the cursor of the following request, and stop when has_more is false or next_cursor is null.
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
  • It is not an error, and it does not mean something is wrong with your key
  • Products we cannot price are also dropped from the list silently rather than returned at a price of 0.
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 request
Details and caveats
  • The fix is to search again with a longer, more specific term, not to page further
  • a search has no page two.
400bad_request
platform was not sent
platform is the only required parameter of this endpoint · Add it and send the request again — nothing was left in progress.
400invalid_cursor
The cursor you sent cannot be used for this request
Start the walk again from the first page, without a cursor
Details and caveats
  • You get this when the cursor was truncated or altered in transit, when it is longer than we accept, or when you changed sort/platform midway through a walk (a cursor is only valid for the parameters it was issued under)
  • We refuse it rather than quietly answering with the first page, because the quiet version sends your sync back to the beginning over and over without anyone noticing.
400cursor_with_search
cursor and search were sent together (they cannot be combined)
Send one or the other, never both
Details and caveats
  • A search has no page two, so there is no cursor to use with it
  • Use search for one-off lookups, and walk cursor without search, one platform at a time, when you sync the whole catalogue.
Last updated 2026-09-07