How to Scrape ASOS Product Data Across 14 Storefronts
How to scrape ASOS product data when one product id returns 15 different prices. The store codes, the stockprice API, and the checks that prove it.
Ask how to scrape ASOS product data and the useful answer starts with a question back: which country's data? On 9 October 2026 I pulled product 5692333 fifteen times from one IP in Bengaluru and got fifteen different answers, including 60.00 GBP in Britain, 97.00 USD in the United States, and zero variants in stock across the entire euro zone. The product id never changed. This guide covers which parameters actually decide what you get back, how to assert in code that you got it, and what the finished job costs.
Fourteen store codes, 250 countries, one product id
ASOS publishes its own country map. GET https://www.asos.com/api/fashion/store/v2/stores/countries returns an array that I fetched on 9 October 2026: 250 country entries resolving to 14 distinct store codes, 19 currencies and 9 size schemas. No auth, no key, plain JSON.
Each entry carries country, store, siteId, siteUrl, currencies, sizeSchemas and languages. A further thirteen entries have store: null (BV, CU, GG, HM, IM, IR, JE, KP, LY, SO, SY, VE, YE); those are the minorCountries that the site chrome folds into a neighbour.
| Store code | siteId | Countries served | URL prefix |
|---|---|---|---|
| ROW | 24 | 170 | none |
| ROE | 26 | 33 | none |
| FR | 3 | 14 | /fr/ |
| AU | 9 | 4 | /au/ |
| IT | 7 | 3 | /it/ |
| ES | 8 | 2 | /es/ |
| DE | 4 | 2 | /de/ |
| RU | 12 | 2 | /ru/ |
| US | 2 | 2 | /us/ |
| COM | 1 | 1 (GB) | none |
| DK | 32 | 1 | /dk/ |
| NL | 30 | 1 | /nl/ |
| PL | 34 | 1 | /pl/ |
| SE | 28 | 1 | /se/ |
Read the ROW and COM rows together, because that pairing is where most ASOS scrapers go wrong. The United Kingdom store code is COM, not GB. Pass store=GB to any catalogue endpoint and you get HTTP 400 with {"errorCode":"pdt_002","errorMessage":"Invalid Store Requested"}. Meanwhile ROW is a single code covering 170 countries, so knowing the store code tells you almost nothing about the currency you are about to receive. Eleven of the fourteen codes sit behind a URL path prefix; COM, ROE and ROW all answer on the bare www.asos.com.
This is the shape of the problem that makes ASOS different from the flat, single-catalogue retailers. Marketplaces like Etsy and AliExpress localise the presentation of one listing. ASOS localises the catalogue itself, and the exit country of your proxy is not what selects it.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Reading storeCode out of the page you fetched
Never infer the storefront from the URL you sent. Read it from the response. ASOS hands you the answer in a script tag with a literal id, window.asos.siteChrome._initialStore, on every page of the site.
import html
import json
import re
STORE_RE = re.compile(
r'id="window\.asos\.siteChrome\._initialStore"[^>]*>(.*?)</script>',
re.S,
)
def read_store(page_html):
"""Return the regionalStore block ASOS embeds in every page."""
match = STORE_RE.search(page_html)
if not match:
raise ValueError("no _initialStore block: not an ASOS storefront page")
blob = json.loads(html.unescape(match.group(1).strip()))
return blob["regionalStore"]
def assert_storefront(page_html, want_store, want_currency):
store = read_store(page_html)
got = (store["storeCode"], store["defaultCurrency"])
if got != (want_store, want_currency):
raise AssertionError(
f"wanted {want_store}/{want_currency}, server served {got[0]}/{got[1]} "
f"(siteId {store['siteId']}, country {store['countryCode']}, "
f"sizes {store['defaultSizeSchema']})"
)
return store
Calling assert_storefront(page, "US", "USD") turns a silent currency bug into a loud failure. A scraper that skips it will write sterling prices into a column labelled USD for weeks, and nothing in the HTTP status code will tell you.
Two fields in that block belong alongside every row. keyStoreDataversion was 7qyyrb1-46 on both the GB and US storefronts when I checked, and it rolls when ASOS changes store configuration, which makes it a useful cache key. siteId is the stable integer behind the store code and the better database column.
The stockprice call the product page makes for you
Product pages follow /{brand-slug}/{product-slug}/prd/{productId}, for example /asos-design/asos-classic-trench-coat/prd/5692333. The page carries JSON-LD, and here is the trap: on the GB product page I fetched, the Product node's offers value was the empty object {}. No price, no currency, no availability. Anyone who built an ASOS parser around JSON-LD offers is collecting nulls.
Price and stock arrive from a separate call, and ASOS writes the exact URL into the page for you:
GB /api/product/catalogue/v4/stockprice?productIds=5692333&store=COM¤cy=GBP&keyStoreDataversion=7qyyrb1-46&country=GB
US /api/product/catalogue/v4/stockprice?productIds=5692333&store=US¤cy=USD&keyStoreDataversion=7qyyrb1-46&country=US
Both strings came out of window.asos.pdp.config.stockPriceApiUrl on the two storefronts. Better still, the page also embeds window.asos.pdp.config.stockPriceResponse, which is the full response rendered server side. That means a plain HTTP GET of the product page already contains every variant price and stock flag. No browser, no JavaScript execution, no second request.
Take the embedded copy when you are already fetching the page, and call the endpoint directly when you only need numbers. It accepts up to 100 ids per request, which I confirmed by walking the parameter up until it broke:
productIds (100 ids) -> 200, 100 items, 1,229,775 bytes
productIds (200 ids) -> 400 {"errorCode":"pdt_002","errorMessage":"The field ProductIds
must be a string or array type with a maximum length of '100'."}
That cap is the single most useful number in this article. One hundred products per credit changes the economics of a daily price feed completely.
import requests
STOCKPRICE = "https://www.asos.com/api/product/catalogue/v4/stockprice"
def chunks(seq, size=100):
for i in range(0, len(seq), size):
yield seq[i:i + size]
def fetch_prices(product_ids, store, currency, country, session=None):
"""Yield (product_id, price, currency, in_stock) for a storefront."""
http = session or requests.Session()
for group in chunks(list(product_ids), 100):
params = {
"productIds": ",".join(str(i) for i in group),
"store": store,
"currency": currency,
"country": country,
}
resp = http.get(STOCKPRICE, params=params, timeout=30)
resp.raise_for_status()
for item in resp.json():
price = (item.get("productPrice") or {}).get("current") or {}
variants = item.get("variants") or []
seen = {v.get("price", {}).get("currency") for v in variants}
if seen and currency not in seen:
raise AssertionError(
f"asked for {currency}, response carries {sorted(seen)}"
)
yield (
item["productId"],
price.get("value"),
currency,
bool(item.get("isInStock")),
)
The currency assertion inside the loop is deliberate. The response repeats the currency on every variant, so there is no excuse for trusting the request.
Store and currency are validated as a pair
The endpoint does not quietly coerce bad input, which is good news: every mistake is a 400 with a readable message. These are the exact responses I recorded on 9 October 2026.
| Request | HTTP | `errorMessage` |
|---|---|---|
| `store=GB¤cy=GBP` | 400 | Invalid Store Requested |
| `store=ZZ¤cy=GBP` | 400 | Invalid Store Requested |
| `store=COM¤cy=USD` | 400 | Invalid Currency Requested |
| `store=US¤cy=GBP` | 400 | Invalid Currency Requested |
| `productIds=...` (no store) | 400 | The Store field is required. |
| `store=COM&country=IT` | 400 | Country must be a valid country code |
| `store=COM¤cy=GBP&country=gb` | 200 | lowercase country accepted |
| `store=COM¤cy=GBP` (no country) | 200 | `country` is optional |
Three rules fall out of that table. Store and currency form a validated pair, so you cannot ask the British store for dollars. The country value must be one the named store actually serves, which is why store=COM&country=IT fails even though IT is a real ISO code. And keyStoreDataversion is optional; dropping it still returned 200 with identical pricing.
One store code in the countries map did not work for listings at all. store=RU¤cy=USD returned 400 with {"parameterName":"storeapi","errorMessage":"No valid s..."} on the search endpoint, so treat that map as a superset of what you can query and let the 400s prune it.
Where ROW breaks the one-store-one-currency rule
Geo-aware scraping guides assume store code and currency move together. On ASOS that holds for twelve store codes and collapses for two:
| Store code | Countries | Distinct primary currencies | Distinct primary size schemas |
|---|---|---|---|
| ROW (siteId 24) | 170 | 14 | 4 |
| ROE (siteId 26) | 33 | 4 | 3 |
The 14 currencies under ROW break down as USD for 149 countries, then GBP for 4, NZD for 4, CHF for 2, HKD for 2, and one each of AED, CAD, CNY, ILS, SAR, SGD, AUD, TWD and EUR. All 170 countries share one URL, www.asos.com, with no path prefix. Two scrapers hitting the identical URL with the identical store code can legitimately receive Emirati dirhams and Taiwan dollars.
I checked that this is real pricing rather than display conversion, same product, varying only the store, country and currency triple:
| store | country | currency | Price | Variants in stock |
|---|---|---|---|---|
| COM | GB | GBP | 60.00 | 1 of 7 |
| US | US | USD | 97.00 | 1 of 7 |
| ROW | IN | USD | 95.00 | 1 of 7 |
| ROW | CA | CAD | 127.00 | 1 of 7 |
| ROW | SG | SGD | 128.99 | 1 of 7 |
| ROW | AE | AED | 280.00 | 1 of 7 |
| ROW | CH | CHF | 93.99 | 1 of 7 |
| AU | AU | AUD | 130.00 | 1 of 7 |
| DE | DE | EUR | 79.99 | 0 of 7 |
| FR | FR | EUR | 79.99 | 0 of 7 |
| ROE | BE | EUR | 82.99 | 0 of 7 |
| ROE | NO | NOK | 939.00 | 0 of 7 |
| SE | SE | SEK | 812.00 | 0 of 7 |
| PL | PL | PLN | 326.00 | 0 of 7 |
| DK | DK | DKK | 578.00 | 0 of 7 |
Two things jump out. USD 95.00 under ROW is not USD 97.00 under the US store, so the same currency buys the same coat at two prices depending on which store answered. And the euro and Nordic block had zero of seven variants available while Britain, America, Australia and the ROW countries each had one. Stock is per store, not per product, so a dashboard reading "in stock" from one storefront reports one market's inventory as global truth.
Your schema needs (product_id, store_code, country, currency) as its key, not product_id. Teams running geo pricing audits hit this on every multi-market retailer, and ASOS is the sharpest example I have measured.
Catalogue size is a per-country number
The split goes deeper than price. The listing endpoint returns itemCount, and that count is not constant across storefronts for one category id. Category 2641 is Coats and Jackets:
| store | country | Localised category name | itemCount |
|---|---|---|---|
| COM | GB | Coats & Jackets | 5,125 |
| DE | DE | Mäntel und Jacken | 4,349 |
| IT | IT | Cappotti e Giacche | 4,221 |
| FR | FR | Manteaux et vestes | 4,196 |
| NL | NL | Jassen en jacks | 4,097 |
| DK | DK | Jakker og frakker | 4,024 |
| ROE | BE | Coats & Jackets | 3,974 |
| SE | SE | Kappor & jackor | 3,968 |
| ROW | CA | Coats & Jackets | 3,578 |
| ES | ES | Abrigos y chaquetas | 3,564 |
| PL | PL | Płaszcze i kurtki | 3,448 |
| ROW | AE | Coats & Jackets | 3,321 |
| ROW | SG | Coats & Jackets | 3,159 |
| AU | AU | Coats & Jackets | 3,121 |
| US | US | Coats & Jackets | 2,525 |
| ROW | IN | Coats & Jackets | 2,500 |
Britain sees 5,125 coats. India sees 2,500. That is a 2.05x spread on the same category id, and the three ROW rows differ from each other by over a thousand items despite sharing a store code. If your competitive set was built from one storefront, roughly half of what ASOS sells in Britain is invisible to it.
The lang parameter controls the localised categoryName, which gives you a free second assertion: if you asked for lang=de-DE and got back "Coats & Jackets", your request did not land where you thought.
Three calls that scrape ASOS product data end to end
Three unauthenticated JSON endpoints cover everything a product feed needs. All three returned 200 for me on 9 October 2026.
| Endpoint | Returns |
|---|---|
| `/api/product/search/v2/categories/{categoryId}` | `itemCount`, `categoryName`, `products[]` for a category |
| `/api/product/search/v2/?q={term}` | `searchTerm`, `itemCount`, `products[]` for a keyword |
| `/api/product/catalogue/v4/summaries?productIds=` | name, `brandId`, `brandName`, images, `sizeGuide`, `isNoSize` |
The listing calls take channel=desktop-web, country, currency, store, lang, sizeSchema, limit and offset. Two behaviours worth knowing before you write the pager, both measured rather than documented.
limit is capped at 200 and the cap is silent. I asked for 1000 and received exactly 200 products in a byte-identical response to limit=200. There is no error, so a loop that trusts its own limit skips 80% of each page.
offset walks the full count with no deep-pagination wall. At offset=5000 I still got products, at offset=5124 the single final item, and at offset=5200 an empty array. So 5,125 items really means 26 requests.
import requests
SEARCH = "https://www.asos.com/api/product/search/v2/categories/{cid}"
PAGE = 200 # hard server cap: larger values are silently clamped
def iter_category(cid, store, country, currency, lang, size_schema):
http = requests.Session()
offset, total = 0, None
while True:
resp = http.get(
SEARCH.format(cid=cid),
params={
"channel": "desktop-web",
"store": store,
"country": country,
"currency": currency,
"lang": lang,
"sizeSchema": size_schema,
"limit": PAGE,
"offset": offset,
},
timeout=45,
)
resp.raise_for_status()
page = resp.json()
if total is None:
total = page["itemCount"]
print(f"{store}/{country}: {total} items in {page['categoryName']!r}")
products = page.get("products") or []
if not products:
return
for product in products:
yield product
offset += len(products) # never offset += PAGE
if offset >= total:
return
Incrementing by len(products) rather than by PAGE keeps that loop correct when the server clamps a page. The first page is also rendered into the category HTML under window.asos.plp._data, so one document fetch gives you both the product list and the storefront assertion from earlier. Reading a site's own JSON calls instead of its markup is covered in scraping hidden JSON API endpoints.
Product-level isInStock hides the variant truth
Every row in my cross-store table said isInStock: true for Britain while only one of seven variants was buyable. The product flag is an OR across sizes, so it stays true until the last size sells out. For availability work it is close to useless.
The variant objects carry what you need: id, isInStock, isLowInStock, isRestockingSoon, stockLastUpdatedDate, warehouse, seller and a full price block each. Product level adds hasMultipleColoursInStock, hasMultiplePricesInStock and restockingLeadTime, which was 14 on the coat I tested.
The per-store timestamps are the detail I did not expect. Variant 5692357 of product 5692333 reported stockLastUpdatedDate of 2019-03-26T11:47:41.609Z on the GB store and 2025-09-22T17:04:52.201Z on the US store. Same variant, same moment, two different last-updated times, because each store keeps its own inventory record. Keep that field per row; it tells you how stale a market's number is.
The price block repeats in four shapes: current, previous, rrp and xrp, each with a numeric value, a formatted text and an opaque versionId. On the US store the coat's current was 97.00 USD while xrp read 61.43 GBP with a non-zero conversionId, so xrp is a cross-rate reference rather than a price any shopper pays. Take current.value for comparisons and keep text for display only, since it is locale-formatted ("79,99 €") and defeats a naive float parse.
What Akamai sets and what my TLS stack broke
ASOS fronts the site with Akamai. Nameservers today are a1-17.akam.net, a2-64.akam.net, a11-66.akam.net and a16-67.akam.net plus four nsone.net hosts, and responses carry server: ASOS with vary: Accept-Encoding, Cookie, User-Agent.
Within two requests my session held _abck, bm_sz and ak_bmsc, the Akamai Bot Manager trio, alongside ASOS's own asos_drmlp, featuresId and geocountry. My _abck value ended in ~-1~-1~-1~-1~-1, the state Bot Manager uses when no sensor payload has been posted, so no challenge was ever solved. The catalogue endpoints returned 200 anyway. That is one residential IP on one day, not a promise, and anything behind login or checkout is a different story. The mechanics are in bypassing Akamai Bot Manager.
The real obstacle was my own HTTP client. Three clients, identical URL, 9 October 2026:
| Client | Result |
|---|---|
| `requests` 2.32.5 (OpenSSL) | ReadTimeout after 20s, no response |
| `curl` 8.15.0 (Windows schannel) | hung in TLS renegotiation, exit 28 |
| `curl_cffi` 0.16.3 | 200 in 0.08 to 0.47s |
The failures were timeouts, not 403s. Nothing was refused: the connection opened, the handshake completed, then the request died. curl_cffi returned 200 both with impersonate="chrome" and with no impersonation profile set, so what mattered was the TLS implementation rather than a specific JA3 profile. If you see hangs instead of blocks on ASOS, change the client before reaching for a browser. A 30-request burst on one session with no delay came back 30 for 30 at 200 in 1.5 seconds, which is also one data point and no reason to hammer.
from curl_cffi import requests as cr
session = cr.Session(impersonate="chrome", timeout=30)
resp = session.get(
"https://www.asos.com/api/product/catalogue/v4/stockprice",
params={
"productIds": "5692333",
"store": "US",
"currency": "USD",
"country": "US",
},
)
print(resp.status_code, resp.json()[0]["productPrice"]["current"])
Telling a timeout from a block matters more here than on most targets, and detecting when your scraper is blocked has the signal checks worth wiring in.
Escalating with country_code when parameters are not enough
Because ASOS keys the catalogue off query parameters rather than your IP, the bottom rung is unusually cheap. The SparkProxy Scraping API lives at https://scrape.sparkproxy.io/api/v1 and authenticates with an X-API-Key header; new accounts get 1,000 free credits. render_js defaults to true at 5 credits, and that default is wrong here. The listing endpoints return JSON and the product page embeds stockPriceResponse server side, so there is nothing to execute. Set render_js=false and pay 1 credit.
import os
import requests
API = "https://scrape.sparkproxy.io/api/v1/scrape"
KEY = os.environ["SPARKPROXY_API_KEY"]
TARGET = (
"https://www.asos.com/api/product/search/v2/categories/2641"
"?channel=desktop-web&store=US&country=US¤cy=USD"
"&lang=en-US&sizeSchema=US&limit=200&offset=0"
)
# Rung 1: plain fetch, 1 credit. No browser, no country pinning.
resp = requests.get(
API,
headers={"X-API-Key": KEY},
params={"url": TARGET, "render_js": "false"},
timeout=60,
)
page = resp.json()
print(page["itemCount"], page["categoryName"])
Rung two adds a US exit so the request stops looking like an Indian IP shopping the American store. country_code costs 5 credits on top, and json_response=true wraps the reply in the envelope that reports what actually happened. The body in that envelope is base64, so decode it before you touch the JSON:
import base64
import json
import os
import requests
API = "https://scrape.sparkproxy.io/api/v1/scrape"
KEY = os.environ["SPARKPROXY_API_KEY"]
TARGET = (
"https://www.asos.com/api/product/catalogue/v4/stockprice"
"?productIds=5692333&store=US¤cy=USD&country=US"
)
resp = requests.get(
API,
headers={"X-API-Key": KEY},
params={
"url": TARGET,
"render_js": "false", # 1 credit, nothing to render
"country_code": "us", # +5 credits, US exit
"proxy_type": "premium", # file-based premium pool
"json_response": "true", # the ONLY source of the envelope
},
timeout=90,
)
envelope = resp.json()
print(envelope["status_code"], envelope["credits_used"], envelope["duration_ms"])
payload = json.loads(base64.b64decode(envelope["body"]).decode("utf-8"))
variants = payload[0]["variants"]
currencies = {v["price"]["currency"] for v in variants}
assert currencies == {"USD"}, f"expected USD, got {currencies}"
print(payload[0]["productPrice"]["current"]["value"], sorted(currencies))
Two details trip people up. format only converts content (html, md, mdx, json, screenshot, pdf), so format=json is not the envelope and json_response=true is. And extract_rules replaces the whole response with an extracted object, so never stack it with json_response.
Now the arithmetic, using the real counts from this article. A full GB Coats and Jackets snapshot is 5,125 items: 26 listing calls at 200 per page, then 52 stockprice calls at 100 ids each. That is 78 credits with render_js=false, roughly 13 full category snapshots inside the free tier. Add country_code to all 78 and the same job costs 468 credits, six times more for data the store parameter already gave you. Spend the 5 credits where it buys something: a storefront that redirects, a market whose promotions you need, a run that started timing out.
Batch mode is cheaper still. Comma-separated URLs in url with render_js=false bills the whole batch as 1 credit, which suits the 26 listing pages precisely. Underneath, the premium pool is 1M+ IPs across 80+ countries with random and 5-minute auto-rotation over HTTP, HTTPS or SOCKS5. Proxy plans start from $75/month as packs rather than per-GB, with unlimited bandwidth, so a wide multi-market crawl is not punished for the 1.2MB a 100-id stockprice response weighs. The 24-hour trial needs no card, and term discounts run 5% at three months, 10% at six and 15% at twelve.
Terms, robots.txt, and the gap between them
Read both documents, because they do not say the same thing.
ASOS robots.txt on 9 October 2026 gives User-agent: seven disallow rules: /web/pages/, /shared-board/, /security-issues, /report-a-legal-concern, /discover/no-results-page/, /Prod/, and the scrollTo= query form. There is no Crawl-delay and no rule touches /api/. The only API exclusion, /api/commerce/bag/*/total, sits under User-agent: AdsBot-Google and binds that crawler alone. The file then lists 33 sitemaps: a sitemap.xml, a product-sitemap-index and a facet-sitemap-index for each of COM, AU, DK, DE, ES, FR, IT, NL, PL, SE and US. The COM product index pointed to 29 child sitemaps, and its first child held 34,071 product URLs.
The terms and conditions are blunter. From https://www.asos.com/terms-and-conditions/, read the same day: "you're not allowed to use automated systems or software to extract data from our Website (AKA 'screen scraping')."
So the crawl directives permit what the contract forbids. That gap is a commercial and legal call, not a technical one, and it belongs with your counsel rather than your scheduler. Nothing here is legal advice. What engineering can do is keep the footprint defensible: public catalogue only, never anything behind authentication, cache on keyStoreDataversion, batch to the 100-id and 200-item caps instead of making ten times the requests, and hold concurrency low enough that the service never notices. Wider price-monitoring design sits in scraping ecommerce prices, and the resale angle in proxies for fashion resale market data.
Frequently asked questions
FAQ
There is no documented public API and no key to request. The endpoints under https://www.asos.com/api/ are the site's own internal calls, which returned JSON over plain unauthenticated GETs when I tested them on 9 October 2026. They carry no stability guarantee and the version segments (v2, v4) can move without notice.
Because the store code changes the price, not just the currency. Product 5692333 returned 95.00 USD under store=ROW&country=IN and 97.00 USD under store=US&country=US on the same day. Record the store code and country next to every price or the numbers will not reconcile.
Not to select the catalogue. The store, currency, country, lang and sizeSchema parameters do that, and I pulled every storefront from one Indian IP. A matching exit still helps for IP-driven messaging, for not presenting Akamai with a geography that contradicts the locale, and for markets with edge redirects.
The country code is ISO 3166-1 alpha-2; the store code is ASOS's own internal grouping. Britain's store code is COM, and store=GB returns HTTP 400 "Invalid Store Requested". Two store codes, ROW and ROE, cover 170 and 33 countries respectively, so the mapping is many-to-one in both directions of confusion.
No, and paying for one is waste here. The listing and stockprice endpoints are JSON, the first listing page is rendered into the category HTML, and the full variant price and stock array is embedded in the product page as window.asos.pdp.config.stockPriceResponse. Plain HTTP with a working TLS stack gets all of it.
For GB Coats and Jackets at 5,125 items: 26 listing calls at the 200-item cap, plus 52 stockprice calls at the 100-id cap, so 78 requests. At 1 credit each on a plain fetch that is 78 credits, and batching the listing pages brings it lower still.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
How to Bypass Fastly Next-Gen WAF
Bypass Fastly Next-Gen WAF (Signal Sciences) the right way: how its agent decides per signal at the edge, the 406 block, and why clean IPs beat header tricks.
How to Bypass Vercel BotID When Web Scraping
Bypass Vercel BotID: confirm it in one request, read the Kasada engine underneath, see what a block really returns, and know where the ceiling sits.
Anubis Proof of Work Bypass: What Solving Really Costs
Anubis proof of work bypass read from the MIT-licensed source: the SHA-256 loop, bits versus nibbles difficulty, measured solve costs, and when to stop.
