DEV Community

Harman Singh
Harman Singh

Posted on

An open JSON index of Toronto resale prices, with curl and jq

On 27 September 2026, 4,629 pieces were live at the two Toronto boutiques we list, with a median asking price of $400 in Canadian dollars. That reading is a public JSON file you can fetch with one curl.

We build shopwithin.co, which lists what is in stock at independent luxury boutiques. These are our notes on the price index we publish over those shelves: what it counts, how the median works, when it is counted, and how to query it.

The short answer

  • Page: Toronto Resale Price Index, with a schema.org Dataset entity
  • File: the JSON distribution, CC BY 4.0, CORS open to any origin
  • Counted: 4,629 pieces, 3,699 at Utopia and 930 at WDLT117
  • Prices: $10 to $21,000 CAD, median $400
  • Pre owned: 1,379 pieces carry a wear grade, 30 percent, median $400
  • Rows: 20 segments, 15 of them sneaker models

Every number here was read with curl on 27 September 2026.

What it counts

The index walks every live listing at the Toronto boutiques we list. Each price is the asking price the boutique set, unchanged.

A piece priced in another currency is dropped from every number, and the file says how many. On 27 September, counted.skipped was 0.

Condition comes from the wear grade the boutique itself wrote: pre owned, lightly used or worn once. Since 26 September 2026, "vintage" in a title no longer counts as a grade, because boutiques use it for a wash or an era.

Sold prices are not in it. We do not hold them, so we do not estimate them. These are asking prices, and the method text inside the JSON says so in plain words.

One finding surprised us. Only 1 of the 4,629 pieces states outright that it is new, and 3,249 state no condition at all. So the file carries statedNewMedian: null instead of a guess, and the page says why.

How the median is computed

The median is the lower of the two middle prices, which statisticians call the lower median. It is never an average. This is the whole function, shared by the page, the JSON route, the monthly snapshot and the tests.

// app/lib/designer-prices.ts, with line breaks added
/**
 * Low, median and high over a set of asking prices. The median is the lower
 * of the two middle prices on an even count, so every number a price page
 * prints is a price some piece is actually asking.
 */
export function bandOf(prices: readonly number[]): PriceBand | null {
  const sorted = [...prices]
    .filter((price) => Number.isFinite(price) && price > 0)
    .sort((a, b) => a - b);
  if (sorted.length === 0) return null;
  return {
    count: sorted.length,
    low: sorted[0],
    median: sorted[Math.ceil(sorted.length / 2) - 1],
    high: sorted[sorted.length - 1],
  };
}
Enter fullscreen mode Exit fullscreen mode

Give it [300, 350, 400, 450] and it returns 350, not 375. We chose that on purpose.

Every median in the file is a price some piece on a Toronto shelf is asking today. A journalist can quote it and a reader can go find that piece.

Two floors keep thin rows out. A segment needs 10 live pieces before it gets a row at all. The pre owned and stated new medians each need 3 pieces on their side.

You can see the second floor in the live file. Yeezy Slide had 12 pieces on 27 September, so it gets a row. Only 1 of them carried a wear grade, so its resaleMedian is null.

When it is counted

Nothing is precomputed into a table. The route reads the live catalog, runs the arithmetic, and lets the CDN share each response for ten minutes.

Each server instance holds its own count in memory for up to an hour, then reads the catalog again on the next request. So countedAt matters.

At 07:17 UTC on 27 September, the copy the CDN held said it was counted at 06:28 UTC, and its age header said 523 seconds. A read that missed the cache said 07:07 UTC.

The HTML page's dateModified said 06:28 UTC. All three said 4,629 pieces.

The series is separate. A script appends one row a month to a JSON file committed to the repo, and a page render never writes it.

A second run in the same month replaces that month's row. On 27 September the history array held one row: September 2026, 4,700 pieces, median $400.

The route refuses to publish a partial count. If the latest read missed part of any boutique's catalog, it falls back to the last whole count this instance took in the past 24 hours.

With no whole count on hand, it answers 503 with no-store. It never answers 200 with a small number, and never 404.

This rule came from a real bug. A sync blip once read only a small slice of the catalog. The JSON published that count under the shared cache header, and the CDN went on serving it after the catalog was whole again.

// app/routes/index_.toronto-resale[.]json.tsx, lightly trimmed and reformatted
export const loader = async () => {
  const census = await wholeCensusOrNull(); // a whole reading, or null
  if (!census) throw dark(); // 503, retry after 10 s, cache-control: no-store
  const model = resolveResaleIndex(census, SEO_CITY);
  if (!model) throw dark();
  const body = JSON.stringify(resaleIndexJson(model, resaleIndexHistory()), null, 2);
  return new Response(`${body}\n`, {
    headers: {
      "content-type": "application/json; charset=utf-8",
      // "public, max-age=0, s-maxage=600, stale-while-revalidate=86400"
      "cache-control": SHARED_PRICE_CACHE_CONTROL,
      link: '<https://creativecommons.org/licenses/by/4.0/>; rel="license"',
      "access-control-allow-origin": "*",
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

The 404 rule is about search. A 404 tells Google to drop a URL, and a catalog that failed to answer is not an empty one. A 503 with retry-after asks the crawler to come back.

The licence travels in a Link header, so a script that never sees the page still gets it.

One trap if you check the headers yourself. The CDN keeps s-maxage for itself, so a GET on 27 September showed only public, max-age=0. The age and x-vercel-cache: HIT headers are what tell you a shared copy answered.

Using it with curl and jq

These ran against the live file on 27 September 2026. The output is what came back, with tabs shown as spaces.

# The headline numbers, and when they were counted
curl -s https://shopwithin.co/index/toronto-resale.json \
  | jq -c '{countedAt, pieces: .counted.pieces, median: .counted.median, resaleShare: .counted.resaleShare}'
# {"countedAt":"2026-09-27T06:28:15.861Z","pieces":4629,"median":400,"resaleShare":30}

# Sneaker models, deepest first: label, pieces, median, pre owned median
curl -s https://shopwithin.co/index/toronto-resale.json \
  | jq -r '.segments[] | select(.parent == "sneakers") | [.label, .pieces, .median, .resaleMedian] | @tsv' \
  | head -4
# Jordan 1      241  350  300
# Jordan 4      134  350  300
# Travis Scott  132  450  1000
# Kobe          107  400  350
Enter fullscreen mode Exit fullscreen mode

The Travis Scott row is the one to read slowly. Its 57 pre owned pieces ask a median of $1,000, while all 132 ask a median of $450. A wear grade does not always mean a lower price.

# Which rows link to a page on the site
curl -s https://shopwithin.co/index/toronto-resale.json \
  | jq -c '[.segments[] | select(.shelf != null) | .label]'
# ["Sneakers","Jordan 1","Jordan 4","Chrome Hearts","Supreme"]
Enter fullscreen mode Exit fullscreen mode

Five of the 20 rows carry a shelf URL, such as /shop/toronto/sneakers/jordan-1. The other 15 leave the shelf key out entirely. jq reads a missing key as null, so the filter works either way.

A sneaker model links only where the site has a page for that model. So Travis Scott and Kobe get no shelf key, not a link to the nearest shelf.

Drift against the stored month is one more filter. jq '.counted.pieces - .history[-1].pieces' returned -71 on 27 September, from 4,700 in the September row to 4,629 live.

What we still owe

Nothing schedules the monthly script yet. We run it by hand.

While checking this post on 27 September, we found that script still calls a catalog read the server module no longer exports. The October row needs that one line fixed first.

The lesson is general. The script is plain .mjs and loads the TypeScript modules through Vite's ssrLoadModule. Those exports come back untyped, so the type checker never saw the stale name.

Two boutiques is a small sample. The file names both and their counts, so you can weigh it yourself.

If you use the numbers, cite them the way the file's own citation field does: "Source: within, Toronto Resale Price Index, September 2026, shopwithin.co/index/toronto-resale". The licence is CC BY 4.0.

Top comments (0)