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).
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.
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.
4. Search
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.
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)
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.
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.
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:
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.
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:
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:
Keep og:image and other metadata for scrapers on the .jpg form: AVIF support is unreliable there.
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
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
Receive from EuroStocks: the API base URL, the token URL, a client id and the service account's credentials.
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.
Load and cache attributes and catalog; check version === "v1".
Render the search page and filters from the contract; bound the year filter to
merchant.min_first_registration_year; build URLs with catalog slugs.
Handle the search box's interpretation block: adopt the chips, put text back in the box, show the messages
(§4).
Show the prices the contract advertises (price_display), with vat_scheme, the lease terms and the contract's
labels and disclaimers.
Handle the crawler side (§7): a sitemap with list routes only, noindex on detail pages,
Disallow: /api/ and a real 404 for sold cars.
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
Generate a typed client from GET /api/v1/openapi.json (§3).