Skip to main content

BMW ZA Inventory Source

South African new- and used-car stock from the buy.bmw.co.za BFF, implemented in sources/za_inventory.py (_ZaInventoryProductEntity).

Auth and pagination

  1. POST /api/load-page/bmw → anonymous JWT in {"token": ...}
  2. POST /api/product/products with a clienttoken header, once per handle

Paginated by first (page size, capped at 250 server-side) and after (record offset, not page index). The corpus size is reported as productCategory.totalCount. There is no total key — reading one sizes the fetch at a single page and silently truncates the day's inventory.

productCatalogFilters, productCatalogSorting and productCatalogFinanceFilters are all mandatory — omitting any of them 500s. productCatalogFinanceFilters.maxMonthlyBudget is load-bearing: 50_000 returns 144 of 147, and 10**12 overflows to 0. The shipped 50_000_000 is on the flat part of that curve.

Two disjoint categories, one table

The site exposes the corpus under two handle values. Measured 2026-07-28 by paging both to exhaustion:

handlelisting_categorytotalCountconditionsconfigurationString token[3]
btobto14477 New / 67 Usedalways BTO
newnew582458 New / 124 Usedrange or VIN (no BTO marker)

They are fully disjoint — 0 VIN overlap. Every new listing carries a VIN and real mileage, so these are physical cars, not configurator entries. Both are fetched into bmw.za.transformed.za_inventory (726 rows, all VINs distinct) and discriminated by listing_category.

bto is a build-to-order slot, new is stock on the ground — filter on listing_category for anything with days-on-lot or age semantics, since a BTO slot has no meaningful time-on-lot. Note the site's naming is misleading: the new category is ~21% productCondition: Used.

Field mapping

Entity fieldZA pathNote
vinvinidentity key
vehicle_ididstorefront listing id
manufacturer_codemanufacturerCodeAG model code, e.g. 12GE
series_codeconfigurationStringplatform code, e.g. F70, G45, U11 — see below
series_namemodele.g. 1 Series, X3
model_rangerangee.g. 1, X, M, i
model_descriptiontitlee.g. X3 20d xDrive, 320d
model_yearmodelYearStolo has no model year; ZA is the source for this field
body_typebodyType2-letter code, e.g. SH, GF
body_type_namesubtitlee.g. Sports Hatch, SAV — 1:1 with bodyType
fuel_typefuelTypePetrol / Diesel / Hybrid / Electric
drive_typetitle badgexDrive / sDrive / null — see below
hybrid_flagfuelType == "Hybrid"
usedproductCondition == "Used"agrees exactly with mileage > 0
pricepricetop-level string, full precision — see below
registration_dateregistrationDatefirst registration for used stock, stock-entry date for new
listing_categoryrequest handlebto / new — the only place a row's category survives
mileagemileageStolo has no mileage; ZA is the source for this field
cylinderscylinders"0" (electrified) is normalized to null
exterior_code / exterior_colorexteriorCode / exteriorColourspecific paint code and name, not a generic cluster
interior_code / interior_colorinteriorCode / interiorColourspecific upholstery code and name
configuration_stringconfigurationString
feature_refsconfigurationString + relatedTaxonomysee below

driveTrain is unusable

Every listing reports driveTrain: "Rear Wheel Drive" — including X3 20d xDrive, M135 xDrive and iX1 xDrive30. The badge in title is the only usable signal, so drive_type is derived from it and models without a badge stay null (~36% of the corpus).

price vs cashPaymentOption.totalCost

cashPaymentOption.totalCost is truncated to whole rand and disagrees with the top-level price string on roughly half of live listings (735117 vs 735117.8604). price is the mapped field.

configurationString

The leading run is variable width — four distinct layouts across the two handles:

handle / conditionprefix
bto / NewBM-1-N-BTO-<vin>-1-1-
bto / UsedBM-1-U-BTO-<vin>-<mileage>-1-1-
new / NewBM-1-N- (no VIN)
new / UsedBM-1-U-<vin>-<mileage>-

What is invariant is the tail:

…-<series>-<manufacturerCode>-<exteriorCode>-<interiorCode>-<option codes…>

so the split anchors on manufacturerCode rather than a fixed offset. The token before it is the series code (F70, G45, U11 — 28-29 distinct); the three after it are exterior code, interior code, then the 40–75 option codes actually fitted to the vehicle. This is the only place fitted options appear. Verified against all 729 listings of both handles (2026-07-28): 729/729 resolve, 0 anomalies.

relatedTaxonomy.options / .trims / .colours list what the model can be built with (bucket-prefixed: S-1H2, P-KUSW, L-475) and their descriptions are HTML-escaped. They name only ~3% of the fitted codes; the rest are written as code-only FeatureRefs.

Fields with no ZA path

production_number, drive_type_code, transmission_type, sports_flag, base_price, create_date, production_date, production_week, end_of_production, est_arrival_date, arrival_date, dealer_name, order_type, event_id, sold, priority_code.

leadTime is a coarse 7-or-51-day bucket, not an arrival date. createdAt, updatedAt, registrationYear, mplanPrice, commercial and published are constant/zero across the whole corpus.

Dealer attribution: partial, and unresolved

minorDealerIds is the only dealer-bearing field anywhere in the payload — confirmed by walking every nested key of all 729 listings; it is the single hit. Measured across the full corpus 2026-07-28:

  • 59% coverage — 427 of 729 listings carry at least one id; 302 carry none.
  • Multi-valued. bto rows hold 0 or 1, but new rows hold up to 7 (256×1, 64×2, 21×3, 8×4, 3×5, 2×6, 1×7). So it is not "the dealer holding the car" — it reads as the set of dealers associated with a listing. It cannot go in the scalar dealer_id column at all.
  • Unresolved namespace. 22 distinct ids corpus-wide (9843, 24337, 45294, and a bare 34), against ~144 C2B POIs for ZA. Zero overlap with C2B dealer keys (02487_1), including against their zero-stripped 5-digit prefixes.

No lookup for these ids was found:

  • the SPA serves its HTML shell for unknown paths, so path guessing is useless
  • the only dealer directory in the JS bundle is MINI mock data (22 entries, 0 overlap)
  • no product-detail endpoint is reachable

/api/reference-codes exists in the bundle and 500s rather than 404s, so it takes parameters that were not guessed — that is the most promising lead, best settled by observing a product-detail page in a browser.

Until then ZA inventory cannot be attributed to a specific dealer, and dealer_id / dealer_name stay null. If the ids are resolved later, the right structure is a dealer_refs list column, not dealer_id.

See stolo-source.md for the fields Stolo and the BQ backfill supply instead.