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
POST /api/load-page/bmw→ anonymous JWT in{"token": ...}POST /api/product/productswith aclienttokenheader, once perhandle
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:
| handle | listing_category | totalCount | conditions | configurationString token[3] |
|---|---|---|---|---|
bto | bto | 144 | 77 New / 67 Used | always BTO |
new | new | 582 | 458 New / 124 Used | range 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 field | ZA path | Note |
|---|---|---|
| vin | vin | identity key |
| vehicle_id | id | storefront listing id |
| manufacturer_code | manufacturerCode | AG model code, e.g. 12GE |
| series_code | configurationString | platform code, e.g. F70, G45, U11 — see below |
| series_name | model | e.g. 1 Series, X3 |
| model_range | range | e.g. 1, X, M, i |
| model_description | title | e.g. X3 20d xDrive, 320d |
| model_year | modelYear | Stolo has no model year; ZA is the source for this field |
| body_type | bodyType | 2-letter code, e.g. SH, GF |
| body_type_name | subtitle | e.g. Sports Hatch, SAV — 1:1 with bodyType |
| fuel_type | fuelType | Petrol / Diesel / Hybrid / Electric |
| drive_type | title badge | xDrive / sDrive / null — see below |
| hybrid_flag | fuelType == "Hybrid" | |
| used | productCondition == "Used" | agrees exactly with mileage > 0 |
| price | price | top-level string, full precision — see below |
| registration_date | registrationDate | first registration for used stock, stock-entry date for new |
| listing_category | request handle | bto / new — the only place a row's category survives |
| mileage | mileage | Stolo has no mileage; ZA is the source for this field |
| cylinders | cylinders | "0" (electrified) is normalized to null |
| exterior_code / exterior_color | exteriorCode / exteriorColour | specific paint code and name, not a generic cluster |
| interior_code / interior_color | interiorCode / interiorColour | specific upholstery code and name |
| configuration_string | configurationString | |
| feature_refs | configurationString + relatedTaxonomy | see 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 / condition | prefix |
|---|---|
bto / New | BM-1-N-BTO-<vin>-1-1- |
bto / Used | BM-1-U-BTO-<vin>-<mileage>-1-1- |
new / New | BM-1-N- (no VIN) |
new / Used | BM-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.
btorows hold 0 or 1, butnewrows 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 scalardealer_idcolumn at all. - Unresolved namespace. 22 distinct ids corpus-wide (
9843,24337,45294, and a bare34), 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.