Kijiji Search Listings by Query
Purpose
Return a list of Kijiji classifieds postings matching a keyword query in a given city and category — title, price, location, posting date, listing ID, and canonical listing URL, plus the total result count for the query. Read-only; never posts, messages, or edits.
When to Use
- Monitoring new/updated listings matching a keyword in one or more Canadian cities.
- Bulk extraction across categories, cities, or provinces.
- Anywhere you'd otherwise scrape Kijiji's rendered HTML — the listings are already embedded as structured JSON in the server-rendered page, so a single HTTP fetch beats driving a browser.
Workflow
Kijiji's search results page is a Next.js app that server-renders the full listing data into the initial HTML as a __NEXT_DATA__ JSON blob (an Apollo GraphQL cache). You do not need to render JavaScript, log in, use cookies, or use stealth/residential proxies — a plain HTTP GET returns HTTP 200 with all 40 listings on the page fully decoded. Lead with the fetch path. The browser path (below) works as a fallback but pays a large cost/latency premium because it drives a headless session to read the exact same blob.
-
Build the search URL. The canonical SEO form is:
https://www.kijiji.ca/b-{category-slug}/{city-slug}/{keywords}/k0c{categoryId}l{locationId}- The human-readable slug segments (
b-bikes,city-of-toronto) are cosmetic — only the trailing codes are authoritative. You can pass placeholder slugs (e.g.b-x/x) and the results are identical. Keep{keywords}(the 3rd path segment) correct — that is the actual query. k0is the page-1 marker. For subsequent pages insert/page-N/before thek0c…l…segment:…/{keywords}/page-2/k0c{cat}l{loc}(page 2 = offset 40, page 3 = offset 80, …).c{categoryId}selects the category. Omit thec…code entirely for all categories (URL becomes…/k0l{locationId}), or usec0.l{locationId}selects the geography. Usel0for all of Canada.- Sort: append
?sort=dateDescfor newest-first (maps tosort=DATE&order=DESC); default (no param) issort=MATCHrelevance.priceAsc/priceDescalso work.
- The human-readable slug segments (
-
Fetch the HTML (no proxy, no stealth):
GET https://www.kijiji.ca/b-x/x/{keywords}/k0c{categoryId}l{locationId}e.g. via
browse cloud fetch "<url>". -
Extract the embedded JSON. Pull the contents of
<script id="__NEXT_DATA__" type="application/json">…</script>andJSON.parseit. Navigate toprops.pageProps.__APOLLO_STATE__(call itapollo). -
Locate the result set. In
apollo.ROOT_QUERY, find the single key beginningsearchResultsPageByUrl:→ call itsrp.srp.pagination→{ offset, limit, totalCount }(totalCount is the query-wide count; each page holds up to 40 organic results).srp.searchQuery.searchString→ the decoded server query params (useful sanity check:keywords=…&category=…&location=…&sort=…).srp.results.topListings→ array of{ "__ref": "StandardListing:<id>" }— promoted / TOP_AD ads; usually exclude these from organic results.- The key matching
srp.results["mainListings({...})"]→ array of{ "__ref": "StandardListing:<id>" }— the organic results for this page. (The key literally embeds the pagination args, e.g.mainListings({"pagination":{"limit":40,"offset":0}})— match by prefixmainListings.)
-
Decode each listing. For every ref, read
apollo["StandardListing:<id>"]:title— string.price— object.price.amountis in cents (divide by 100).price.typeisFIXED,FREE,SWAP_TRADE,PLEASE_CONTACT, etc.price.amountisnull/absent when the type isn't a fixed amount — guard for it.price.originalAmount(cents) present on price-drop listings.location.name(e.g. "Old Toronto"),location.address, andlocation.coordinates.{latitude,longitude}.sortingDate— ISO timestamp used for date-sort ordering (the "posted/bumped" date shown on the card).activationDate— original publish timestamp.url— absolute canonical listing URL (https://www.kijiji.ca/v-{catslug}/{cityslug}/{titleslug}/{id}). Use it directly; don't reconstruct it.adSource(ORGANICvsTOP_AD) andflags.topAdlet you filter promoted ads if any leak into a list.
-
Paginate if
pagination.totalCount > 40: refetch with/page-N/inserted beforek0c…l…and repeat steps 3–5. Keep to ≤ ~1 req/s.
Discovering category & location codes
- Category codes: every search page's
srp.controls.filteringhas acategorySectiongroup whosevalues[]list{ label, value (=categoryId), seoUrl, totalResults }for the current category subtree — read the code and the ready-madeseoUrlstraight from there. Verified (Bikes subtree):10=Buy & Sell (top),644=Bikes,645=BMX,649=Frames & Parts,650=Bike Clothing/Shoes/Accessories,14654001=eBike,15096001=Cruiser/Commuter/Hybrid,15096002=Fixie. - Location codes: the
locationSelectionfilter group lists sibling/child locations with codes +seoUrl. Verified:0=Canada (all), and provinces9004=Ontario,9007=British Columbia,9003=Alberta,9001=Québec,9006=Manitoba,9009=Saskatchewan,9002=Nova Scotia,9005=New Brunswick,9008=Newfoundland,9011=PEI,9010=Territories; cities/regions1700272=Toronto (GTA),1700273=City of Toronto. To resolve an arbitrary city, fetch a search in its province (l90xx) and read the child cities fromlocationSelection.values[].seoUrl, or start broad withl0(all Canada) and narrow via the returned filters.
Browser fallback
Only if the fetch path is ever blocked or the JSON shape changes:
browse open "<same SEO URL>" --remotethenbrowse wait load.browse get html bodyand extract the identical__NEXT_DATA__blob (steps 3–5 apply unchanged) — the page is fully server-rendered, so the listings are present in the initial DOM without scrolling.- If you must read from the rendered DOM instead, each card is a
<li data-testid="rich-card-list-item-N">/<article>with the title, price, location, date and an<a href>to the listing;browse get markdownrenders all 40 cards in one shot. This path was validated end-to-end via autobrowse but is ~100× the cost of the single fetch.
Site-Specific Gotchas
- No anti-bot / no proxy needed. Pre-run probe: no antibots detected; direct
browse cloud fetch(no--proxies, no--verified) returns HTTP 200 with the full data blob. Adding stealth is unnecessary overhead. The converged/successful method used neither. - Slugs are cosmetic — codes rule.
b-{category}/{city}slug segments are ignored by the backend; onlyc{categoryId}andl{locationId}matter.b-x/x/{kw}/k0c644l1700273returns exactly the same results as the pretty URL. Don't fail a run because you can't compute the exact slug — use placeholders. price.amountis in CENTS. Divide by 100. A listing priced $65 showsamount: 6500. Missing/null amount means non-fixed price (FREE,SWAP_TRADE,PLEASE_CONTACT) — surfaceprice.typerather than a bogus 0.- Separate
topListings(promoted) vsmainListings(organic).topListingsare paid TOP_AD placements and repeat across pages; for a clean organic result set use only themainListings(...)array. Individual TOP_AD entries can also appear insidemainListings— filter onadSource === "ORGANIC"/flags.topAd === falseif you need strictly organic. mainListingskey embeds its own args. The Apollo key is literallymainListings({"pagination":{"limit":40,"offset":0}}), so match it by themainListingsprefix, not an exact string.totalCountcan wobble by ±1 between fetches (e.g. 4701 vs 4702) as listings churn — it's a live count, not a stable value. Don't assert exact equality across pages.- Pagination is path-based, not a query param. Insert
/page-N/before thek0c…l…segment; page N maps tooffset = (N-1)*40. Apage-segment placed elsewhere is ignored. sortingDatevsactivationDate. The card's visible "date" issortingDate(reflects bumps/re-posts);activationDateis the original publish time. ReportsortingDateas the posting date unless you specifically need first-publish time.- Response Set-Cookie churn is harmless —
machId,kj-ct, etc. are set on every fetch; you can ignore them for read-only scraping (no session state required between requests). __NEXT_DATA__is ~150 KB and contains far more than listings (ads config, filters, i18n). Parse it, then index straight intoprops.pageProps.__APOLLO_STATE__— don't try to read the whole blob into an LLM context.- Inner-agent note (browser path): driving the DOM one card at a time blows the output-token budget (a naive autobrowse run truncated at 30 turns / max_tokens compiling 40 listings). If you fall back to the browser, read the
__NEXT_DATA__blob orbrowse get markdownin a single shot instead of per-card extraction.
Expected Output
{
"success": true,
"city": "City of Toronto",
"location_id": 1700273,
"category": "Bikes",
"category_id": 644,
"query": "bike",
"sort": "match",
"total_results": 4701,
"page_offset": 0,
"listings": [
{
"id": "1740296332",
"title": "USED Kids MTB Mountain bike for sale",
"price": 65.0,
"price_type": "FIXED",
"location": "City of Toronto",
"latitude": 43.65508,
"longitude": -79.42047,
"posted_date": "2026-07-08T14:37:05.000Z",
"activation_date": "2026-07-05T09:11:22.000Z",
"url": "https://www.kijiji.ca/v-kids-bike/city-of-toronto/used-kids-mtb-mountain-bike-for-sale/1740296332",
"ad_source": "ORGANIC"
},
{
"id": "1709602197",
"title": "Shotgun kids MTB Handlebars",
"price": 35.0,
"price_type": "FIXED",
"location": "Old Toronto",
"posted_date": "2026-07-07T15:58:54.000Z",
"url": "https://www.kijiji.ca/v-kids-bike/city-of-toronto/shotgun-kids-mtb-handlebars/1709602197",
"ad_source": "ORGANIC"
}
]
}
Non-fixed-price and free listings:
{
"id": "1730000001",
"title": "Free bike frame - pickup only",
"price": null,
"price_type": "FREE",
"location": "Ottawa",
"posted_date": "2026-07-06T12:00:00.000Z",
"url": "https://www.kijiji.ca/v-bike-frames-parts/ottawa/free-bike-frame/1730000001",
"ad_source": "ORGANIC"
}
No results (e.g. an over-narrow query):
{
"success": true,
"query": "zxqw-nonexistent",
"total_results": 0,
"listings": []
}