Storefront API: dealers & stock locator
Two geographical widgets share these endpoints: a store-locator that maps the tenant's dealers, and a 'find this part nearby' widget that aggregates stock across resellers. They both consume /api/t/config for the Mapbox token.
GET /api/t/config
Per-tenant public configuration. Today it carries the Mapbox token a tenant has set in their portal. Future integrations get their own top-level keys, but the shape stays flat, easy to read in a Shopify Liquid template.
GET /api/t/config
X-API-Key: pst_live_xxx
{ "mapboxToken": "pk.eyJ1Ijoi..." }
Returns "" for mapboxToken if the tenant hasn't configured one. Cache-Control is public, max-age=300. The PowersportOS Shopify theme caches the response in sessionStorage under powersportos_config and invalidates if mapboxToken is empty.
GET /api/t/dealers
Returns the full list of active dealers for the tenant. Used by the store-locator widget to render pins on the map plus the side-list of locations.
GET /api/t/dealers
X-API-Key: pst_live_xxx
[
{
"name": "Acme Powersports",
"address": "Storgatan 12",
"city": "Stockholm",
"postalCode": "11122",
"country": "Sweden",
"phone": "+46812345678",
"website": "https://acme.example/",
"lat": 59.3293,
"lng": 18.0686
}
] - phone / website
- May be empty strings, treat empty as 'not provided', skip rendering the field.
- lat / lng
- Decimal numbers. If
null, the dealer should appear in the list but not on the map. PowersportOS geocodes addresses server-side on create/update using Nominatim, so this is rarely null in practice. - country
- Full country name (e.g. 'Sweden', not 'SE'). Drives the country-filter dropdown in the store-locator. The set of countries is whatever's in the data, there's no hard-coded master list.
Sorted by country then name. Returns [] if no dealers exist. Cache-Control is public, max-age=300.
GET /api/t/dealers/stock
Returns the nearest dealers that have a specific part in stock. This is the engine behind a "Find this part at a reseller near you" widget on a manufacturer's product page, given the customer's location and the variant SKU, return resellers (or own retail locations) sorted by distance, with stock badges.
- partNumber
- Required. The SKU to look up. Returns 400 if missing.
- lat / lng
- Optional decimal-number pair. Both must be supplied together to enable distance sort. If omitted, results sort alphabetically by city instead.
- limit
- Optional integer, default 10, clamped to 1–50.
GET /api/t/dealers/stock?partNumber=55716013&lat=59.3293&lng=18.0686&limit=5
X-API-Key: pst_live_xxx
[
{
"name": "Acme Powersports",
"address": "Storgatan 12",
"city": "Stockholm",
"postalCode": "11122",
"country": "Sweden",
"phone": "+46812345678",
"website": "https://acme.example/",
"lat": 59.3293,
"lng": 18.0686,
"distanceKm": 12.3,
"stock": 5,
"level": "in_stock"
}
] Response fields
- level
- Always present. One of
"in_stock","low","out". Drives the badge colour client-side. Bucketed by the backend:≤ 0→ out,1–3→ low,≥ 4→ in_stock. - stock
- Numeric stock count, only present when the reseller has chosen EXACT precision in their stock-sharing settings. Most resellers use LEVEL precision, in which case
stockis absent and onlylevelis meaningful. Checktypeof stock === 'number'before rendering a count. - distanceKm
- Decimal kilometres from the supplied
lat/lngto the dealer, rounded to one decimal.nulliflat/lngweren't supplied or the dealer has no coordinates. - phone / website
- Same empty-string-means-missing convention as
/dealers.
Two paths, one response
The endpoint serves two flows with identical response shapes:
- Manufacturer-tenant flow: the dealers under the calling tenant have
linkedTenantIdset, pointing at the reseller's PowersportOS tenant. Stock is read from the linked reseller's catalog, gated by an approvedTenantStockShareconsent row pointing back at the calling manufacturer. Precision (EXACT vs LEVEL) comes from the share row. - Retail-tenant flow: the dealers under the calling tenant are the tenant's own physical stores (no
linkedTenantId). Stock is read fromDealerStockrows attached to each dealer. No consent gate, it's the tenant's own data. Always EXACT precision.
A single tenant can have both flavors of dealer in their catalog (a manufacturer that also runs its own retail outlets, for example). Both contribute to the response transparently.
Privacy and opt-in
For the manufacturer flow, only resellers who've actively opted in (approved a TenantStockShare row in their portal) are included. Resellers who haven't opted in are silently filtered out, they don't appear as "no stock", they just don't appear at all. Resellers can also choose LEVEL precision so the actual count never leaves their system; only the in/low/out bucket is exposed.
Caching
Cache-Control is public, max-age=60. Stock changes more often than the catalog or dealer list, so the TTL is shorter. The PowersportOS Shopify theme caches geocoded postcodes in sessionStorage (key powersportos_geo_<postal>_<country>) so the customer doesn't pay the Mapbox round-trip on every variant change.
Recommended client-side behaviour
- Geocode postcodes via Mapbox using the token from
/api/t/config. Don't ship your own token, the tenant's token has the correct URL-origin restrictions configured. - Cache the customer's resolved
lat/lnginsessionStorageso they don't have to re-approve geolocation or re-enter their postcode on every product page. The PowersportOS theme usespowersportos_user_location. - Re-fetch on variant change. Listen for the
powersportos:variant:changeCustomEvent and call/dealers/stockagain with the new SKU. - Branch the badge on
level, not onstock. Most responses don't includestock, onlylevelis guaranteed. - Empty array is normal. Render an empty state ("No resellers in your area carry this part") rather than treating it as an error.