Flow storefront integration

Build your own customer-facing vehicle-search website on the EuroStocks Flow API, like the reference storefront at https://import-demo.eurostocks.com.

Prerequisites
  • A Flow service account from EuroStocks (get access).
  • Your own back end, in any language (PHP, .NET, Java, Node, …): the API is reachable server-to-server only.

1. Architecture — the one hard rule

The browser never gets a token. The API is reachable server-to-server only: no CORS, no tokens in the browser or in URLs. So you always build your own back-end proxy:

TEXT
browser (no token)
   │  same-origin
   ▼
your back-end proxy ──▶ Authentik
   │                    (token)
   │  HTTPS + bearer token
   ▼
Flow API
  • The browser talks same-origin to your proxy; only the proxy knows the credentials and the token.
  • The proxy forwards only a fixed, small set of GET endpoints (§3) and adds the Authorization: Bearer <token> header itself. Remove any Authorization header and any merchant parameter coming from the browser.
  • Credentials and tokens belong only in the server environment or memory: never in logs, responses or URLs.

2. Authentication

EuroStocks gives you a service account (Authentik, machine-to-machine) with: the API base URL (https://flow-api.eurostocks.com), the token URL, a client id, and the service account's username + app password.

You request a token with OAuth2 client_credentials: a POST to the token URL with the form fields grant_type, client_id, username, password and scope (openid memberships). The answer holds access_token and expires_in (seconds).

BASH
curl -s https://auth.eurostocks.com/application/o/token/ \
  -d grant_type=client_credentials \
  -d client_id="$FLOW_CLIENT_ID" \
  -d username="$FLOW_USERNAME" \
  -d password="$FLOW_APP_PASSWORD" \
  -d scope="openid memberships"

This is Authentik's service-account flavour of client_credentials: it deliberately uses username + password instead of the standard client_id + client_secret pair. Do not "fix" it to the standard form: authentication then fails.

Merchant link: the service account is a member of exactly one merchant: your storefront. The Flow API derives it itself, so never send a merchant parameter.

Token handling (required to get right):

  • Cache the token in memory and reuse it until ~30 seconds before it expires; then request a new one.
  • Let concurrent requests share one token request (single-flight): do not request a token per request.
  • If the API still answers 401: request a fresh token once and retry the request once.
  • Recommended: request the first token when your server starts, so the first visitor does not wait for it.

3. The API surface

Every path is under /api/v1 (old unversioned paths answer 404). Everything is GET. Note: the storefront/ segment is on search and the vehicle detail only.

Endpoint Purpose
GET /api/v1/storefront/search Search: hits, total, facet counts (§4)
GET /api/v1/storefront/vehicles/{id} Vehicle detail (full specifications, gallery)
GET /api/v1/attributes The contract: filters, sorts, labels, groups (§5)
GET /api/v1/catalog Reference taxonomy: make → model group → model, with canonical URL slugs (§6)

Your proxy forwards these four only.

Typed client: GET /api/v1/openapi.json serves the API's OpenAPI 3.1 document (with the same token, no extra right needed). Generate your client from it instead of transcribing fields (for example with openapi-typescript) and use the operations tagged storefront: exactly the four above. Fetch it at build time, not from the browser. The labels in the document are the default texts; the current ones are in /api/v1/attributes.

What your storefront offers. Your merchant has settings the API enforces; they apply to every search, facet counts included:

  • Minimum registration year: every search response carries merchant.min_first_registration_year. When it is set, search never returns older cars, and a chosen year below it gives zero hits. Bound your year filter to it; null means no floor.
  • Makes and models: a storefront can be limited to certain makes, or exclude models. The catalog (§6) then holds only what you offer; a make or model outside it gives zero hits, and the search box does not apply it (outside_scope, §4).
  • Price display: merchant.price_display (final, lease or both) decides which prices and price filters the contract advertises.

These limits apply to search: a direct link to a vehicle id also works for a car outside them.

Rate limiting (recommended): all visitor traffic reaches the Flow API through your proxy with one service account. The API therefore cannot tell individual visitors apart, and abuse by one visitor counts as your traffic. So limit per visitor IP in your own proxy, on the forwarded /api/* paths. A sensible setting: a token bucket with a burst of ~10 requests and ~1–2 requests/second sustained per IP (± 60–120/min). That is well above the busiest legitimate behaviour (quickly switching filters peaks briefly at a few requests per second) but stops automated excess. Answer an overrun with 429 + a Retry-After header. This does not have to live in the proxy: an edge or CDN layer in front of it (e.g. Bunny.net or Cloudflare) can enforce the same per-IP limit, is often easier to manage, and keeps the traffic out altogether. The cached reference documents (attributes, catalog) barely touch your limit: your proxy answers them from its own memory.

The Flow API also applies its own rate limit per merchant, based on your subscription. When you exceed it, your proxy gets a 429 (code: "RATE_LIMITED") with a Retry-After header (seconds): respect it and do not retry aggressively. Your own per-IP limiter is therefore also self-protection: it stops one visitor (or bot) from using up the quota of your whole storefront.

Caching: attributes and catalog are reference documents. Fetch them server-side, keep them in memory and revalidate with If-None-Match: both send an ETag and answer a cheap 304 while nothing changed. The catalog sends Cache-Control: private, max-age=30; keep your own TTL short, so a change to what you offer shows quickly. Send them to the browser compressed. Forward searches per request.

Request parameters (query string on /api/v1/storefront/search):

  • q — the search box: recognised words become filters (see The search box below).
  • description — "Version / description contains": words always searched as text, never as a filter (trekhaak as a word, AMG Line), together with the text words of q in one query; at most 100 characters.
  • page, per_page — pagination. The server caps per_page at 50 (default 24) and page at 200; a value out of range is clamped silently, never an error. So take the paging maths from the response (page, per_page) and cap your own pagination at page 200.
  • sort — a sort token from the contract (§5); do not hardcode sorts. The default sort is the first in the list (price, low → high). The last token, _text_match:desc ("Best match"), ranks by how well the search text matches; without text it falls back to price.
  • facets — a comma-separated list of the fields you want counts for (e.g. facets=make,fuel,transmission,year); a recognised list replaces the default set. Ask only for what you show. When you page, sort or change the page size with unchanged filters, send facets=none: no counts (facet_counts: []).
  • Filter fields — every filter in the contract advertises its own query parameters (params); use those names literally and do not derive names yourself. Enum fields take comma-separated contract codes (fuel=PETROL,HYBRID; the codes are listed per filter in the contract), ranges the advertised pair (price_min/price_max, mileage_min/mileage_max, seats_min/seats_max).
    • year_min / year_max is the registration year from–to (both inclusive); year is an exact multi-select (year=2024,2025).
    • make, model, model_group filter the taxonomy; model finds both spellings the feed uses (the catalog key and the label).
  • An allowlist on the API side decides which parameters are accepted; a price or lease parameter your storefront does not advertise is a 400.

The search box (q)

Recognised words become ordinary filter parameters. The search box understands the Dutch (and some German) words shoppers type. The other words search as text in the dealer's model line (title and model): every word must match, tolerant of typos and spelling variants (c200 = c 200, s line = sline). A filter you send yourself always wins.

Recognised Examples Becomes
Make bmw, volkswagen, vw, mercedes, benz make
Model, model group bmw x5, golf, mercedes glc model / model_group (+ make)
Fuel benzine, diesel, elektrisch, ev, hybride, plug-in, phev fuel, hybrid_plugin
Transmission automaat, dsg, handgeschakeld transmission
Body type suv, stationwagen, combi, cabrio category
Drive 4x4, awd, 4matic, quattro, xdrive, 4motion drive_type
Equipment trekhaak (afneembare trekhaak / vaste trekhaak: only that kind), panoramadak, leer, stoelverwarming, navi, hud, cruise control, acc, led, camera, pdc, and the Dutch name of every option in the contract that option's filter
Colour zwart, zwarte golf; next to an interior word the interior colour: zwart leer, beige interieur color; interior_color
Paint metallic, matte, mat zwart metallic, matte_color
First owner eerste eigenaar, 1e eigenaar previous_owners_max=1
Accident-free geen schade, zonder schadeverleden, schadevrij accident_damaged=false (as the dealer declares it)
Type and engine codes 530d (BMW 530 + diesel), 530e (plug-in hybrid), 520da (+ automatic), x5 xdrive30d (diesel + four-wheel drive), tdi (diesel), tsi / tfsi (petrol), tfsi e / ehybrid (plug-in hybrid) model + fuel / drive_type / transmission
Numbers with a unit or qualifier vanaf 2020, 2018-2021, onder €20.000, max 50.000 km, 150 pk, 110 kw, tot 300 per maand year_min/year_max, price_*, mileage_*, power_pk_* / power_*, lease_*
  • Model without make: a model name of 4+ characters that belongs to one make also works without the make (golf = Volkswagen Golf); shorter names (x5, a4) only with the make. A code that belongs to one make (tfsi, quattro → Audi; xdrive → BMW; 4motion → Volkswagen) also lets a short model of that make count: a4 tfsi = Audi A4 + petrol.
  • Trims stay text: gti, gtd, gte, rs, amg, m sport, r-line, s-line, st-line (a golf gte is one version, not every plug-in Golf), and so does grijs kenteken.
  • Numbers: a bare year is that one year (2020). pk and kW without a qualifier mean "from"; km, € and a monthly amount mean "up to". A number without a unit (tot 20000) becomes a price as long as your storefront shows prices; tot 20000 km is mileage. Engine sizes (1.5, 2,0) stay text.
  • Typos: an obvious typo counts as the intended word: one letter off a make, a word from this list or a model of a make already known, at 5+ letters, without a digit and with one meaning only (mercdes, panoramdak, vw tiguen). The chip names the real word, from the typed one.
  • Connecting words left over (en, of, met, een, de, het, in, op, van, und, mit) are dropped.
  • interpret=false searches q as plain text: spelling variants, typo tolerance and brand aliases only (volkswagen = VW), no filters and no interpretation block.

The interpretation block. Every response to a non-empty q carries interpretation:

  • applied — the chips. Per chip: params exactly as you would send them yourself (enum codes and true as strings, range bounds as numbers), label / label_nl and from (the typed words).
  • text — the words searched as text (null = none).
  • not_applied — recognised but not applied, with a reason:
    • conflicts_with_filter: your request already sets that parameter to another value; suggestions holds the replacement;
    • outside_scope: a make or model outside what you offer (§3);
    • not_available: a price or lease bound your storefront does not show;
    • ambiguous: a number without a unit that cannot be a price here (e.g. max 300); suggestions offers the readings, the words stay in text.

A search and its (shortened) response:

BASH
curl -s "https://flow-api.eurostocks.com/api/v1/storefront/search?q=bmw%20530d%20tot%2040000&facets=fuel" \
  -H "Authorization: Bearer $FLOW_TOKEN"
JSON
{
  "found": 37, "page": 1, "per_page": 24,
  "merchant": { "id": "import-demo", "name": "Import Demo", "price_display": "final",
                "min_first_registration_year": 2018 },
  "facet_counts": [{ "field_name": "fuel", "counts": [{ "value": "DIESEL", "count": 37 }] }],
  "hits": [{ "id": "900001", "make": "BMW", "model": "530", "make_slug": "bmw",
             "model_slug": "530", "final_price": 38950, "vat_scheme": "nl_vat_21",
             "image_url": "https://…", "image_count": 12 }],
  "interpretation": {
    "applied": [
      { "params": { "make": "bmw" }, "label": "BMW", "label_nl": "BMW", "from": "bmw" },
      { "params": { "model": "530" }, "label": "530", "label_nl": "530", "from": "530d" },
      { "params": { "fuel": "DIESEL" }, "label": "Diesel", "label_nl": "Diesel", "from": "530d" },
      { "params": { "price_max": 40000 }, "label": "Price up to € 40,000",
        "label_nl": "Prijs tot € 40.000", "from": "tot 40000" }
    ],
    "text": null,
    "not_applied": []
  }
}

How to handle it: adopt applied into your filter state and URL without searching again (the response already holds those results), put text back in the search box and show the not_applied messages. Page 2 then sends the filters plus q=<text> and gets the same result. Let the visitor see and edit the whole typed sentence (the reference storefront does): when the visitor removes a chip, remove the words of its from from the box; when the visitor submits the sentence again, first remove the chips of the previous sentence (filters the visitor chose stay, except a value that also came from the sentence). Search on submit (Enter or the search button), not on every keystroke: that saves your rate-limit quota and avoids flickering results.

Response shape (the fields the reference storefront builds on):

  • found — the exact total; hits — the results of this page.
  • Per hit, among others: id, title, make, model, make_slug, model_slug, final_price, vat_scheme (the VAT context of the price), optionally lease_price (€/month, with its lease_terms), image_url, image_urls (the first ~5 gallery URLs) and image_count.
  • facet_counts — a list of { field_name, counts: [{ value, count }] } per requested field.
  • merchant — { id, name, price_display, min_first_registration_year }: your storefront's settings (§3).
  • interpretation — only with a non-empty q (see above).
  • Mind the naming: search hits use snake_case (make_slug, final_price), the vehicle detail camelCase (makeSlug, finalPrice, leaseTerms); account for that in your mapping.

Two pitfalls:

  1. Facet counts are cached briefly. Counts are computed exactly but cached server-side for a short time (~60 s), so they can differ slightly from the actual results of that moment. You are free to show them; just expect that a shown count and the exact found total do not always match exactly.
  2. Price display follows the contract. Which price you show is decided by the contract (price_display: final / lease / both); a lease-only storefront, for example, sends no final_price. Show the matching VAT context (vat_scheme) with every price, and the lease terms (lease_terms / leaseTerms) with a monthly amount. The API serves only this public projection; there is no cost breakdown or margin information, so do not try to derive it.

Images — CDN representations and the AVIF format

Every image URL the API serves (image_url, image_candidates, image_urls and the gallery of the vehicle detail) is a representation URL of the mobile.de CDN (img.classistatic.de), in the form …?rule=mo-<width>.jpg. Use only the URLs served and do not derive URLs or widths yourself: which representations exist is knowledge of the feed, and the API already serves the right size per purpose (search card, gallery, thumbnails).

There is one documented transformation of a served URL (see also the field descriptions in the OpenAPI document): leave the .jpg extension out of the rule parameter (?rule=mo-1024 instead of ?rule=mo-1024.jpg) and the CDN serves the same image as AVIF, usually 40–55 % smaller. Two important caveats:

  1. The format is fixed, there is no content negotiation. The extensionless URL always serves AVIF (whatever the Accept header, there is no Vary). So never just swap the URL: a browser without AVIF support then gets a file it cannot decode. Always use a <picture> with a typed source and the .jpg URL as the fallback:

    HTML
    <picture>
      <source type="image/avif" srcset="https://img.classistatic.de/…?rule=mo-1024">
      <img src="https://img.classistatic.de/…?rule=mo-1024.jpg" alt="…">
    </picture>

    Keep og:image and other metadata for scrapers on the .jpg form: AVIF support is unreliable there.

  2. This is observed behaviour of a third-party CDN (verified 2026-08-06), not a guarantee this guide can give. The type gate above bounds the risk: should the AVIF variant ever disappear, at most one request fails, and an error handler on the <img> can fall back to the .jpg source (remove the AVIF sources and the browser selects again).

5. The attributes contract — build your UI on it

GET /api/v1/attributes is the contract that drives your filters, sort options, labels and layout. Render from this contract, not from hardcoded lists: then your site follows changes in the offer automatically.

  • version — check for "v1". If it differs, show an (internal) warning: your filters may be out of date.
  • filters / sorts — which filter fields and sort tokens exist, including the kind (enum, range, int, bool, date_range, text; the wider attribute registry also knows tri_state), their query parameters (params) and, per enum, the allowed value codes. Ignore unknown future kinds gracefully (do not render them) instead of breaking on them. The advertised parameters belong to your storefront: lease parameters only when you show lease prices, and vat_reclaimable disappears on a storefront without margin-scheme cars.
  • attributes — the registry of equipment and specifications. Every attribute is a search filter; their values are only in the vehicle detail (attributes), not on search hits.
  • Labels: use label / group / disclaimer for English and label_nl / group_nl / disclaimer_nl for Dutch, with the English fields as the fallback. The wording carries meaning (e.g. whether VAT is deductible): do not invent your own. Codes are stable, labels are not: EuroStocks can correct a label without a new release.
  • groups — the sections you group your filters and the detail page's vehicle data in.
  • response_only fields + detail_field links — which response fields you show as data rows on the detail page; a field with values (such as vat_scheme) is translated through that code table.
  • Disclaimers: show fields with a disclaimer (such as CO₂, a ≈ estimate) with that disclaimer. CO₂ caveat: for electric vehicles the feed sends co2_emissions: 0; suppress the row there.

6. Catalog and URLs

GET /api/v1/catalog serves the reference taxonomy (make → model group → model) with canonical, stored URL slugs, limited to what your storefront offers (§3): build your make and model dropdowns on it and they follow your offer by themselves.

  • Never make slugs yourself. Map incoming URL slugs to filter values through the catalog; build outgoing links with the slug fields on the hit (make_slug / model_slug) and the detail (makeSlug / modelSlug).
  • The reference storefront uses paths such as /{make}/{model} (list) and /{make}/{model}/{id} (detail): a shareable pattern worth copying (what to let search engines index: see §7).
  • Escape everything. Make, model and id from the URL can be manipulated by the visitor: HTML-escape them wherever you write them into the page (titles, meta tags). Keep the vehicle id's raw URL encoding when you insert it into the upstream path.

7. SEO and crawlers

The stock is large and turns over quickly. So you do want Google on the stable list pages, but not on hundreds of thousands of short-lived detail pages (dead links in search results, thin content, and every crawl costs your quota).

  • Index only the taxonomy layer: the home page, the unfiltered results page and the clean make and model paths (/{make}, /{make}/{model}). Only those URLs belong in your sitemap.xml (easy to generate from the catalog).
  • Detail pages: noindex, follow via a meta tag in the HTML, not via robots.txt: Google must be able to fetch the page to see the noindex. Links to them stay followable, so your list pages keep their link value.
  • Query URLs (filters, sorting, pagination) get a canonical to the clean path without the query; otherwise faceted search becomes a crawl trap of endless filter combinations.
  • robots.txt: Disallow: /api/ (crawling it only costs quota) and point to your sitemap.
  • Sold or vanished cars: a real 404 or 410 (or a redirect to the model list), never an empty page with status
    1. Soft 404s at scale harm the whole domain, and even with noindex, visitors keep arriving through shared links. The API itself then answers 404 (§8).
  • Canonical and og:url are absolute URLs on your own origin.

This is the right default for a storefront on the full import stock. If you show only a small, more stable subset (hundreds of cars, longer time in stock), detail pages can be indexed too; the other rules still apply.

8. Error codes

Errors are JSON {error, code?}. Act on the machine code code, not on the text in error:

Status code Meaning
401 AUTH_REQUIRED Missing or invalid token: request a fresh token once and retry (§2).
403 NO_ROLES The token belongs to no merchant at all.
403 MODULE_FORBIDDEN The service account has no right to the storefront API.
403 MERCHANT_FORBIDDEN A merchant parameter was sent that does not belong to your token: never send merchant.
400 MERCHANT_REQUIRED The service account belongs to more than one merchant; contact EuroStocks.
429 RATE_LIMITED Rate limit exceeded: wait the seconds in Retry-After (§3).

Without a code: an invalid parameter is 400 {error} (e.g. a price parameter your storefront does not advertise), an unknown vehicle id 404 {error: "vehicle not found"}, an unknown /api/* path 404 {error: "unknown endpoint"} and an unexpected failure 500 {error: "internal error"}.

9. Start checklist

  1. Receive from EuroStocks: the API base URL, the token URL, a client id and the service account's credentials.
  2. Build the proxy: token handling (§2) and forwarding only the endpoints of §3; remove merchant and the browser's Authorization header; rate-limit per visitor IP; disallowed /api/* paths → 404.
  3. Load and cache attributes and catalog; check version === "v1".
  4. Render the search page and filters from the contract; bound the year filter to merchant.min_first_registration_year; build URLs with catalog slugs.
  5. Handle the search box's interpretation block: adopt the chips, put text back in the box, show the messages (§4).
  6. Show the prices the contract advertises (price_display), with vat_scheme, the lease terms and the contract's labels and disclaimers.
  7. Handle the crawler side (§7): a sitemap with list routes only, noindex on detail pages, Disallow: /api/ and a real 404 for sold cars.
  8. Verify: no token or credentials in the browser, logs or URLs; 401 → refresh once and retry; the error codes of §8 handled, Retry-After respected on 429.
Next steps