---
title: "TheCarApi — API reference"
description: "Complete developer documentation for the TheCarApi vehicle auction data API."
canonical: "https://thecarapi.com/docs"
contract_version: "2026-08-19"
api_base: "https://api.thecarapi.com"
endpoints: "37"
---

# TheCarApi — API reference

Contract `2026-08-19` · base URL `https://api.thecarapi.com` · 37 public endpoints.

Authentication is an `X-API-Key` header or `Authorization: Bearer`. Every response is JSON.
Primary envelopes carry `contract_version`, `request_id`, `server_time` and `data_updated_at`;
a handful of routes omit those — read `X-Request-ID` from the header if you want one code path.
A vehicle is addressed by the pair `site_name` + `auction_id_str`, for example `encar/38112900`.

## Contents

- [Getting started](https://thecarapi.com/docs): What the API serves, the two datasets it exposes, the shape of a request, and a first call you can paste into a terminal.
- [Authentication & scopes](https://thecarapi.com/docs/authentication): Three interchangeable ways to present a key, the restrictions a key can carry, and the scope groups that gate each endpoint.
- [Responses & pagination](https://thecarapi.com/docs/conventions): The response envelope, caching and conditional requests, compression, pagination aliases, and rate-limit headers.
- [Errors & limits](https://thecarapi.com/docs/errors): Status codes, the error body shape, and which failures are worth retrying.
- [Sources & site slugs](https://thecarapi.com/docs/sources): The `site` slug vocabulary, what each source publishes, and why the classifieds network is not one of them.
- [Fields by source](https://thecarapi.com/docs/fields-by-source): What each of the seven auction sources actually publishes — the condition-data coverage matrix, the source-specific traps, and how to write code that survives the differences.
- [CO2 & emissions](https://thecarapi.com/docs/co2): The difference between the measured and estimated CO2 fields, the NEDC/WLTP split that makes a standard tag mandatory, which sources publish a figure, and how to render it without overclaiming.
- [Live prices](https://thecarapi.com/docs/live-prices): Cycle prices versus the at-request bid read on running auctions, and how to read `live_price` and `live_price_pending`.
- [Data dictionary](https://thecarapi.com/docs/schema): Search card and auction detail field lists, the normalized `vehicle_details` block, per-source coverage, and what is never exposed.
- [Search & discovery](https://thecarapi.com/docs/search): Search live auction inventory, then resolve lightweight full-text matches. Ended lots are hidden by default; is_active=false / include_ended=true shows them as well as live ones.
- [Filter facets](https://thecarapi.com/docs/facets): Discover live filter values and counts. Every facet endpoint accepts the search filters, so a facet describes whatever slice of inventory you are looking at rather than the whole of it — each dimension ignoring only its own filter, so a user can switch brands without the option disappearing. Counts and the totals /api/search reports for the same filters cannot disagree. Facet responses cache for about 600 seconds, and a filter sidebar can read every flat dimension in one call with /api/facets.
- [Catalog](https://thecarapi.com/docs/catalog): Build slug-addressable manufacturer and model-group navigation. All catalog list routes paginate with a default limit of 50 and are capped at offset 5000 — a deeper page is a 400 naming the limit. They carry page, page_size and max_page.
- [Auctions & history](https://thecarapi.com/docs/auctions): Retrieve source-aware listing detail, galleries, price changes, and VIN history. A running openlane or ecarstrade auction has its bid read from the auction house at the moment you request the detail — nothing needs to be passed to enable it. Every other listing is served at its cycle price, refreshed at least every twelve hours.
- [Vehicle details](https://thecarapi.com/docs/car-details): Fetch a full source payload from cache or, when needed, from the upstream source.
- [Top offers](https://thecarapi.com/docs/top-offers): Live auctions our pipeline judged to be priced below their market reference. Same deals as /api/search?sort=top_offers, but each card additionally carries the reference the verdict was made against.
- [European classifieds](https://thecarapi.com/docs/theparking): A retail price surface of roughly 9.8M live listings gathered from 681 origin portals — national classifieds sites, dealer groups and manufacturer stock pages — across 39 European countries: mobile.de, kleinanzeigen.de, autoscout24, leboncoin.fr, lacentrale.fr, autosupermarket.it, njuskalo.hr and hundreds more. Every listing keeps its origin portal in source_site, so the same feed answers a national question and a portal-level one. These are retail listings, not auction inventory: no bidding, no auction end date, no detail payload and no gallery, just a single remote thumbnail per listing. They live in their own tables with their own vocabulary (seller type, origin portal), which is why they get their own query surface instead of a site slug on /api/search. Ended and paid-placement ("promoted") rows are excluded from every response, and all three endpoints return 503 while the dataset has not been built yet. Scope: theparking — that is the scope name in the path, not the name of a source.
- [Market intelligence](https://thecarapi.com/docs/market): Precomputed price snapshots for a brand, model, and year window — one for the Bulgarian retail market, one for our own auction inventory. Scope: market. Neither is enabled on a new key by default; ask for them. Both are read-only lookups into snapshots built by a pipeline on its own schedule. A 404 therefore means "no snapshot exists for that window", which is the normal answer for a thin brand/model/year combination — not an error to retry. Neither route carries envelope metadata; read X-Request-ID from the header.
- [Import calculator](https://thecarapi.com/docs/calculator): Estimate the landed cost of importing a vehicle. These are estimates, not a binding quote.
- [SEO helpers](https://thecarapi.com/docs/seo): Build popular landing pages and resolve brand/model URL slugs.
- [Health & contract](https://thecarapi.com/docs/ops): Check service dependencies and inspect the API index.
- [Code examples](https://thecarapi.com/docs/code-examples): Minimal clients in cURL, TypeScript, Python and PHP, plus recipes for pagination, conditional requests and detail pages.
- [Recipes](https://thecarapi.com/docs/recipes): Six end-to-end flows that cover most of what the API is for. Each is a complete sequence, not a fragment.
- [Agents & machine formats](https://thecarapi.com/docs/agents): Every page of this reference as Markdown, plain text, JSON and OpenAPI — and how to point a coding agent at it.

---

# Getting started

What the API serves, the two datasets it exposes, the shape of a request, and a first call you can paste into a terminal.

A REST API over normalized auction inventory from Auto1, OpenLane, Schadeautos, Copart Germany, eCarsTrade, Encar and the Japanese auction houses, plus a European retail classifieds network of some 680 portals. Every response is JSON, every read is a `GET`, and the only required credential is an API key in a header.

This reference covers the stable customer integration surface and nothing else. Website-only helpers and internal publishing operations exist but are not public, are not versioned for you, and are not documented here.

## The mental model

A vehicle is addressed by the pair `site_name` + `auction_id` — for example `encar/38112900`. That pair is stable for the life of the listing and is the only identifier you need to persist. Everything else in the API is either a way of *finding* that pair (search, facets, catalog, top offers) or a way of *expanding* it (detail, images, price history, VIN history).

1. **Discover.** `/api/search` with filters, or `/api/facets` to populate the filter controls themselves. You get result cards carrying `site_name` and `auction_id`.
2. **Expand.** `/api/auction/{site}/{id}` returns the full record for one card — specification, condition, the normalized `vehicle_details` block, and the image gallery embedded as `vault_gallery`.
3. **Enrich.** Price history, VIN history, market reference prices and the landed-cost calculator all take the same pair, or figures from the detail response.

> **Fresh where it counts** — On a running auction with open bidding, the bid is read at the moment you request that car's detail. Every other price is served as of the latest refresh, at most twelve hours old. Knowing which is which is the single most useful thing in this reference — see [Live prices](https://thecarapi.com/docs/live-prices).

> **Two datasets, two surfaces** — Auction inventory is queried through `/api/search` with a `site` slug. The classifieds network is retail listings — no bidding, no end date, no detail payload — so it is never merged into the auction feed, never appears in `/api/sites`, and is queried through `/api/theparking/*` instead.

## Base URL

```text
https://api.thecarapi.com
```

HTTPS is required; plain HTTP is refused rather than redirected. Send JSON request bodies with `Content-Type: application/json`. There is no URL version prefix — the schema contract is carried in the `contract_version` field of most responses (see [Responses & pagination](https://thecarapi.com/docs/conventions) for the handful that omit it) and declared in full by [/api/contract](https://thecarapi.com/docs/ops).

## Your first request

```bash
curl -sS \
  -H "X-API-Key: $API_KEY" \
  -H "Accept-Encoding: gzip" \
  --compressed \
  "https://api.thecarapi.com/api/search?brand=bmw&fuel=Diesel&year_from=2018&sort=price_low&limit=24"
```

_Every documented endpoint takes the same header. `--compressed` is worth having from the first call — see Responses & pagination._

A successful response carries `success: true`, the payload under a route-specific key, and the correlation fields described in [Responses & pagination](https://thecarapi.com/docs/conventions). Store `request_id` — it is the only thing that lets support trace a specific call.

## Anatomy of a request

| Header | Value | Why |
| --- | --- | --- |
| `X-API-Key` | Your key | Required, unless you send `Authorization` instead. One auth method per request. |
| `Authorization` | `Bearer <your key>` | Equivalent to `X-API-Key`; use whichever your HTTP client makes easier. |
| `Accept-Encoding` | `gzip` | Responses over 2 KB come back gzipped — roughly an eighth of the bytes on a full search page. |
| `If-None-Match` | A previously received `ETag` | Unchanged pages answer `304` with no body. |
| `Content-Type` | `application/json` | Only for the routes that accept a POST body. |

| Response header | Meaning |
| --- | --- |
| `X-Request-ID` | Correlation id, mirrored in the body as `request_id`. Log it. |
| `ETag` | Weak validator for conditional requests. |
| `X-Cache` | `HIT`, `MISS` or `STALE`. |
| `X-Cache-Age` | Age in seconds of a cached `/api/car-details` payload. Sent only on a cache hit there. |
| `X-Data-Source` | Where a `/api/car-details` body came from: `auctions` (our stored record), `car_details_cache`, or `scraped` (fetched from the source during your request). |
| `X-RateLimit-Limit` / `-Remaining` / `-Reset` | Quota state for the current window. **Sent only for keys that carry a quota** — a key with no configured limit gets no rate-limit headers at all. |
| `Retry-After` | Sent with `429`. Wait this long, do not guess. |
| `X-Live-Price` | `pending` when a bid refresh missed the request budget. |
| `X-Details-Fetch` | Set while `/api/car-details` is still fetching upstream. |

Responses may also carry `X-Perf-*` and `X-Local-Source` headers. Those are diagnostic, are not part of this contract, and may change or disappear without notice — do not branch on them.

## What this API is not

- **Not a bidding interface.** It observes auction prices; it never places, raises or withdraws a bid, and it grants no purchase rights on any source platform.
- **Not a fee quote.** Auction fees, transport and delivery figures are quoted to *our* buyer account and *our* address, so they are stripped at the response boundary. Use the [import calculator](https://thecarapi.com/docs/calculator) for landed cost on your own route.
- **Not a file host for source paperwork.** Inspection reports and documents are returned as links to the auction house; they are never proxied or stored, and upstream links expire.

## Read these before you render anything

Three pages answer the questions that cause the most rework, and all three are about what the data *means* rather than how to request it.

| Page | The question it answers |
| --- | --- |
| [Live prices](https://thecarapi.com/docs/live-prices) | Which prices are read at the moment you ask and which are up to twelve hours old — and why a detail page can legitimately show a higher price than the card it was opened from. |
| [Fields by source](https://thecarapi.com/docs/fields-by-source) | Which of the seven sources actually publishes damages, service history, documents and equipment — and which do not. Read this before you promise a feature. |
| [CO2 & emissions](https://thecarapi.com/docs/co2) | Why there are two CO2 fields, why an estimate always ships with a test standard attached, and why a 2019 figure is not comparable with a 2022 one. |

> **Reading this reference with an agent** — Every page here is available as Markdown, plain text and JSON, and the whole endpoint surface as an OpenAPI document (the API's published spec is 3.0.3; this site's generated `/openapi.json` is 3.1). See [Agents & machine formats](https://thecarapi.com/docs/agents).

---

# Authentication & scopes

Three interchangeable ways to present a key, the restrictions a key can carry, and the scope groups that gate each endpoint.

Every endpoint except the two liveness probes (`/api/health/live` and `/api/health/ready`) requires an API key. Present the key in **one** of these ways:

| Method | Header or parameter | Notes |
| --- | --- | --- |
| Header | `X-API-Key: <key>` | Recommended for server-to-server calls. |
| Bearer | `Authorization: Bearer <key>` | Equivalent to `X-API-Key`. |
| Query string | `?api_key=<key>` | Disabled by default; use only when explicitly enabled for your key. Keys in URLs end up in logs and referrers. |

```bash
# Header — the normal case
curl -H "X-API-Key: $API_KEY" "https://api.thecarapi.com/api/sites"

# Bearer — identical behaviour, useful when your HTTP layer already speaks OAuth
curl -H "Authorization: Bearer $API_KEY" "https://api.thecarapi.com/api/sites"
```

> **Never ship a full-access key to a browser or mobile app** — A key in client code is a public key, whatever the header is called. Issue a separate key with an origin allowlist and the minimum scopes, or proxy through your own backend.

## Key restrictions

| Restriction | Effect when violated |
| --- | --- |
| IP or CIDR allowlist | Requests from outside the list return `403`. |
| Origin allowlist | Browser requests from other origins return `403`. |
| Expiry date | Requests after the date return `401`. |
| Status (revoked, suspended) | All requests return `403`. |
| Scope set | A request to a route your key does not cover returns `403` with `{"success": false, "error": "API key is not authorized for this route"}`. There is no machine-readable error code in the body — branch on the status, not on the message text. |

Five failed authentication attempts from one IP trigger a temporary brute-force lockout: every subsequent request from that address answers `429` for **15 minutes**, including requests carrying a valid key. A successful authentication clears the counter, so a client that fixes its key on the first or second failure never reaches the threshold. Do not retry a `401` in a loop.

## Published scope groups

| Scope | Published access |
| --- | --- |
| `search` | Inventory search, filter facets (individually or combined via `/api/facets`), sources, and the full model catalog |
| `catalog` | Manufacturer and model-group catalog |
| `seo` | Popular searches and brand/model slug resolution |
| `auctions` | Auction detail, images, price history, and VIN history |
| `details` | Full vehicle details, including upstream fetches |
| `top-offers` | Auctions priced below their market reference |
| `theparking` | European classifieds feed, facets, and models |
| `market` | Cars.bg and auction market price snapshots |
| `calculator` | Import cost calculator and supported countries |
| `ops` | Service health, the contract, and the API index |

`*` grants every route. A scope may also be a literal URL rule rather than a group. `public` is a legacy compatibility bundle — search facets, catalog, SEO, auctions, calculator and health — preserved for older integrations; new keys are issued against the groups above.

## What a new key gets by default

Scopes are granted per endpoint, and a new customer key is issued with the common surface enabled and the rest switched off. If a route answers `403` on your key, it is not broken — it was not granted, and the operator can enable it.

|  | Endpoints |
| --- | --- |
| **Enabled by default** | `/api/search`, `/api/brands`, `/api/models`, `/api/years`, `/api/fuels`, `/api/countries`, `/api/gearboxes`, `/api/sites`, `/load-models`, `/api/facets`, `/api/catalog/*`, `/api/seo/*`, `/api/auction/*`, `/api/auction-images/*`, `/api/calculator/*`, `/api/top-offers`, `/api/theparking/*`, `/api/car-details`, `/api/contract`, `/api/health`, `/` |
| **Granted on request** | `/api/vin/{vin}/history`, `/api/cars-bg-market`, `/api/auction-market`, `/api/listVehicles` (and `/listVehicles`), `/api/search/auction-ids` |

`/api/health/live` and `/api/health/ready` need no key and are unaffected by scopes.

> **A scope can reach further than this reference documents** — Some groups — `ops` among them — also cover internal routes that are not part of the contract. Those are unversioned, undocumented and may change or disappear without a changelog entry. Treat the endpoints in this reference as the whole of what your key is meant to call.

> **Check scopes at startup, not per request** — A missing scope is a deployment problem, not a runtime condition. Call `/api/contract` once when your process starts and fail loudly if the surface you depend on is not there.

---

# Responses & pagination

The response envelope, caching and conditional requests, compression, pagination aliases, and rate-limit headers.

## Response envelope

Successful responses use `success: true` and a route-specific payload key. Primary responses also include the fields below.

| Field | Type | Meaning |
| --- | --- | --- |
| `contract_version` | string | Schema contract, currently `2026-08-19`. |
| `request_id` | string | Correlation id, also returned in `X-Request-ID`. |
| `server_time` | timestamp | UTC ISO-8601 response time. |
| `data_updated_at` | timestamp | Time the underlying data was last refreshed. |

> **Not every route carries the metadata** — The four contract fields are merged into search, facet, catalog, auction-detail, auction-image, top-offer, theparking and SEO responses. They are **absent** from `/load-models`, `/api/car-details`, `/api/listVehicles`, price history, VIN history, `/api/search/auction-ids`, the market snapshots, the calculator, `/api/contract`, the health routes and `GET /`. Every one of those still returns the correlation id on the `X-Request-ID` **header**, so read the header rather than the body field if you want one code path.

`GET /load-models` goes further and returns no `success` flag either: its response *keys* are data (brand names) and nothing else may share that namespace. If you are writing a generic client wrapper that asserts `body.success`, special-case that one route.

You may send your own `X-Request-ID` (up to 80 characters) and it will be echoed back.

```json
{
  "success": true,
  "results": [ /* … */ ],
  "total": 18342,
  "limit": 24,
  "offset": 0,
  "contract_version": "2026-08-19",
  "request_id": "0f2c1b9e-4a77-4c31-9a0b-6d2f0a1c8e55",
  "server_time": "2026-08-19T09:41:02Z",
  "data_updated_at": "2026-08-19T06:00:00Z"
}
```

## Caching

Read routes can return `X-Cache: HIT`, `MISS`, or `STALE`. Facet and catalog responses cache for about 600 seconds; shallow search pages cache for about 300 seconds. Deep search pages (`offset > 5000`) are intentionally not cached, and a response carrying a refreshed live price drops its `max-age` to the live-price TTL.

## Conditional requests: send the ETag back

Store the `ETag` from a response and put it in `If-None-Match` on the next request for the same URL. If nothing changed you get `304 Not Modified` with no body — keep using the copy you already hold. A client polling the same search page every few minutes replaces a multi-megabyte download with an empty response for as long as the results hold still.

```http
GET /api/search?site=openlane&limit=100 HTTP/1.1
X-API-Key: your_key
If-None-Match: W/"6f1c0a9d8e..."

HTTP/1.1 304 Not Modified
ETag: W/"6f1c0a9d8e..."
```

`/api/search` gained an `ETag` in contract `2026-08-19`; the facet and catalog routes already had one. ETags are **weak** (`W/"…"`) across the API by design, so one tag stays valid whether or not the body came back compressed. Compare them as opaque strings and echo them back exactly as received — do not strip the `W/` prefix or the quotes.

## Ask for compression

Send `Accept-Encoding: gzip` and any response over 2 KB comes back gzipped — on the order of an eighth of the bytes on a full 100-row search page. Below that threshold responses are sent uncompressed, where the header overhead would outweigh the saving. Most HTTP clients negotiate this and decompress transparently; a few (notably PHP Guzzle in certain configurations) have to be told to.

```php
// Guzzle: decode_content is what turns "Accept-Encoding: gzip" into a decoded body.
$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.thecarapi.com',
    'headers'  => ['X-API-Key' => getenv('THECARAPI_KEY')],
    'decode_content' => 'gzip',
]);
```

## Pagination

| Parameter | Meaning |
| --- | --- |
| `limit` / `page_size` | Page size. Max **100**; larger values are clamped down, not rejected. `limit=0` or a negative value is a `400`. Do not send both aliases. |
| `offset` | Zero-based row offset. Do not combine with `page`. |
| `page` | One-based page number; maps to `offset = (page - 1) * limit`. `page=0` is a `400`. |
| `page` / `page_size` (response) | The same window in page terms, echoed back so a client that paginated by `page` gets its own spelling. Both are `null` on a `count_only` response, which has no pages to describe. Carried by `/api/search`, the catalog routes and `/api/listVehicles`. `/api/top-offers` and `/api/theparking/listings` return only `total`, `limit`, `offset` and `total_pages` — page by `offset` there. |
| `total` / `total_pages` | Null when totals are suppressed. |
| `max_page` | Deepest reachable page under the current depth policy. |
| `include_total` | Set `false` to skip counting. Cheaper on large result sets. |

Default page size is **100** on `/api/search` and **50** everywhere else (catalog, `/api/listVehicles`, `/api/top-offers`, `/api/theparking/listings`). `limit` is capped at **100** across the API — larger values are clamped down rather than rejected.

### Depth policy

- **No offset cap.** `/api/search` and `/api/listVehicles` page to any depth, filtered or not, so `max_page` on those responses is simply `total_pages`. A very deep *filtered* search is answered from a compact index where it can be; where it cannot, it is subject to the safety timeout and may come back `503` asking you to narrow it. Pages past offset 5000 are also not cached, so they cost more.
- **Capped at offset 5000.** `/api/catalog/*`, `/api/top-offers` and `/api/theparking/listings`. A deeper page is a `400` naming the limit; narrow the result set instead.

The live cap is published as `pagination.max_offset` by `GET /api/contract`, alongside `pagination.max_limit`. Read those rather than hardcoding them.

> **Do not mix the two spellings** — `page`/`page_size` and `offset`/`limit` are interchangeable ways of describing the same window, but combining them — `page` with `offset`, or `page_size` with `limit` — is a `400`.

## Rate limits

Managed keys can carry fixed-window quotas on any of four periods: **minute**, **hour**, **day**, **month**. Windows are *fixed*, not sliding — a "60 per minute" key may send 60 requests at `12:00:59` and 60 more at `12:01:00`. That boundary burst is expected.

A key can also be issued with **no** quota, and then none of the rate-limit headers are sent — their absence means "unlimited", not "limit reached". When a key does carry quotas, every request reports the window with the least headroom left, which is the one that will bite you next. All configured windows are checked before any is charged, so a request rejected by the day limit does not also spend a minute of your minute allowance.

A quota breach returns `429` with `X-RateLimit-Remaining: 0`, names the window it broke (`{"success": false, "error": "Rate limit exceeded (1000 per hour)"}`), and includes `Retry-After` — honour it rather than backing off on a schedule of your own. `/api/facets` costs **one** request against your quota however many dimensions you ask for, which is the cheapest way to build a filter sidebar.

```javascript
async function call(url, init = {}) {
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch(url, init);

    if (res.status === 429 || res.status === 503) {
      const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }

    // 4xx other than 429 is a bug in the request. Retrying cannot fix it.
    if (!res.ok && res.status < 500) {
      throw new Error(`${res.status} ${await res.text()} (request ${res.headers.get("X-Request-ID")})`);
    }
    return res;
  }
  throw new Error("giving up after 4 attempts");
}
```

_Retry `429`, `503` and `5xx`. Never retry `400`, `401`, `403` or `404`._

---

# Errors & limits

Status codes, the error body shape, and which failures are worth retrying.

Errors use `success: false` and a sanitized `error` message. Log the `X-Request-ID` response header when reporting a failure — it is the only handle support has on a specific call.

| Status | Meaning | Retry? |
| --- | --- | --- |
| `400` | Invalid filter, pagination, source, unknown query parameter, or malformed body. | No — fix the request. |
| `401` | Missing, invalid, or expired API key. | No — repeated failures trigger a lockout. |
| `403` | Revoked key, or scope, IP, or origin denial. | No. |
| `404` | Source-aware resource not found. | No. |
| `409` | Ambiguous legacy identifier. | No — disambiguate with `site`. |
| `413` | Request body over **50 MB**. Only reachable on the POST routes. | No. |
| `429` | Brute-force lockout (five failed authentications from your IP) or a rate/quota limit. | Yes — after `Retry-After`. |
| `500` | Unexpected server error (message is sanitized). | Yes — with backoff. |
| `503` | A dependency is unavailable, a search or facet query exceeded its safety timeout, a dataset has not been built yet, or authentication itself could not be verified (`Authentication is temporarily unavailable` — retry it; it is not a verdict on your key). | Yes — with backoff. |

```json
{
  "success": false,
  "error": "Unknown site 'ebay'. Valid sites: auto1, japanauction, copart, ecarstrade, encar, openlane, schadeautos"
}
```

_A `400` names the offending value and lists what would have been accepted._

**Unknown query parameters are currently ignored, not rejected — do not rely on that.** Each endpoint understands a fixed set of parameters, and one outside that set is on its way to becoming a `400` naming the offender. Send only what an endpoint documents, and treat a filter that seems to have no effect as a misspelling rather than an unsupported feature.

## Failures that are not errors

Three responses look like something went wrong and have not. Handling them as errors is the most common integration bug:

- **A missing `live_price` block.** It means the car is served at its cycle price, which for most listings is the only price there is. See [Live prices](https://thecarapi.com/docs/live-prices).
- **`details_pending: true`.** The upstream fetch is still running. Poll, do not retry in a tight loop.
- **An empty `inspection_reports` or `images` array.** Most listings publish no reports, and a gallery that has not been vaulted yet reports its shortfall as `pending`.

## Safety timeouts on deep filtered searches

`/api/search` and `/api/listVehicles` have no offset cap. A very deep *filtered* search is answered from a compact index where it can be; where it cannot, it is subject to the safety timeout and may come back `503` asking you to narrow it. Catalog, top-offers and theparking listings are capped at offset 5000 — a deeper page is a `400` naming the limit. Narrow the filter rather than retrying the same URL.

---

# Sources & site slugs

The `site` slug vocabulary, what each source publishes, and why the classifieds network is not one of them.

A `site` slug identifies which auction source a listing came from. It is the value you pass to `/api/search?site=`, the first path segment of `/api/auction/{site}/{id}`, the `site` parameter of `/api/car-details`, and the `site_name` fee-model selector on the import calculator.

> **Read the slug list, do not hardcode it** — Fetch the live set and counts from `/api/sites`. An unknown slug is a `400` naming the offender, not an empty result — a hardcoded list turns a new source into a broken deploy.

| Slug | Upstream | Origin | Inventory | Detail payloads |
| --- | --- | --- | --- | --- |
| `auto1` | Auto1 (EU) | Per-listing | Live auctions | Full + normalized `vehicle_details` |
| `openlane` | OpenLane (EU) | Per-listing | Live auctions | Full + normalized `vehicle_details` |
| `ecarstrade` | eCarsTrade (EU) | Per-listing | Live auctions | Full + normalized `vehicle_details` |
| `schadeautos` | Schadeautos (NL) | NL | Live listings | Full |
| `copart` | Copart Germany | DE | Live auctions | Full |
| `encar` | Encar (South Korea) | KR | Live listings | Full |
| `japanauction` | Japanese auctions — USS, ARAI, AUCNET, BAYAUC, CAA | JP | Live listings | Full, plus the graded auction sheet |

## eCarsTrade

eCarsTrade behaves like every other source — there is no special code path and no separate endpoint. It is a valid value everywhere a source is accepted:

```http
GET  /api/search?site=ecarstrade
GET  /api/auction/ecarstrade/{auction_id}
GET  /api/auction/ecarstrade/{auction_id}/price-history
GET  /api/auction-images/ecarstrade/{auction_id}
GET  /api/car-details?site=ecarstrade&id={id}
POST /api/calculator/calculate   { "site_name": "ecarstrade" }
```

The one source-specific detail worth knowing: an eCarsTrade gallery arrives under the `ImageUrls` key of `car_identification` on the auction detail payload. You do not normally need it — `/api/auction-images/ecarstrade/{id}` returns the same gallery already ordered, vaulted and CDN-served with `served_url`, `width` and `height`, and the identical body rides on the detail response as `vault_gallery`. Reach for the raw key only when you want exactly what the source published.

## Japanese auctions

Japanese export inventory pooled from the major domestic auction houses — USS, ARAI, AUCNET, BAYAUC and CAA — addressed by the slug `japanauction`, which is a valid value everywhere a source is accepted. Two things differ from the EU sources: its listings carry the `JP` origin country, and `/api/car-details?site=japanauction` takes the offer id/UUID rather than the numeric auction id — the same as `auto1`. It publishes less detail than the three EU auction houses, so expect `vehicle_details` to be sparse — except for the graded **auction sheet**, the one substantial detail record that source publishes, normalized into `inspection_reports` as type `auction_sheet`.

> **A Japanese price is an opening bid, not a cost** — Roughly a quarter of lots publish no opener at all, and some of the rest are nominal starters. Read `current_price` on this source as a floor the lot will bid up from — not as what it will sell for — and expect best-value ranking to flag some of them optimistically. `buy_now_price` is never set.

> **`country=europe` is an exclusion, not a list** — It means "every origin that is not overseas", so a source shipping from outside Europe has to be named for it to be excluded. It currently excludes `KR` (Encar) and `JP` (Japanese auctions); rows with no recorded country are treated as European and are included. If you need strict membership, pass explicit ISO codes instead.

## Not a source: the European classifieds network

The classifieds network is retail market-reference data, not auction inventory: 9,808,689 live listings gathered from 681 origin portals across 39 European countries. It is never merged into the auction feed, and is deliberately a light dataset — no detail fetch, no image vault, no per-listing detail payload. It therefore never appears in `/api/sites` and is **not** a valid `site` value; passing it returns `400`. Query it through its own `/api/theparking/*` endpoints under the `theparking` scope — that slug is the scope name, not a source name.

|  | Auction sources | Classifieds network |
| --- | --- | --- |
| Queried through | `/api/search?site=…` | `/api/theparking/listings` |
| Scope | `search`, `auctions`, `details` | `theparking` |
| Price meaning | Bid / buy-now / public EUR | Retail asking price |
| Bidding & end date | Yes | No |
| Detail payload | Full, source-aware | None — the listing row is the whole record |
| Images | Vaulted CDN gallery | One remote thumbnail, hotlinked |
| Appears in `/api/sites` | Yes | No |

---

# Fields by source

What each of the seven auction sources actually publishes — the condition-data coverage matrix, the source-specific traps, and how to write code that survives the differences.

Seven auction sources feed one API, and they do not publish the same things. A German wholesale platform ships a full inspection report and a DAT equipment catalogue; a Japanese export lot ships an auction inspection sheet and a condition grade; a Korean listing ships neither but carries a deep specification block. This page is the honest map of who gives you what, so you can decide what your product can promise before you build it.

> **The one rule that saves you the most work** — Read `vehicle_details` and never branch on `site_name`. That block is the layer that already reconciled these differences for you. Drop to `car_identification` only for the handful of source-specific values listed below that have no cross-source equivalent.

## Three layers of data

Every vehicle in the API is described at three levels of normalization. Knowing which layer a field lives on tells you immediately how portable it is.

| Layer | Where | Consistency | Use it for |
| --- | --- | --- | --- |
| **The common core** | Search result cards and the top level of both detail endpoints | Identical across all seven sources | Listings, filters, sorting, price display, countdowns — anything that has to work uniformly. |
| **The normalized detail block** | `vehicle_details` on `/api/auction/{site}/{id}` and `/api/car-details` | Same key names everywhere; **presence varies** by source | Condition, damages, documents, service history, equipment. Check for the key, do not assume it. |
| **The raw source map** | `car_identification` | Source-specific. Key names change with each upstream | The last mile — a value the source publishes that has no cross-source equivalent yet. |

The common core is guaranteed in the sense that the *field* is always in the contract; an individual value can still be `null` when a particular source does not publish it. `fuel_group`, for example, is empty for most Japanese lots — and a lot with no recorded fuel matches **every** `fuel` filter rather than none, so filtering by fuel never makes those lots unreachable.

## What each source gives you

Read this table before you scope a feature. "Sparse" is not a bug to report — it is what the auction house publishes.

| Source | What it is | Depth | What only it gives you |
| --- | --- | --- | --- |
| `auto1` | Europe's largest digital B2B wholesale marketplace | **Deep** — full `vehicle_details` | The richest equipment data in the API (DAT catalogue), paint-thickness measurements, wheel condition, structured damages with a summary and prior-damage history. |
| `openlane` | Pan-European ex-lease and fleet auctions | **Deep** — full `vehicle_details` | Per-damage free-text comments, typed report PDFs (internal damage, external damage, maintenance, battery health) and its own report index in `car_reports`. |
| `ecarstrade` | Belgian online auction house — ex-lease, ex-rental, dealer trade | **Deep** — full `vehicle_details` | **The only source with per-visit service history rows.** Also the only source publishing `estimated_value_eur`, and the only one running blind auctions. |
| `schadeautos` | Netherlands and Benelux salvage and repairable stock | Moderate | Salvage-focused fields: engine capacity, transmission, VAT/margin status, net and gross export pricing, narrative damage text. |
| `copart` | Copart Germany salvage and run-and-drive lots | Moderate | Run-and-drive signal, build-sheet series and special equipment, live lot bid dynamics. |
| `encar` | South Korea's dominant vehicle marketplace | Moderate — deep specs, shallow condition | A large specification and options block, photo counts grouped by type, and registration data. Condition and accident detail is thinner than the EU sources. |
| `japanauction` | Japanese export inventory pooled from USS, ARAI, AUCNET, BAYAUC and CAA | **Shallow** — expect `vehicle_details` sparse or absent | The auction inspection sheet images, the originating auction house, the lot number, a condition grade, and a published steering side. |

## Condition data: the coverage matrix

This is the table people actually need. It says which keys of `vehicle_details` you can expect from which source. Blank means the auction house does not publish it — the field is simply omitted, which is the normal case and not an error.

|  | `auto1` | `openlane` | `ecarstrade` | Other four |
| --- | --- | --- | --- | --- |
| `damages` (per panel) | Structured, with summary, count and prior damages | Structured, with a comment per damage | **Free text + flags only** — no per-panel list exists upstream | Sparse or absent |
| `inspection_reports` | One URL | Typed PDFs by `type_id` | Appraisal PDF + third-party link | `japanauction`: the auction sheet (`auction_sheet`). Others sparse |
| `documents` | Service book + service-detail images | **None** — `paperwork` booleans only | Service-history PDF + appraisal links | — |
| `service_history` | Summary only | Summary only | **Per-visit rows** — the only source with them | — |
| `equipment` | DAT catalogue (richest) | Option list + accessories | Option list | Varies |
| `paperwork` | Partial | `COCAvailable`, `RegistrationDocsAvailable`, `IsV5Present`, `LicensePlate` | Partial | — |
| `estimated_repair_costs` | Yes | Yes | Yes | — |

> **Reports are links, never files** — Every inspection report and document is returned as a URL to the auction house. We never proxy or store the PDF. Fetch them yourself if you need them, and expect upstream links to expire. Reports are also per-car and optional — most listings carry none, so an empty or absent `inspection_reports` array is the normal case, not a pending fetch.

## Source-specific notes worth knowing

### eCarsTrade — blind auctions and the estimate

Most eCarsTrade auctions are **blind**: every bid is private, so no price is published anywhere and none is inferred. On such a lot `current_price`, `start_price` and `buy_now_price` are all `null` — and that is the correct, final answer, not a missing value to retry for. You do not have to infer it: the detail response carries `is_blind: true`, which is the one reliable way to tell "there will never be a price" from "there is no price right now". A client that renders "price unavailable, retrying…" without reading this flag retries forever.

What a blind lot often *does* carry is `estimated_value_eur`, the auction house's own valuation (its "eCT Estimation Price"). It is frequently the only number on the lot, which makes it tempting to render in a price slot. **Do not.** No bid is settled against it and no car is sold at it — a client that shows it as a price quotes customers a figure the auction will not honour. Label it explicitly as an estimate, and never sort or filter on it: it is `null` for every other source and for roughly 86% of eCarsTrade lots, so any ordering that uses it silently drops them.

```json
{
  "auction_id": 7479712,
  "site_name": "ecarstrade",
  "current_price": null,
  "start_price": null,
  "buy_now_price": null,
  "estimated_value_eur": 12000.0
}
```

_A blind lot. Every price field is null and that is final — the estimate is not a substitute._

### Japanese auctions — the price is an opening bid

A live Japanese lot has no hammer price until it closes, so `current_price` on `japanauction` is the **opening bid**, not a cost. Roughly a quarter of lots publish no opener at all, and a minority of the rest are nominal starters set far below what the car will fetch. Read it as a floor. `buy_now_price` is never set on this source, and best-value ranking will flag some of these lots optimistically as a result.

Two more differences: every lot carries the `JP` origin country, so `country=europe` excludes them by design — pass `country=JP` or `site=japanauction` to target them — and `/api/car-details?site=japanauction` takes the offer id/UUID rather than the numeric auction id, the same as `auto1`.

### OpenLane — report types

OpenLane classifies each report it publishes. `type` is the readable name and `type_id` the source's raw code.

| `type_id` | `type` | Appears in |
| --- | --- | --- |
| 1 | `internal_damage_report` | `inspection_reports` |
| 2 | `external_damage_report` | `inspection_reports` |
| 3 | `maintenance_records` | `inspection_reports` |
| 4 | `option_report` | `option_reports` |
| 5 | `battery_health_report` | `inspection_reports` |
| other | `report` | `inspection_reports` |

Auto1's single inspection report is normalized into the same list with type `inspection_report` and no `type_id`. eCarsTrade's appraisal links arrive under `documents` rather than here, because the source itself presents them as documents.

### Japanese auctions — the auction sheet

`japanauction` contributes one report type of its own: `auction_sheet`. It carries no `type_id` and arrives in `inspection_reports` alongside everything else. It is the Japanese auction house's own graded condition sheet — the marked-up diagram with the overall grade, the interior grade and the per-panel annotations — and it is the single document a Japanese lot is bought on. Where the rest of a `japanauction` lot's `vehicle_details` is sparse, this is usually the one thing present, and it is worth surfacing prominently rather than burying in a generic "reports" list. Like every other report it is a link, not a stored file, and not every lot has one.

### Naming collisions to be aware of

The same word means different things upstream, which is precisely why the normalized block exists. A downloadable file is `Documents[]` on Auto1 but `ECarsTradeDocuments[]` on eCarsTrade — while `Documents` on OpenLane is a dictionary of paperwork **booleans** and not a file list at all. If you read `car_identification` directly, you own that problem. If you read `vehicle_details.documents`, you do not.

## Steering side

`steering` is `left` or `right` on every result card and both detail endpoints, and it is never `null` for a vehicle in our inventory. `japanauction` is the only source that publishes a steering field of its own; for every other source the side is read off the car's registration market — a car registered where traffic drives on the left (GB, IE, JP, CY, MT, AU, NZ, ZA, IN, TH, HK, SG) is `right`, everything else `left`.

Because every listing resolves to one side or the other, the two values partition the inventory: their counts sum to the unfiltered total. As a filter it takes two values, each with four equivalent spellings — `left`, `lhd`, `left-hand`, `left_hand` and `right`, `rhd`, `right-hand`, `right_hand` — and anything else is a `400`. It is accepted by `/api/search`, `/api/top-offers`, `/api/listVehicles` and every facet endpoint — though on a facet endpoint it is computed live rather than read from the precomputed table, so it is slower than an unfiltered facet call.

## Writing code that survives the differences

```javascript
// Right: ask for the key, accept its absence, never branch on the source.
function conditionSummary(auction) {
  const d = auction.vehicle_details ?? {};

  return {
    // Present on auto1 and openlane; free text on ecarstrade; absent elsewhere.
    damages: d.damages ?? [],
    damageNotes: d.damage_comment ?? d.technical_issues ?? null,

    // ecarstrade is the only source with per-visit rows. Everyone else has a summary.
    serviceVisits: d.service_history ?? null,
    serviceSummary: d.service_history_summary ?? null,

    // Most listings carry no reports at all. Empty is normal, not an error.
    reports: d.inspection_reports ?? [],

    repairCost: d.estimated_repair_costs ?? null,
  };
}

// Wrong: this breaks the day a source is added, and hides data today.
if (auction.site_name === "auto1") { /* ... */ }
```

_The absence of a key is information about the auction house, not a failure of the request._

- **Never hardcode the source list.** Read it from `/api/sites` at startup. An unknown slug is a `400` naming the offender, so a stale hardcoded list turns a new source into a broken deploy.
- **Design for the shallowest source you serve.** If your product promises "full service history on every car", only eCarsTrade can keep that promise. Promise it where it exists and degrade gracefully where it does not.
- **Treat `car_identification` as unversioned.** Its keys follow each upstream's own naming and change when the upstream changes. Anything you depend on long-term should come from the common core or `vehicle_details`.
- **`details_pending: true` is not an error.** It means the detail payload has not been assembled yet. Poll rather than retrying in a tight loop, and render the card data you already have.

---

# CO2 & emissions

The difference between the measured and estimated CO2 fields, the NEDC/WLTP split that makes a standard tag mandatory, which sources publish a figure, and how to render it without overclaiming.

Roughly half of EU auction lots publish a CO2 figure and the rest publish nothing at all. The API therefore carries **two** CO2 fields with deliberately different meanings, plus a third that says which test standard the second one is on. Rendering them as one number is the single most common way to get emissions wrong.

| Field | Type | What it is |
| --- | --- | --- |
| `co2` | `integer | null` | **Measured.** The figure the auction house itself published, in g/km. `null` on the roughly half of lots that publish none. Carries no standard tag. |
| `co2_estimated` | `integer | null` | **Derived.** Inferred for lots that publish nothing, from other lots of the same specification. Never a measured value. `null` wherever the specification does not decide it. |
| `co2_estimated_standard` | `"NEDC" | "WLTP" | null` | Which EU test cycle `co2_estimated` is expressed on. Always read the estimate together with this. |

> **Why they are two fields and not one** — An estimate must never be able to masquerade as a measurement. Estimates are deliberately not written into `co2`, so a consumer can always tell the two apart — and so can a regulator, a buyer, or your own support team when someone asks where a number came from.

## The two standards, and why it matters

EU registration documents declared NEDC-correlated CO2 through 2020 and switched to WLTP from 1 January 2021. **The same physical car reads 12–25% higher under WLTP.** Nothing about the car changed; the test did.

| Specification | Registered ≤2020 (NEDC) | Registered ≥2021 (WLTP) | Step |
| --- | --- | --- | --- |
| Peugeot 5008 Petrol 96 kW | 115–121 | 145–150 | +25% |
| Peugeot 3008 Petrol 96 kW | 114–120 | 140–144 | +21% |
| BMW 1-series Diesel 85 kW | 100–104 | 111–120 | +17% |
| VW Passat Diesel 110 kW | 106–113 | 127–128 | +15% |
| VW Tiguan Diesel 110 kW | 127–129 | 140–144 | +12% |
| BMW X1 Diesel 85 kW | 104 | 117 | +12% |

_Median g/km for fixed specifications across the labelled inventory. The 2021 switch is visible as a step, not a trend._

> **`co2` mixes both eras and does not say which** — The measured column has always carried both standards: pre-2021 rows are effectively NEDC, 2021-and-later rows are WLTP, and the field carries no tag to tell them apart. If you rank, average or threshold on `co2` across a mixed-year set, newer cars look dirtier than they are. Group by registration year, or state the caveat where you show the number.

## Which sources publish a measured figure

| Source | Measured `co2` | Notes |
| --- | --- | --- |
| `openlane` | Yes | Published on the listing itself. |
| `ecarstrade` | Yes | From the specification table on the listing. |
| `auto1` | Yes | Published with the vehicle specification. |
| `copart` | Effectively no | Present on a fraction of a percent of lots. |
| `schadeautos` | No | Publishes none. |
| `encar` | No | Korean market — homologated on a different cycle entirely. |
| `japanauction` | No | Japanese market — homologated on JC08/WLTC, not an EU cycle. |

## Which lots can carry an estimate

Only EU-market lots: `openlane`, `auto1`, `ecarstrade` and `schadeautos`. Korean and Japanese cars are homologated on JC08/WLTC and American salvage on the EPA cycle — **no EU figure exists for them**, so producing one would not be imprecision, it would be a category error. Those lots carry `null` in both CO2 fields and that is correct.

Within the eligible set, an estimate is produced only where the specification decides the answer with enough confidence — matched on standard, make, model, fuel, engine power in kW and, where possible, year. Where the specification does not pin it down, the field stays `null` rather than guessing. Coverage is deliberately traded away for accuracy: a broader rule that filled far more lots was measured, found to land within 10 g/km only about a third of the time, and rejected.

> **What accuracy to expect** — Measured against a source the reference table was **not** built from — the realistic case — the tighter match answers around a quarter of eligible lots at a median error of about 5 g/km, and the looser one around 30% at about 7 g/km. Good enough to band a car, filter a search, or show a comparison. Not good enough to print on a document.

## Rendering it correctly

```javascript
function co2Label(auction) {
  // A measured figure wins. Note it carries no standard tag — infer the era
  // from registration year if you need to compare across 2020/2021.
  if (auction.co2 != null) {
    return { value: auction.co2, measured: true, standard: null };
  }

  // An estimate is only meaningful with its standard attached.
  if (auction.co2_estimated != null) {
    return {
      value: auction.co2_estimated,
      measured: false,
      standard: auction.co2_estimated_standard,   // "NEDC" | "WLTP"
    };
  }

  // Neither. Common, and correct — most non-EU lots will land here.
  return null;
}
```

_Prefer the measurement, fall back to the estimate, and never silently merge the two._

- **Show the provenance.** "142 g/km" and "≈142 g/km (WLTP, estimated)" are different claims. Your users can tell, and so can anyone auditing your listing.
- **Do not sort or filter on `co2_estimated`.** It is `null` on most lots, so any ordering that uses it drops them without saying so. Sort on `co2` if you must sort at all, and expect the same caveat about mixed eras.
- **Do not compare a 2019 figure with a 2022 figure.** They are different tests. A 12–25% gap between two cars of the same specification is usually the standard, not the engine.
- **Do not treat `null` as zero.** An electric car and a car that published nothing both render as blank if you do — one of those is very wrong.

## Related emissions fields

`effluent_standard_group_search` on the auction detail carries the vehicle's emissions class where the source publishes it (Euro 5, Euro 6 and so on). It is a separate concept from CO2 — it describes pollutant limits the car was type-approved against, not the carbon it emits — and the two should not be presented as one figure. `vehicle_details.specs` may also carry source-published emissions and, for electric vehicles, charging figures.

---

# Live prices

Cycle prices versus the at-request bid read on running auctions, and how to read `live_price` and `live_price_pending`.

**Two kinds of price, and it is worth knowing which one you are holding.** Every listing — spec, condition, availability and price — is refreshed at least every **twelve hours**. For the overwhelming majority of inventory that is the right cadence: a fixed-price listing, a buy-now car or a lot that is not taking bids does not move in between, so its **cycle price** is simply its price.

The exception is a **running auction with open bidding**, where the figure can move any time somebody bids. For exactly those cars the bid is read from the auction house **at the moment you ask for the car's detail**, and you get that number instead of the cycle one.

|  | Cycle price | Live price |
| --- | --- | --- |
| What it is | The listing as of the last refresh | The current bid, read from the auction house while your request is served |
| Covers | Every listing on every source | Running open-bidding auctions on `openlane` and `ecarstrade` |
| Age | At most twelve hours | Seconds |
| Where you see it | Search, facets, top offers, market data, and any detail response that did not qualify | The two detail endpoints only |
| How you can tell | No `live_price` block on the response | The response carries a `live_price` block |
| Reused for | Until the next refresh | About two minutes (`live_prices.ttl_seconds`) |

> **An absent `live_price` block is not an error** — It means you are holding a cycle price — which for that car is the correct price, not a stale one. Most listings never qualify for a live read, because there is no open per-car bid to read. This is the single most common misreading of the API.

**There is no separate real-time endpoint, no parameter to switch on, and no premium tier for it.** Ask for a car's details the way you already do. If it qualifies, the response carries a `live_price` block; if it does not, the cycle price is what exists.

> **Where the live read applies** — `GET /api/auction/{site}/{id}` and `GET|POST /api/car-details`, the latter when the response is served from stored auction data — the normal case. A first-ever fetch of a listing we hold no row for returns the source's own fresh payload anyway. **Search results, facets and top offers are never live-priced**; they serve cycle prices, always, which is what makes them fast enough to page through.

## Which sources

| Source | Live prices | Why |
| --- | --- | --- |
| `openlane` | Yes | Timed online auction with open bidding. |
| `ecarstrade` | Yes | Timed online auction; on an **open** auction the current bid is only published over the auction house's socket. **Blind** eCarsTrade auctions are never refreshed — they publish no bid anywhere by design. |
| `auto1`, `encar`, `copart`, `schadeautos`, `japanauction` | No | No per-car bid published that we can read at request time. Served at the cycle price, which is the listing’s price. |

Do not hardcode that list — read `live_prices.sites` from `GET /api/contract`.

## Reading the response

When a price was refreshed, the detail body gains:

```json
"live_price": {
  "price": 25900,
  "currency": "EUR",
  "source": "openlane",
  "fetched_at": 1786659750
}
```

`price` is the raw bid at the auction house. `fetched_at` is a Unix timestamp in seconds — use it, not your own clock, to render an "as of" indicator. At the same time the **existing price fields are overwritten** with figures recomputed from that bid, on both detail endpoints, so a client that ignores `live_price` entirely still shows the right number.

| Field | Meaning |
| --- | --- |
| `current_price` | The raw current bid. Equals `live_price.price`. |
| `current_final` | Bid plus fees and taxes. |
| `current_tax`, `current_tax_delivery` | The recomputed tax components. |
| `public_price_eur` | The headline price, recomputed. |
| `buy_now_price`, `buynow_final` | Not touched — a buy-now figure does not move with bidding. |

## Where the block sits

Both detail endpoints publish **the same price fields, under the same names, with the same JSON types**, and a live refresh rewrites them the same way on either one. The only difference is nesting, and it follows the shape each endpoint already had.

```json
// /api/auction/{site_slug}/{auction_id} — wrapped in "auction"
{ "success": true,
  "auction": { "auction_id": 11409652,
               "current_price": 25900,
               "current_final": 33566,
               "public_price_eur": 33926,
               "live_price": { "price": 25900, "currency": "EUR" } } }

// /api/car-details — source-shaped, so the same keys sit at the top level
{ "success": true, "site": "openlane", "auction_id": 11409652,
  "current_price": 25900,
  "current_final": 33566,
  "public_price_eur": 33926,
  "auction": { "extracted_fields": { "images": [] } },
  "live_price": { "price": 25900, "currency": "EUR" } }
```

> **`/api/car-details`’s `auction` key is not the vehicle record** — For `openlane` it is `{"extracted_fields": {"images": […]}}` — an image container that shares a name and nothing else. Read prices from the top level on that endpoint, and from inside `auction` on `/api/auction/{site}/{id}`.

## Price fields are JSON numbers

Prices are JSON numbers on both endpoints, whether or not a live refresh happened. Before contract `2026-08-18` they were serialized as **strings** on a normal read but as **numbers** whenever a live price had been refreshed — the same field, two types, switching on something a client could neither control nor predict.

```diff
- "current_price": "25600.00"
+ "current_price": 25600.0
```

Nothing to do if you parse with `Number(...)`, `float(...)` or into a dynamic type, or if your client already tolerated the live-price case. A statically typed client that declares these as strings has to retype them as numbers.

```text
start_price          current_price        current_final
current_tax          current_tax_delivery buy_now_price
buynow_final         buynow_tax           buynow_tax_delivery
final_price          public_price_eur     current_bgn
buynow_bgn
```

_The fields affected by the string → number change._

## When there is no live price

The `live_price` block is **absent** whenever the price was not refreshed. That covers several different situations, and apart from the two flagged below they are indistinguishable in a detail response:

- The source has no live auctions — five of the seven never do.
- The auction has already ended.
- The car has no bids yet. Common on eCarsTrade, where a car with no current bid publishes no price at all.
- **The auction is blind.** Most eCarsTrade auctions are: every bid is private, so no price is published and none is inferred. `current_price` stays `null` and that is the correct, final answer — not a missing value to retry for. This case you *can* identify: the detail response carries `is_blind: true`, and it is the one reliable way to tell "there will never be a price" from "there is no price right now".
- Live prices are unavailable on this deployment.
- **The refreshed figure did not arrive in time for this response.** This one is different, and the response tells you so — see `live_price_pending` below.

**Its absence never means the stored price is wrong.** It means you are looking at a cycle price, which is the normal state for the overwhelming majority of listings. To tell "live prices are unavailable" from "this car has none right now", read `live_prices.enabled` from `/api/contract` once at startup; where the feature does not exist at all, the whole `live_prices` block is missing.

## live_price_pending: the refresh that arrived late

Your request is never held waiting on the auction house. When a current figure is not already to hand, the response comes back immediately with the last known price plus `live_price_pending: true`, and the refreshed figure is ready a few seconds later. Nothing is lost; it just missed your response.

| Response carries | What you are holding | What to do |
| --- | --- | --- |
| `live_price` | The current bid | Nothing. Render it. |
| `live_price_pending: true` | The last known price; the refresh is still running | Repeat the same request once after ~2s. The repeat is served from cache. |
| Neither | The cycle price, and that is what exists | Nothing. This is the normal case. |

This is the **only** case worth reading again for, and it is the only case that sets the flag. One extra read is enough — if it still carries no `live_price`, none is coming for this car right now; stop rather than retrying. A pending response is sent with `Cache-Control: no-store` and `X-Live-Price: pending`, so a browser, CDN or shared proxy cannot answer your follow-up with the very body that missed the price.

```javascript
async function vehicleWithLivePrice(site, id) {
  const first = await getJson(`/api/auction/${site}/${id}`);
  if (first.auction.live_price || !first.auction.live_price_pending) return first;

  // The refresh missed the budget but is still running. Exactly one more read.
  await new Promise((r) => setTimeout(r, 2000));
  return getJson(`/api/auction/${site}/${id}`);
}
```

_Read once more, then stop. A loop here only burns quota._

## Polling

A refreshed price stays current for `live_prices.ttl_seconds` (default 120), and `Cache-Control: max-age` on the response matches. **Polling faster than that returns the same figure** and gains you nothing — honour `max-age`. Note this is per **read**, not a subscription: asking for the car once gives you the bid at that moment; it does not keep tracking while you hold the page. A bid that moves afterwards appears on your next read once the TTL has passed.

## What movement to expect

A live price usually equals the last known one. When it does not, the gap is typically a few hundred euros on `openlane` and can be several thousand on `ecarstrade`, whose bid cars carry no price in the listing at all. Movement concentrates in the hours before a lot closes. Treat `live_price` as a **correction, not a stream** — it is the right thing to read when a user opens a vehicle, and the wrong thing to poll in a loop.

## What stays on cycle prices

Only the detail path is live. `/api/search`, `/api/top-offers`, the market intelligence endpoints and every price-based sort or filter read cycle prices.

> **A detail page can show a higher price than the card you clicked** — That is not an inconsistency to report; it is a bid that moved after the last cycle. Do not build "price changed since you clicked" warnings on this difference, and do not expect a car live-priced above your search's `price_to` to disappear from that search.

Everything here is read only. This API observes auction prices; it never places, raises or withdraws a bid on your behalf.

---

# Data dictionary

Search card and auction detail field lists, the normalized `vehicle_details` block, per-source coverage, and what is never exposed.

## Search result card fields

Fields are returned when the selected source provides them.

```text
auction_id, site_name, auction_batch_id, batch_start_date, batch_end_date,
auction_sec_left, buy_now_price, car_country_extended, country_code,
country_marker_code, country_marker_name, country_marker_color, car_name_en,
current_price, buynow_final, current_final, buynow_bgn, current_bgn,
date_first_registration, has_technical_damage, is_broken, is_buy_now, kw,
mileage, start_price, final_price, thumbnail_url, hp, pk, size, picture_count,
clean_make, clean_model, model_display, model_class_series, fuel_group,
gearbox_group, registration_year, public_damage_state, public_price_eur,
offer_link, is_active, is_top_offer, top_offer_savings_pct, top_offer_savings
```

Four more fields are derived rather than stored. The first three are on every row; the fourth appears once the photo is on our CDN.

| Field | Meaning |
| --- | --- |
| `auction_id_str` | The auction id as a **string**, on every row. Address a car with this one — see below. |
| `auction_end_at` | Absolute UTC close instant, or `null` for stock with no deadline. On every row. |
| `steering` | `left` / `right`, never `null`. On every row. |
| `served_thumbnail_url` | The card's thumbnail once the photo is stored on our CDN — the same value `thumbnail_url` then carries. **Absent until then**, so fall back to `thumbnail_url` rather than treating it as required. |

> **Use `auction_id_str` to address a car, not `auction_id`** — They carry the same id, but `japanauction` ids exceed 2^53 — almost every lot from that source — so a JSON number cannot hold one exactly. JavaScript, and any other client parsing JSON numbers as IEEE-754 doubles, silently rounds `auction_id` and the result no longer addresses the row it came from. `auction_id` is unchanged and stays a number for existing consumers; `auction_id_str` is the exact value, and it is what the `site_name` + id pair you persist should use.

Rows also carry image annotations alongside the thumbnail: `thumbnailUrl` and `servedThumbnailUrl` (camelCase twins of the fields above), `image_status` and `image_source` as on gallery entries, `original_thumbnail_url` (the source's own thumbnail, when the vault's replaced it), and `images` — at most the first **8** display-ordered gallery entries, primary first. **`images` on a search row is a card preview, never the full gallery**: for everything, read `vault_gallery` on the detail response or `/api/auction-images/{site}/{id}`.

`fuel_group` can be empty — `japanauction` publishes no fuel for most of the catalogue — and a lot with no known fuel matches **every** `fuel` filter rather than none, so filtering by fuel does not make those lots unreachable.

## Additional auction detail fields

```text
car_id, chassis_number, co2, co2_estimated, co2_estimated_standard,
buynow_tax, current_tax, buynow_tax_delivery, current_tax_delivery,
cylinder_capacity, effluent_standard_group_search, is_margin,
requested_sales_price, requested_sales_price_can_be_shown, special_price, places,
seller_score, country_code_dealer, community_name, community_seller_name,
currency_code_id, vehicle_grade_id, mechanical_indicator_id, is_commercial_car,
car_identification, car_report_types, images, gallery_images, created_at,
updated_at, first_seen_at, last_seen_at, last_available_at, last_changed_at,
vehicle_details, vault_gallery, estimated_value_eur, is_blind, details_pending
```

It also carries `auction_id_str`, `auction_end_at` and `steering`, exactly as the search cards do, plus the same four catalog-shaped keys `/api/listVehicles` adds: `vehicle_id` (a duplicate of `auction_id`, and subject to the same 2^53 caveat — prefer `auction_id_str`), `manufacturer_slug`, `model_group_slug` and, where the row has one, `fuel_type`. The two slugs are the values the catalog routes are addressed by, so a detail response is enough to build a link to its manufacturer and model-group pages without a second lookup.

### `is_blind` and `details_pending`

Two booleans answer questions the rest of the payload cannot.

**`is_blind`** is `true` when the auction house publishes no bid at all, by design. This is the one case where `current_price: null` is a **final answer rather than missing data** — there is no figure to fetch, now or later, and no live price will ever arrive. Most eCarsTrade auctions are blind. A client that shows "price unavailable, retrying…" on a blind lot will retry forever; read this flag and show `estimated_value_eur` instead, labelled as an estimate.

**`details_pending`** is `true` when the listing's full detail payload has not been fetched from the source yet. While it is `true`, `vehicle_details` is omitted and the response is served with a short `max-age` so a polling client sees the details land. It is not an error and needs no handling beyond rendering the specification section once it turns `false`.

> **The fields worth knowing first** — `public_price_eur` is the canonical public EUR price. `car_identification` is a source-specific specification map. `auction_end_at` is the absolute UTC close instant — drive countdowns from it, not from `batch_end_date` (per-house timezone) or `auction_sec_left` (a snapshot taken at the last refresh, not seconds from now). `estimated_value_eur` is the auction house's own valuation and **not a price you can pay** — see [Fields by source](https://thecarapi.com/docs/fields-by-source). `co2` and `co2_estimated` are measured and derived respectively and must never be merged — see [CO2 & emissions](https://thecarapi.com/docs/co2).

## The vehicle_details block

Present on `/api/auction/{site}/{id}` and `/api/car-details`. The three detail-rich sources describe the same concepts under different names — a downloadable file is `Documents[]` on Auto1 but `ECarsTradeDocuments[]` on eCarsTrade, while `Documents` on OpenLane is a dict of paperwork booleans and not a file list at all — so this block normalizes them. Read it instead of branching on `site_name`. Every key is omitted when the source has nothing for it, and the whole block is omitted while `details_pending` is true.

| Key | Type | Meaning |
| --- | --- | --- |
| `documents` | array | Downloadable files: `{type, url, label}`. Links only. |
| `inspection_reports` | array | Inspection/damage/appraisal reports: `{type, type_id, url}`. |
| `option_reports` | array | Equipment reports (`type_id` 4), kept separate from damages. |
| `car_reports` | array | OpenLane's own report index, as the source sends it. |
| `service_history` | array | Per-visit rows: `{date, mileage_km, company, description, price_eur}`. |
| `service_history_summary` | object | Whether serviced, last date, last mileage. |
| `technical_inspection` | object / string | Roadworthiness inspection validity. |
| `paperwork` | object | `COCAvailable`, `RegistrationDocsAvailable`, `IsV5Present`, `LicensePlate`. |
| `condition` | object | Driveability, keys, tyre depths, grade, inspection status, accident flag. |
| `damages` | array | Structured damage records per panel/area, where the source provides them. |
| `damage_comment` | string | Free-text damage notes (also `damage_service_comment`). |
| `technical_issues` / `remarks` | string | eCarsTrade's free-text defect and remark text. |
| `is_damaged` / `has_technical_issues` | boolean | eCarsTrade category flags. |
| `estimated_repair_costs` | number | Estimated cost to repair the vehicle's damage. Not a fee. |
| `paint_measurements` / `wheels` / `highlights` | array / object | Auto1 condition extras. |
| `technical_inspection_date` | string | Roadworthiness inspection expiry, where the source publishes a bare date. |
| `equipment` | array | Equipment/options list. |
| `warranty` | object | Warranty type, expiry date and mileage. |
| `specs` | object | Engine, body, dimensions, emissions, EV charging figures. |
| `location` | object | Physical and origin country of the vehicle. |
| `seller` | object | Seller/dealer record as the source provides it. |
| `profile` | object | eCarsTrade's vehicle profile block. |
| `other_notes` | array / object | Further Auto1 condition notes. |

### Coverage is not uniform across sources

Each auction house publishes a different subset of these keys, and the differences are large: only eCarsTrade has per-visit service rows, only Auto1 has paint measurements, OpenLane publishes no document files at all, and `japanauction`'s one substantial detail record is the graded auction sheet (`auction_sheet` in `inspection_reports`). This is an upstream limitation, not a gap in our data — do not build a UI that assumes a key is always there. The full coverage matrix, the OpenLane report-type codes and the per-source traps live on [Fields by source](https://thecarapi.com/docs/fields-by-source).

## Never exposed

Bidder and private fields — bidder identities, private bids, personal maximums, and order data — are never returned. Neither is account-scoped commercial data: the auction houses' fee, cost and delivery figures are quoted to *our* buyer account and *our* delivery address, so they describe our commercial terms rather than the vehicle.

| Removed | Examples |
| --- | --- |
| Fees and auction terms | `Fees`, `bidprice`, `MerchantVat`, `Finance`, `Bidding`, `PackageDeal`, `commission_tiers`, `price_breakdown` |
| Delivery quoted to our address | Every key beginning `delivery` or `selfpickup`, at any depth, plus `transport`, `TransportOptions`, `StandingFees`, `PickupAddress`, `CompoundAddress` |
| Third-party contact details | Email addresses redacted from free-text values; surrounding text kept |
| Our identity on the platforms | The buyer-account identity each source sees, our logistics account references, and `BidHistory` |
| Internal processing metadata | `_metrics`, `details_fetch_meta`, `enrich_errors` |

Still served: the auction house's own published asking prices (`price_buy_now`, `price_min_bid`, `price_estimation`), the VAT scheme (`is_margin`, `vat_scheme`), the estimated repair cost, `Specs.TransportCategoryId` (a vehicle class, not a shipment), the seller/warehouse location, and `buynow_tax_delivery` / `current_tax_delivery`. For landed-cost figures use the [import calculator](https://thecarapi.com/docs/calculator), which is built for your own route.

## Sources and enums

| Type | Values |
| --- | --- |
| Sources | `schadeautos`, `auto1`, `openlane`, `copart`, `encar`, `ecarstrade`, `japanauction` |
| Sort | `default` (photo-first, then newest), `newest`, `top_offers`, `time_left_least`, `time_left_desc`, `time_left_most`, `price_low`, `price_high`, `year_new`, `year_old`, `brand_az`, `brand_za`. The retired `random` value is still accepted and is served as `default`. |
| `vehicle_type` | `cars`, `suvs & trucks` (alias: `suvs and trucks`) |
| `smart_filter` | `top_offers`, `low_mileage`, `best_value` |
| Booleans | `true`, `false`, `1`, `0`, `yes`, `no` |

The European classifieds network is not in the source list. It is retail market-reference data, not auction inventory, so it is not a valid `site` value — see the [classifieds endpoint group](https://thecarapi.com/docs/theparking) instead.

---

# Search & discovery

Search live auction inventory, then resolve lightweight full-text matches. Ended lots are hidden by default; is_active=false / include_ended=true shows them as well as live ones.

## GET /api/search

Primary filtered search over live auction inventory.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `search` | Query string | string | no | `BMW 320d` | Free text entered by your user. | Free-text brand, model, or keyword query. |
| `site` | Query string | string | no | `encar` | /api/sites -> sites[].name | Comma-separated source slugs, OR within the parameter. Case-insensitive. An unknown slug is a 400 naming the offender — one bad member rejects the whole list. |
| `site_exclude` | Query string | string | no | `copart` | /api/sites -> sites[].name | Comma-separated source slugs to leave out. Sent alongside site, it narrows the include list; excluding everything you included is a 400, not an empty page. |
| `brand` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Brand display name, slug, or brand_id — all three select the same inventory. Matched on the brand identity rather than the published spelling, so sources that write it differently (openlane publishes "Volkswagen" where others write "VW") are all included. |
| `model` | Query string | string | no | `320d` | /api/models?brand=bmw -> models[].slug | Model name or slug. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. |
| `fuel` | Query string | string | no | `Diesel` | /api/fuels -> fuels[] | Canonical fuel group, matched whole. Hybrid, Mild Hybrid and Plug-in Hybrid are three separate groups — fuel=Hybrid returns full hybrids only. A lot with no recorded fuel matches every fuel filter rather than none, so it stays reachable. |
| `gearbox` | Query string | string | no | `Automatic` | /api/gearboxes -> gearboxes[] | Canonical gearbox group, matched whole. Same unknown-matches-all rule as fuel: a car with no recorded gearbox answers every gearbox filter. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. |
| `vehicle_type` | Query string | string | no | `cars` | Choose from the documented vehicle_type enum. | cars or suvs & trucks. |
| `body_style` | Query string | string | no | `sedan` | Your application's normalized body-style value. | Body-style filter. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Left- or right-hand drive. The parameter takes two values, each with four equivalent spellings. Every listing resolves to one side or the other, so the two values partition the inventory and their counts sum to the unfiltered total. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 80000` | Mileage range chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Horsepower range chosen by your user. | Horsepower range. |
| `price_from / price_to` | Query string | integer | no | `10000 / 30000` | EUR budget chosen by your user. | Public EUR price range. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Return damaged or broken vehicles only. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Return Buy-Now listings only. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Return auctions ending within about two hours. |
| `smart_filter` | Query string | enum | no | `best_value` | Choose from the documented smart_filter enum. | top_offers, low_mileage, or best_value. |
| `is_active` | Query string | boolean | no | `false` | Your filter choice. | Default true: lots whose auction has closed are hidden. false shows closed lots in addition to live ones — it does not return only ended lots, and it is not a filter on the is_active response field. |
| `include_ended` | Query string | boolean | no | `false` | Your filter choice. | The same switch inverted: include_ended=true equals is_active=false. Default false. If both are sent, include_ended wins. |
| `sort` | Query string | enum | no | `price_low` | Choose from the documented Sort enum. | Sort value from the data dictionary. Default default — photo-first, then newest. The retired random value is still accepted and is served as default. |
| `limit / page_size` | Query string | integer | no | `24` | Page size chosen by your application. | Page size, maximum 100. Default 100. |
| `offset / page` | Query string | integer | no | `24 / 2` | Current page state in your application. | Offset-based or page-based pagination. |
| `include_total` | Query string | boolean | no | `false` | Your performance/display choice. | Set false to skip the count. Default true. |
| `count_only` | Query string | boolean | no | `true` | Your response-mode choice. | Return counts without result rows. |

### Example requests

```http
GET https://api.thecarapi.com/api/search?brand=bmw&fuel=Diesel&year_from=2018&sort=price_low&limit=24
```

```http
GET https://api.thecarapi.com/api/search?site=encar&country=KR&buy_now=true&page=2&page_size=20
```

### Example response

```json
{
  "success": true,
  "results": [
    {
      "auction_id": 38112900,
      "site_name": "encar",
      "clean_make": "BMW",
      "clean_model": "320d",
      "registration_year": 2020,
      "mileage": 45000,
      "public_price_eur": 21500,
      "thumbnail_url": "https://cdn.example/photo.webp"
    }
  ],
  "total": 18342,
  "limit": 24,
  "offset": 0,
  "count_only": false,
  "page": 1,
  "page_size": 24,
  "total_pages": 764,
  "max_page": 764,
  "secret_mode": false,
  "contract_version": "2026-08-19"
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `results[]` | object[] | Search result cards. The full allowlisted field set is in the data dictionary; fields are present when the source provides them. Address a car by auction_id_str, not auction_id — japanauction ids exceed 2^53, so a client parsing JSON numbers as doubles silently rounds the numeric one. |
| `results[].images` | object[] | At most the first 8 display-ordered gallery entries, primary first. This is a card preview, never the full gallery — read vault_gallery on the detail response or /api/auction-images/{site}/{id} for everything. |
| `total` | integer \| null | Total matching rows, counting the same lots the pages return — so paging to total_pages does not run into short or empty pages. Null when include_total=false or totals are suppressed. |
| `limit / offset` | integer | The window that was actually applied, echoed back. On a count_only=true response both are 0. |
| `page / page_size / total_pages / max_page` | integer \| null | The same window in page terms, plus total pages and the deepest reachable page. All four are null on a count_only response, which has no pages to describe. max_page equals total_pages here — /api/search has no offset cap. |
| `count_only` | boolean | Echoes the parameter. On true the rows are omitted and only total remains. |
| `secret_mode` | boolean | Legacy field, always false. Ignore it. |
| `contract_version` | string | Schema contract the response was built against. |
| `request_id / server_time / data_updated_at` | string | Correlation id (also X-Request-ID), response time, and when the underlying data was last refreshed. |

**Notes.** site accepts auto1, japanauction (the Japanese auction houses), copart, ecarstrade, encar, openlane or schadeautos — the European classifieds network is not a site value. Both site and site_exclude take a comma-separated, case-insensitive list; an unknown slug on either is a 400 naming the offender, and passing both narrows the include list by the exclusion. There is no offset cap: filtered or not, max_page equals total_pages. A very deep filtered search is answered from a compact index where it can be; where it cannot, it is subject to the safety timeout and may come back 503 asking you to narrow it. Pages past offset 5000 are not cached.

## GET /api/search/auction-ids

Resolve a free-text query to matching auction ids only.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `q` | Query string | string | no | `bmw 320d touring` | Free text entered by your user. | Free-text query. An empty or missing query returns an empty list, not a 400. |

### Example requests

```http
GET https://api.thecarapi.com/api/search/auction-ids?q=bmw%20320d
```

```http
GET https://api.thecarapi.com/api/search/auction-ids?q=kia%20ev6
```

### Example response

```json
{
  "success": true,
  "auction_ids": [
    38112900,
    11409652
  ],
  "found": 2
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `auction_ids` | integer[] | Bare auction_id values with no source attached. An id is only unambiguous if it happens to be unique across sources — which is exactly what /api/auction/{site}/{id} needs a site for. Use this as a filter, not as an addressing scheme; hydrate through /api/search?search= instead, which returns whole cards carrying site_name and auction_id_str. |
| `found` | integer | Length of auction_ids. The whole match set is returned, capped at 10,000 ids — there is no paging. |

**Notes.** Not enabled on a new key by default — ask for it. Three answers are possible: 200 with an empty list when full-text search is switched off or q is empty; 503 with success: false and an empty list when full-text search is enabled but unreachable; 500 with success: false and an empty list for anything else. An empty list on a 200 genuinely means "no matches" only when you already know the feature is on. Treat success: false as "unknown", never as "zero". This response carries no envelope metadata — read X-Request-ID from the header.

---

# Filter facets

Discover live filter values and counts. Every facet endpoint accepts the search filters, so a facet describes whatever slice of inventory you are looking at rather than the whole of it — each dimension ignoring only its own filter, so a user can switch brands without the option disappearing. Counts and the totals /api/search reports for the same filters cannot disagree. Facet responses cache for about 600 seconds, and a filter sidebar can read every flat dimension in one call with /api/facets.

## GET /api/facets

Every flat facet dimension in one request instead of six.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `fields` | Query string | string | no | `brands,fuels,gearboxes` | The dimensions your sidebar actually renders. | Comma-separated subset of brands, years, fuels, countries, gearboxes, sites. Defaults to all of them. models is not available here — it is brand-scoped and stays at /api/models. |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/api/facets?fields=brands,fuels,gearboxes&country=DE
```

```http
GET https://api.thecarapi.com/api/facets?fields=brands,years,sites&damaged=true
```

### Example response

```json
{
  "success": true,
  "brands": [
    {
      "id": 12,
      "name": "BMW",
      "slug": "bmw",
      "count": 3266
    }
  ],
  "fuels": [
    "Diesel",
    "Petrol"
  ],
  "gearboxes": [
    "Automatic",
    "Manual"
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `brands / years / fuels / countries / gearboxes / sites` | array | Each field is its own endpoint’s payload merged verbatim — countries still ships country_details alongside it, and no field gains or loses counts by being requested here. |
| `errors` | object | Present only when a dimension could not be served: { field: reason }. Every other field is still returned, so one slow dimension cannot blank a filter sidebar. |

**Notes.** Every filter accepted by the individual facet endpoints applies here, per field, exactly as its own endpoint would apply it — including the cross-filtering, so ?price_to=5000&fields=brands returns each brand count under EUR 5,000 and value lists omit options with no matches left. is_active and include_ended are not accepted: facets always describe lots whose auction is still open. The per-dimension endpoints below are unchanged and stay fully supported — this is an additional way to read the same data. Scope search, the same scope that already grants them, and the fan-out bills one quota unit rather than six.

## GET /api/brands

List brands with round-trippable slugs and live inventory counts.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |
| `search` | Query string | string | no | `bm` | Free text entered by your user. | Substring filter on brand name. |
| `ordering` | Query string | enum | no | `-count` | Choose name, -name, count, or -count. | name, -name, count, or -count. An unrecognised value falls back to name rather than erroring. |
| `limit` | Query string | integer | no | `20` | Result count chosen by your application. | Maximum number of values. No default — the full brand list is returned unless you cap it, which is what a filter sidebar wants. |

### Example requests

```http
GET https://api.thecarapi.com/api/brands?search=bm&ordering=-count&limit=20
```

```http
GET https://api.thecarapi.com/api/brands?country=DE&damaged=true
```

### Example response

```json
{
  "success": true,
  "brands": [
    {
      "id": 12,
      "name": "BMW",
      "slug": "bmw",
      "count": 1543
    }
  ]
}
```

**Notes.** The slug round-trips: send it back as ?brand=<slug> to /api/search or /api/models. Each brand appears once, keyed on its identity rather than on the spelling a source happened to use, and its count is the whole count for that brand — never a per-spelling fraction of it. The list is safe to render straight into a dropdown without de-duplicating.

## GET /api/models

List models for one brand with live inventory counts.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand` | Query string | string \| integer | yes | `bmw` | /api/brands -> brands[].slug (or id/name) | Brand name, slug, or brand_id. |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |
| `search` | Query string | string | no | `x` | Free text entered by your user. | Substring filter on model name. |
| `ordering` | Query string | enum | no | `-count` | Choose name, -name, count, or -count. | name, -name, count, or -count. An unrecognised value falls back to name rather than erroring. |
| `limit` | Query string | integer | no | `20` | Result count chosen by your application. | Maximum number of values. No default — the full model list for the brand is returned unless you cap it. |

### Example requests

```http
GET https://api.thecarapi.com/api/models?brand=bmw&ordering=-count
```

```http
GET https://api.thecarapi.com/api/models?brand=12&search=x&country=DE
```

### Example response

```json
{
  "success": true,
  "models": [
    {
      "name": "320d",
      "slug": "320d",
      "count": 210
    }
  ]
}
```

**Notes.** brand is required — calling without it returns 400. Each model appears once, merged on the model identity rather than its printed label, so a model spelled "GOLF" by one source and "Golf" by another is one row carrying the whole count.

## GET /api/years

List registration years available in current inventory.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/api/years?country=DE
```

```http
GET https://api.thecarapi.com/api/years?damaged=true&buy_now=true
```

### Example response

```json
{
  "success": true,
  "years": [
    2024,
    2023,
    2022,
    2021
  ],
  "year_counts": [
    {
      "value": 2024,
      "count": 512
    },
    {
      "value": 2023,
      "count": 4127
    },
    {
      "value": 2022,
      "count": 3890
    },
    {
      "value": 2021,
      "count": 3544
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `years` | integer[] | Registration years present in the filtered inventory, newest first. |
| `year_counts` | {value, count}[] | Mirrors years in the same order. Each count is the offers for that registration year under the request other filters. |

## GET /api/fuels

List canonical fuel groups available in current inventory.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/api/fuels?country=DE
```

```http
GET https://api.thecarapi.com/api/fuels?buy_now=true
```

### Example response

```json
{
  "success": true,
  "fuels": [
    "Diesel",
    "Electric",
    "Hybrid",
    "Mild Hybrid",
    "Petrol",
    "Plug-in Hybrid"
  ],
  "fuel_counts": [
    {
      "value": "Diesel",
      "count": 18342
    },
    {
      "value": "Electric",
      "count": 903
    },
    {
      "value": "Hybrid",
      "count": 2211
    },
    {
      "value": "Mild Hybrid",
      "count": 5107
    },
    {
      "value": "Petrol",
      "count": 24980
    },
    {
      "value": "Plug-in Hybrid",
      "count": 1489
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `fuels` | string[] | Canonical fuel groups present in the filtered inventory, alphabetical. |
| `fuel_counts` | {value, count}[] | Mirrors fuels in the same order. |

**Notes.** Six groups, and the three hybrid kinds are distinct: Hybrid (full hybrid), Mild Hybrid (48V assist, cannot drive on the motor alone) and Plug-in Hybrid. The fuel filter matches a group whole, so fuel=Hybrid returns full hybrids only. To catch every electrified car, send all three.

## GET /api/gearboxes

List canonical gearbox groups available in current inventory.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/api/gearboxes?country=KR
```

```http
GET https://api.thecarapi.com/api/gearboxes?damaged=true
```

### Example response

```json
{
  "success": true,
  "gearboxes": [
    "Automatic",
    "Manual"
  ],
  "gearbox_counts": [
    {
      "value": "Automatic",
      "count": 22190
    },
    {
      "value": "Manual",
      "count": 15432
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `gearboxes` | string[] | Canonical gearbox groups present in the filtered inventory. |
| `gearbox_counts` | {value, count}[] | Mirrors gearboxes in the same order. |

## GET /api/countries

List vehicle-location countries and display names.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/api/countries?buy_now=true
```

```http
GET https://api.thecarapi.com/api/countries?damaged=true
```

### Example response

```json
{
  "success": true,
  "countries": [
    "DE",
    "JP",
    "KR",
    "NL"
  ],
  "country_details": [
    {
      "code": "DE",
      "name": "Germany"
    },
    {
      "code": "JP",
      "name": "Japan"
    },
    {
      "code": "KR",
      "name": "South Korea"
    },
    {
      "code": "NL",
      "name": "Netherlands"
    }
  ],
  "country_counts": [
    {
      "value": "DE",
      "count": 9871
    },
    {
      "value": "JP",
      "count": 14203
    },
    {
      "value": "KR",
      "count": 8455
    },
    {
      "value": "NL",
      "count": 3120
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `countries` | string[] | ISO country codes present in the filtered inventory. |
| `country_details` | {code, name}[] | The same codes with display names, for rendering a picker. |
| `country_counts` | {value, count}[] | Keyed by canonical ISO code (UK folds into GB), in the same order as countries. |

**Notes.** country=europe means "every origin that is not overseas" and is implemented as an exclusion, not a list: it currently excludes KR (Encar) and JP (the Japanese auction houses), and rows with no recorded country are treated as European and included. If you need strict membership, pass explicit ISO codes instead.

## GET /api/sites

List auction source slugs with live inventory counts.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/api/sites?buy_now=true
```

```http
GET https://api.thecarapi.com/api/sites?damaged=true
```

### Example response

```json
{
  "success": true,
  "sites": [
    {
      "id": 1,
      "name": "encar",
      "count": 9021
    },
    {
      "id": 2,
      "name": "openlane",
      "count": 4110
    },
    {
      "id": 3,
      "name": "ecarstrade",
      "count": 3187
    },
    {
      "id": 4,
      "name": "japanauction",
      "count": 1642
    }
  ]
}
```

**Notes.** This is the discovery endpoint for both /api/search?site= and /api/auction/{site}/{id}. Counts here respect every other filter on the request but ignore site / site_exclude themselves — a facet that filtered by its own dimension could only ever return the sources you already named. The European classifieds network is retail data, not auction inventory, so it never appears here; it has its own endpoint group.

## GET /load-models

Return the complete model catalog grouped by brand.

**Scope:** `search`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / model` | Query string | string \| integer | no | `bmw` | /api/brands -> brands[].slug (or id/name) | Name, slug, or brand_id, exactly as on /api/search. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | /api/years -> years[] | Registration-year range. Accepted and ignored by /api/years itself, which is the dimension they would narrow. |
| `fuel / gearbox` | Query string | string | no | `Diesel` | /api/fuels -> fuels[], /api/gearboxes -> gearboxes[] | Canonical group, matched whole. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | ISO country code or europe. Accepted and ignored by /api/sites and /load-models. |
| `site / site_exclude` | Query string | string | no | `openlane` | /api/sites -> sites[].name | Comma-separated source slugs to include or leave out. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage range. |
| `horsepower_from / horsepower_to` | Query string | integer | no | `150 / 300` | Power bounds chosen by your user. | Power range in HP. |
| `price_from / price_to` | Query string | integer | no | `0 / 5000` | EUR budget chosen by your user. | Public EUR price range. price_to=5000 on /api/brands returns each brand count under EUR 5,000. |
| `vehicle_type / body_style` | Query string | string | no | `cars` | As on /api/search. | Vehicle-type and body-style filters. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Restrict to left- or right-hand-drive vehicles. Slower than an unfiltered facet call, because it is computed live rather than read from the precomputed facet table. |
| `damaged` | Query string | boolean | no | `true` | Your filter choice. | Restrict to damaged or broken vehicles. |
| `buy_now` | Query string | boolean | no | `true` | Your filter choice. | Restrict to Buy-Now inventory. |
| `ending_soon` | Query string | boolean | no | `true` | Your filter choice. | Restrict to lots ending within about two hours. |

### Example requests

```http
GET https://api.thecarapi.com/load-models?country=DE
```

```http
GET https://api.thecarapi.com/load-models?buy_now=true&damaged=false
```

### Example response

```json
{
  "BMW": [
    {
      "text": "320d"
    },
    {
      "text": "X5"
    }
  ],
  "Volvo": [
    {
      "text": "XC60"
    }
  ]
}
```

**Notes.** This response is shaped differently from every other endpoint in the API: the body IS the map. Every key is a brand name and every value is that brand list of models — there is no wrapper object, no success flag and no contract metadata, because the keys are data and nothing else may share that namespace. Read the correlation id from the X-Request-ID header instead. An error response IS wrapped and does carry success: false, so test for the error shape rather than for a success flag that never appears. If you write a generic client wrapper that asserts body.success, special-case this one route. Model entries are objects, not bare strings, and each list is sorted by name. Heavy — cache it, and prefer /api/models for interactive brand-specific dropdowns.

---

# Catalog

Build slug-addressable manufacturer and model-group navigation. All catalog list routes paginate with a default limit of 50 and are capped at offset 5000 — a deeper page is a 400 naming the limit. They carry page, page_size and max_page.

## GET /api/catalog/manufacturers

Paginated manufacturer catalog with inventory counts.

**Scope:** `catalog`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | Restrict to a country or europe. |
| `limit / offset / page / page_size` | Query string | integer | no | `20 / 0` | Pagination state in your application. | Standard pagination. Default limit 50. |

### Example requests

```http
GET https://api.thecarapi.com/api/catalog/manufacturers?country=DE&limit=20
```

```http
GET https://api.thecarapi.com/api/catalog/manufacturers?page=2&page_size=25
```

### Example response

```json
{
  "success": true,
  "results": [
    {
      "slug": "bmw",
      "name": "BMW",
      "inventory_count": 1543,
      "brand_id": 12
    }
  ],
  "total": 96,
  "limit": 50,
  "offset": 0,
  "total_pages": 2,
  "max_page": 2
}
```

## GET /api/catalog/manufacturers/{slug}

Resolve one manufacturer by slug.

**Scope:** `catalog`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `slug` | URL path | string | yes | `bmw` | /api/catalog/manufacturers -> results[].slug | Manufacturer slug. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | Optional country restriction. |

### Example requests

```http
GET https://api.thecarapi.com/api/catalog/manufacturers/bmw
```

```http
GET https://api.thecarapi.com/api/catalog/manufacturers/bmw?country=DE
```

### Example response

```json
{
  "success": true,
  "manufacturer": {
    "slug": "bmw",
    "name": "BMW",
    "inventory_count": 1543,
    "brand_id": 12
  }
}
```

**Notes.** Returns 404 when the slug is unknown.

## GET /api/catalog/manufacturers/stats

Return aggregate manufacturer statistics.

**Scope:** `catalog`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | Optional country restriction. |

### Example requests

```http
GET https://api.thecarapi.com/api/catalog/manufacturers/stats
```

```http
GET https://api.thecarapi.com/api/catalog/manufacturers/stats?country=DE
```

### Example response

```json
{
  "success": true
}
```

**Notes.** Aggregate manufacturer statistics. Optional country filter. The reference does not pin a field list for this body — treat extra keys as operational, not contract.

## GET /api/catalog/model-groups

Paginated model groups for one manufacturer.

**Scope:** `catalog`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `manufacturer__slug` | Query string | string | yes | `bmw` | /api/catalog/manufacturers -> results[].slug | Parent manufacturer slug. |
| `search` | Query string | string | no | `320` | Free text entered by your user. | Substring filter on model name. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | Optional country restriction. |
| `limit / offset / page / page_size` | Query string | integer | no | `20 / 0` | Pagination state in your application. | Standard pagination. Default limit 50. |

### Example requests

```http
GET https://api.thecarapi.com/api/catalog/model-groups?manufacturer__slug=bmw&search=320
```

```http
GET https://api.thecarapi.com/api/catalog/model-groups?manufacturer__slug=bmw&country=DE&page=2&page_size=20
```

### Example response

```json
{
  "success": true,
  "results": [
    {
      "slug": "320d",
      "name": "320d",
      "inventory_count": 210,
      "manufacturer_slug": "bmw"
    }
  ],
  "total": 34,
  "limit": 50,
  "offset": 0,
  "total_pages": 1,
  "max_page": 1
}
```

## GET /api/catalog/model-groups/{slug}

Resolve one model group by slug.

**Scope:** `catalog`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `slug` | URL path | string | yes | `320d` | /api/catalog/model-groups -> results[].slug | Model-group slug. |
| `manufacturer__slug` | Query string | string | no | `bmw` | /api/catalog/manufacturers -> results[].slug | Manufacturer slug for disambiguation. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | Optional country restriction. |

### Example requests

```http
GET https://api.thecarapi.com/api/catalog/model-groups/320d?manufacturer__slug=bmw
```

```http
GET https://api.thecarapi.com/api/catalog/model-groups/320d?manufacturer__slug=bmw&country=DE
```

### Example response

```json
{
  "success": true,
  "model_group": {
    "slug": "320d",
    "name": "320d",
    "inventory_count": 210,
    "manufacturer_slug": "bmw"
  }
}
```

---

# Auctions & history

Retrieve source-aware listing detail, galleries, price changes, and VIN history. A running openlane or ecarstrade auction has its bid read from the auction house at the moment you request the detail — nothing needs to be passed to enable it. Every other listing is served at its cycle price, refreshed at least every twelve hours.

## GET /api/auction/{site_slug}/{auction_id}

Canonical auction detail with private and bidder fields removed.

**Scope:** `auctions`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `site_slug` | URL path | string | yes | `encar` | /api/search -> results[].site_name | Source name returned by /api/sites. |
| `auction_id` | URL path | integer | yes | `38112900` | /api/search -> results[].auction_id | Integer auction database id. |

### Example requests

```http
GET https://api.thecarapi.com/api/auction/encar/38112900
```

```http
GET https://api.thecarapi.com/api/auction/openlane/11125938
```

### Example response

```json
{
  "success": true,
  "auction": {
    "auction_id": 11409652,
    "site_name": "openlane",
    "clean_make": "BMW",
    "clean_model": "320d",
    "model_display": "320d M Sport",
    "registration_year": 2020,
    "mileage": 45000,
    "current_price": 25900,
    "current_final": 33566,
    "public_price_eur": 33926,
    "auction_end_at": "2026-08-20T10:00:00Z",
    "images": [],
    "vault_gallery": {
      "images": [
        {
          "served_url": "/image-vault/ab/cd/openlane_11409652_00_deadbeef.avif",
          "remote_url": "https://cdn.example/photo_1.jpg",
          "image_status": "ready"
        }
      ],
      "count": 28,
      "pending": 0
    },
    "vehicle_details": {},
    "car_identification": {},
    "live_price": {
      "price": 25900,
      "currency": "EUR",
      "source": "openlane",
      "fetched_at": 1786659750
    }
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `vault_gallery` | object | The image vault’s authoritative photo set, embedded verbatim: the exact body of /api/auction-images/{site}/{id} ({ images, count, pending }). Read it and a detail page is one request instead of two. Both routes build it from one cache entry, so they can never disagree; while pending is non-zero this response is served with max-age=10 so a polling client sees photos as they land. A client that ignores it behaves exactly as before. |
| `vehicle_details` | object | Cross-source normalized condition and paperwork: documents, inspection_reports, option_reports, car_reports, service_history, technical_inspection, paperwork, condition, damages, equipment, warranty, specs, location, seller, profile. Every key is omitted when the source has nothing for it, and the whole block is omitted for a listing whose details have not been fetched yet (details_pending: true). |
| `car_identification` | object | Raw source-specific spec map. Prefer vehicle_details where it covers what you need — its keys are stable across sources, these are not. |
| `images / gallery_images` | object[] | What the source record itself carried — untouched by this release. For the vault’s photo set with served URLs and dimensions, read vault_gallery above. |
| `live_price` | object | Present only when the bid was read from the auction house during this request: { price, currency, source, fetched_at }. price is the raw bid and equals current_price; fetched_at is a Unix timestamp in seconds — use it, not your own clock, to render “as of”. Its absence is not an error: it means the car is served at its cycle price, which for most listings is the only price there is. |
| `live_price_pending` | boolean | True when the refreshed figure was not to hand in time for this response — your request is never held waiting on the auction house. Read the car once more after ~2s and then stop; the second read is served from cache. These responses carry Cache-Control: no-store and X-Live-Price: pending. This is the only case worth reading again for, and the only case that sets the flag. |
| `current_price / current_final / current_tax / current_tax_delivery / public_price_eur` | number | JSON numbers, always. Recomputed from the bid when a live price happened, so a client that ignores live_price entirely still shows the right number. buy_now_price and buynow_final are never touched — a buy-now figure does not move with bidding. |
| `auction_id_str` | string | The auction id as a string — the one to persist and to address the car with. japanauction ids exceed 2^53, so JavaScript and any other client parsing JSON numbers as IEEE-754 doubles silently rounds the numeric auction_id and the result no longer addresses the row it came from. auction_id is unchanged and stays a number for existing consumers. |
| `vehicle_id / manufacturer_slug / model_group_slug / fuel_type` | integer \| string | The same four catalog-shaped keys /api/listVehicles adds. vehicle_id is a duplicate of auction_id (same 2^53 caveat — prefer auction_id_str). manufacturer_slug and model_group_slug are the values the catalog routes are addressed by, so a detail response is enough to build a link to its manufacturer and model-group pages without a second lookup. fuel_type is present only when the row carries a fuel. |
| `is_blind` | boolean | True when the auction house publishes no bid at all, by design — most eCarsTrade auctions. This is the one case where current_price: null is a final answer rather than missing data: there is no figure to fetch, now or later, and no live price will ever arrive. Read it before rendering "price unavailable, retrying…", which on a blind lot retries forever; show estimated_value_eur instead, labelled as an estimate. |
| `details_pending` | boolean | True while the full detail payload has not been fetched from the source yet. vehicle_details is omitted meanwhile and the response carries a short max-age so a polling client sees the details land. Not an error. |
| `auction_end_at` | timestamp \| null | Absolute UTC instant the lot closes, or null for stock with no deadline. Drive countdowns from this — not from batch_end_date, whose timezone differs per auction house, nor auction_sec_left, which is a snapshot taken at the last refresh rather than seconds from now. |
| `estimated_value_eur` | number \| null | The auction house own valuation of the vehicle, in EUR — and NOT a price you can pay. No bid is settled against it and no car is sold at it. Only ecarstrade publishes one (its "eCT Estimation Price"); it is null for every other source and null for roughly 86% of eCarsTrade lots. Where it does appear it is often the only number on the lot, because a blind auction hides the bid: current_price, start_price and buy_now_price are all null while this carries the estimate. Label it as an estimate wherever you show it, and never sort or filter on it — it is absent on most cars, so any ordering that uses it silently drops them. |
| `steering` | enum | left or right, never null. japanauction is the only source that publishes a steering field; for every other source the side is read off the car registration market — a car registered in a country that drives on the left (GB, IE, JP, CY, MT, AU, NZ, ZA, IN, TH, HK, SG) is right, everything else left. Also present on every search result card and on /api/car-details. |
| `co2` | integer \| null | CO2 in g/km as the auction house measured and published it. Null on roughly half of lots, which publish none. It carries no standard tag and mixes both EU test cycles by registration year — see the CO2 & emissions page. |
| `co2_estimated` | integer \| null | A derived CO2 figure for lots that publish none, inferred from other lots of the same specification. NEVER a measured value, and null wherever the specification does not decide it. It is a separate field from co2 on purpose, so an estimate can never be mistaken for a measurement. |
| `co2_estimated_standard` | enum \| null | NEDC or WLTP — which EU test cycle co2_estimated is expressed on. Always read the estimate together with this: the same physical car reads 12-25% higher under WLTP, so a figure without its standard is not comparable. |

**Notes.** Live prices: for a running openlane or ecarstrade auction the bid is read from the source while this request is served, the price fields are rewritten and a live_price block is attached. Nothing enables it and there is no separate real-time endpoint. Every other listing is served at its cycle price — the listing as of the last refresh, at most twelve hours old, and for a car that is not taking bids that is simply its price. A refreshed price is reused for ttl_seconds (default 120) and Cache-Control: max-age drops to match, so polling faster gains nothing. Read live_prices from /api/contract rather than hardcoding which sources qualify. This API only observes auction prices — it never places, raises or withdraws a bid. Returns 404 when the source-aware listing does not exist. Auction fees, bid history, transport and every delivery*/selfpickup* key are removed at the response boundary — they are quoted to our buyer account and our delivery address, not yours. Published asking prices, VAT scheme, estimated repair cost and seller location are unaffected; for landed cost use the import calculator, which prices your own route.

## GET /api/auction/{site_slug}/{auction_id}/price-history

Every recorded price movement for one listing, oldest first.

**Scope:** `auctions`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `site_slug` | URL path | string | yes | `encar` | /api/search -> results[].site_name | Auction source slug. |
| `auction_id` | URL path | integer | yes | `38112900` | /api/search -> results[].auction_id | Auction database id. |

### Example requests

```http
GET https://api.thecarapi.com/api/auction/encar/38112900/price-history
```

```http
GET https://api.thecarapi.com/api/auction/openlane/11125938/price-history
```

### Example response

```json
{
  "success": true,
  "site": "encar",
  "auction_id": 38112900,
  "source_auction_id": "38112900",
  "history": [
    {
      "event_type": "initial",
      "source_auction_id": "38112900",
      "changed_fields": [
        "current_price",
        "public_price_eur"
      ],
      "current_price": 21500,
      "buy_now_price": null,
      "start_price": null,
      "final_price": null,
      "current_final": null,
      "buynow_final": null,
      "public_price_eur": 21500,
      "currency_code_id": "EUR",
      "observed_at": "2026-07-10T08:00:00",
      "created_at": "2026-07-10T08:00:05"
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `event_type` | enum | Exactly three values: initial (the first prices we ever recorded for this lot), baseline (the state before the first observed change, written once so a series never starts mid-move), and change (a subsequent movement). There is no price_change value — a client matching on that has been discarding every event. |
| `changed_fields` | string[] | Which price fields moved in this event. Always an array, empty on a baseline row. |
| `current_price / buy_now_price / start_price / final_price / current_final / buynow_final / public_price_eur` | number \| null | The full price snapshot as of observed_at. JSON numbers or null. |
| `currency_code_id` | string | Currency of the source-side figures. |
| `observed_at` | timestamp | When the change was observed. The field to plot against. |
| `created_at` | timestamp | When the row was written. Within seconds of observed_at; not a second data point. |

**Notes.** A row is written only when a price actually changed, so the series is the movement itself rather than a per-cycle sample — a lot whose price never moved has exactly one row. Capped at 5,000 events, oldest first. For auto1, source_auction_id is the alphanumeric batch id; for every other source it is the numeric auction id as text. The top-level source_auction_id mirrors the newest event. An unknown auction is not a 404: it answers 200 with history: [] and source_auction_id echoing the id you asked for, because "we hold no price movements for this lot" and "this lot does not exist" are the same absence here. Check /api/auction/{site}/{id} if you need to tell them apart. Served with Cache-Control: public, max-age=60. This response carries no envelope metadata — read X-Request-ID from the header.

## GET /api/auction-images/{site_slug}/{auction_id}

Ordered gallery metadata backed by the image vault. Usually unnecessary — the same body rides on the auction detail response as vault_gallery.

**Scope:** `auctions`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `site_slug` | URL path | string | yes | `encar` | /api/search -> results[].site_name | Auction source slug. |
| `auction_id` | URL path | integer | yes | `38112900` | /api/search -> results[].auction_id | Auction database id. |

### Example requests

```http
GET https://api.thecarapi.com/api/auction-images/encar/38112900
```

```http
GET https://api.thecarapi.com/api/auction-images/openlane/11125938
```

### Example response

```json
{
  "success": true,
  "count": 1,
  "pending": 12,
  "images": [
    {
      "url": "https://cdn.example/photo_1.jpg",
      "remote_url": "https://cdn.example/photo_1.jpg",
      "served_url": "/image-vault/ab/cd/ecarstrade_7399555_00_deadbeef.avif",
      "thumbnail": "/image-vault/ab/cd/ecarstrade_7399555_00_deadbeef.avif",
      "image_status": "ready",
      "image_source": "downloaded",
      "index": 0,
      "is_primary": true,
      "source_section": "exterior",
      "width": 1024,
      "height": 768
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `count` | integer | How many photos this response carries. |
| `pending` | integer | How many photos the auction still owes. Show placeholders for these instead of a silently short gallery. |
| `url` | string | The best URL available right now — served_url once vaulted, remote_url until then. |
| `served_url` | string | A path, not an absolute URL. It begins /image-vault/… and is relative to the API base, so join it: https://api.thecarapi.com + served_url. Load this directly once the photo is stored. |
| `remote_url` | string | Always absolute — the source’s own URL. Load this through an image proxy until the photo is vaulted. |
| `image_status` | enum | ready, pending, downloading, failed, blocked, or not_downloaded — but on this endpoint it is always ready, because only stored photos are listed at all. The other values appear on the image annotations attached to search cards, where a photo can still be in flight. failed and blocked mean the photo exhausted its retries and is not counted in pending, so a gallery can settle with pending: 0 and still be shorter than picture_count. |
| `image_source` | enum | downloaded (in the vault) or proxied (still served from the source). |
| `index / is_primary` | integer / boolean | Gallery order and which photo is the thumbnail. |
| `source_section` | string | Section the source filed the photo under, e.g. exterior, interior, damage. |
| `width / height` | integer | Pixel dimensions of the stored photo. |
| `servedUrl / thumbnailUrl / image_index` | string \| integer | camelCase and legacy twins of served_url, thumbnail and index, kept for older clients and always equal to their counterparts. Prefer the snake_case names — the twins may be retired. |
| `local_status / local_rel_path` | string | local_status is the same value as image_status; local_rel_path is the vault path served_url is built from. |

**Notes.** You usually do not need this call: /api/auction/{site}/{id} embeds this same body as vault_gallery, so a detail page can be one request rather than two. This endpoint stays fully supported and is still the right one when you want the gallery on its own; both are served from a single cache entry, so they never disagree, and while pending is non-zero both carry max-age=10. Every entry carries a URL that works right now: served_url once the photo is stored on our CDN, remote_url until then. served_url and thumbnail are paths beginning /image-vault/… — join them to https://api.thecarapi.com. remote_url is always absolute. Load served_url directly and remote_url through an image proxy. /image-vault/* paths answer with a 302 to a pre-signed object URL rather than streaming bytes, so use a client that follows redirects — every mainstream one does by default, and the target object is immutable-cached. Stored photos are AVIF or WebP depending on what the source served — rely on the URL and content type, not the extension. An empty images array means nothing is stored yet; an invalid site returns 404.

## GET /api/vin/{vin}/history

Look up a full VIN across current and archived auction records.

**Scope:** `auctions`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `vin` | URL path | string | yes | `WBA8E9G50GNU12345` | /api/auction/{site}/{id} -> auction.chassis_number | Valid 17-character VIN. Matching ignores punctuation and case in the stored VIN, so a source that wrote it with separators still matches. |

### Example requests

```http
GET https://api.thecarapi.com/api/vin/WBA8E9G50GNU12345/history
```

```http
GET https://api.thecarapi.com/api/vin/KNAB3811ALT123456/history
```

### Example response

```json
{
  "success": true,
  "vin": "WBA8E9G50GNU12345",
  "match_count": 2,
  "auctions": [
    {
      "site_name": "encar",
      "auction_id": 38112900,
      "clean_make": "BMW",
      "clean_model": "320d",
      "mileage": 45000,
      "public_price_eur": 21500,
      "first_seen_at": "2026-06-01T00:00:00",
      "last_seen_at": "2026-07-10T00:00:00",
      "archived": false,
      "source_auction_id": "38112900"
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `site_name / auction_id / source_auction_id` | string \| integer | Which source and lot. source_auction_id is the Auto1 batch id where one exists, otherwise the auction id as text. |
| `chassis_number` | string | The VIN as the source published it (punctuation and case as stored). |
| `clean_make / clean_model / model_display` | string | Normalized identity. |
| `date_first_registration / mileage` | string \| integer | Registration date and the mileage recorded on that appearance. |
| `current_price / buy_now_price / start_price / final_price / current_final / buynow_final / public_price_eur` | number \| null | The whole price block. JSON numbers or null. |
| `currency_code_id` | string | Currency of the source-side figures. |
| `first_seen_at / last_seen_at / last_available_at / created_at / updated_at / archived_at` | timestamp \| null | Lifecycle. archived_at is null on a live row. |
| `is_active / offer_link` | boolean \| string | Whether the lot is still open, and its listing URL. |
| `archived` | boolean | True when this appearance comes from the archive rather than live inventory. |

**Notes.** Not enabled on a new key by default — ask for it. Returns up to 100 matches, newest first by the most recent of archived_at / last_seen_at / updated_at / created_at, deduplicated on (source, auction id) so one lot never appears twice. This is the endpoint that tells you a car has been through auction more than once — two rows for one VIN, months apart, with different mileages, is the pattern worth surfacing. A VIN that is not 17 valid characters is a 400; a valid VIN we have never seen answers 200 with match_count: 0. This response carries no envelope metadata — read X-Request-ID from the header.

---

# Vehicle details

Fetch a full source payload from cache or, when needed, from the upstream source.

## GET | POST /api/car-details

Fetch full vehicle detail by source and listing identifier.

**Scope:** `details`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `site` | Query or JSON body | string | yes | `encar` | /api/sites -> sites[].name | schadeautos, auto1, openlane, copart, encar, ecarstrade, or japanauction. |
| `id / link / identifier` | Query or JSON body | string | yes | `38112900` | /api/search -> results[].auction_id, or the source listing URL | Provide any one source id or listing URL. For auto1 and japanauction this is the offer id/UUID, not the numeric auction id. |
| `search_id` | Query or JSON body | string | no | `vehicle-page-42` | Optional correlation id generated by your application. | Optional correlation id. |

### Example requests

```http
GET | POST https://api.thecarapi.com/api/car-details?site=encar&id=38112900
```

```http
POST https://api.thecarapi.com/api/car-details
{
  "site": "openlane",
  "identifier": "11125938",
  "search_id": "vehicle-page-42"
}
```

### Example response

```json
{
  "success": true,
  "site": "openlane",
  "auction_id": 11409652,
  "current_price": 25900,
  "current_final": 33566,
  "public_price_eur": 33926,
  "vehicle_details": {},
  "auction": {
    "extracted_fields": {
      "images": []
    }
  },
  "live_price": {
    "price": 25900,
    "currency": "EUR",
    "source": "openlane",
    "fetched_at": 1786659750
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `vehicle_details` | object \| null | The same normalized block /api/auction/{site}/{id} returns, so the two detail endpoints agree. Null when the listing has no fetched details yet. This is the only part of the response with a stable cross-source contract — the remaining keys are source-shaped (data, auction, images, details depending on the source). |
| `details_pending` | boolean | True while the upstream fetch is still running — a fetch that outran the request time budget keeps running in the background. Poll rather than retrying in a tight loop. The X-Details-Fetch header carries the same signal as fetched, pending or miss, and is absent when the response was served from stored data. Cache-Control is max-age=5 while pending and max-age=60 once settled. |
| `current_price / current_final / public_price_eur` | number | The same price fields as /api/auction/{site}/{id}, under the same names and with the same JSON types — but at the top level here, because this body is source-shaped and has no auction wrapper. The two detail endpoints therefore cannot disagree about what a car costs. |
| `live_price / live_price_pending` | object \| boolean | Identical contract to /api/auction/{site}/{id}, and attached the same way when this response is served from stored auction data — the normal case, for a running openlane or ecarstrade auction. A first-ever fetch of a listing we hold no row for returns the source’s own fresh payload anyway. |
| `auction` | object | Not the vehicle record. For openlane this is { extracted_fields: { images: […] } }, an image container that shares a name and nothing else. Read prices from the top level on this endpoint, and from inside auction on /api/auction/{site}/{id}. |
| `estimated_value_eur` | number \| null | Same field and same meaning as on /api/auction/{site}/{id}, at the top level here. The auction house own valuation — not a price you can pay, ecarstrade only, null everywhere else. |
| `steering` | enum \| null | Same derivation and spelling as the search cards and /api/auction/{site}/{id}, published at the top level here. Null only when the vehicle is not in our inventory at all, because there is then no registration country to derive a side from. |

**Notes.** Parameters may be sent in the query string or a JSON body. This endpoint can be slower than canonical auction detail. Fee, transport, delivery and account-scoped fields are stripped here exactly as they are on auction detail. When no details are stored yet it fetches from the source during the request and returns what it just stored, rather than an empty shell — read details_pending in the body and X-Details-Fetch in the headers (fetched, pending, or miss; absent when served from stored data), and poll rather than retrying in a tight loop. This response carries no envelope metadata — no contract_version, request_id, server_time or data_updated_at in the body. Read the correlation id from the X-Request-ID header. The response also carries X-Data-Source (auctions, car_details_cache or scraped) telling you which path answered, and X-Cache-Age in seconds on a cache hit. Everything outside vehicle_details and the price fields is source-shaped (data, auction, images, details depending on the source); those two are the parts with a stable cross-source contract. Live prices apply here too when the response comes from stored auction data; note the one trap that catches people — for openlane this body’s auction key is an image container, not the vehicle record, so prices are read from the top level. If the source reports the listing is gone, that surfaces as a 404 rather than a 500.

## GET | POST /api/listVehicles

Catalog-shaped listing feed — a compatibility alias for search.

**Scope:** `details`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `manufacturer_slug / manufacturer / brand` | Query or JSON body | string | no | `bmw` | /api/catalog/manufacturers -> results[].slug | Brand, under any of the three accepted spellings. |
| `model_group_slug / model_group / model` | Query or JSON body | string | no | `320d` | /api/catalog/model-groups?manufacturer__slug=bmw -> results[].slug | Model group, under any of the three accepted spellings. |
| `fuel_type / fuel` | Query or JSON body | string | no | `Diesel` | /api/fuels -> fuels[] | Canonical fuel group. |
| `steering` | Query or JSON body | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Left- or right-hand drive, in any of the spellings /api/search accepts. |
| `min_year / max_year` | Query or JSON body | integer | no | `2018 / 2024` | Year bounds chosen by your user. | Registration-year range. |
| `max_mileage` | Query or JSON body | integer | no | `120000` | Mileage ceiling chosen by your user. | Mileage ceiling. |
| `ordering` | Query or JSON body | string | no | `-created_at` | created_at / created / published_at, price / price_eur, year / date_first_registration, each with an optional leading minus. | Sort order. An unrecognised value silently falls back to the default (photo-first, then newest) rather than erroring. |
| `limit / offset` | Query or JSON body | integer | no | `50 / 0` | Pagination state in your application. | Or page_size / page. Default limit 50. No offset cap. |

### Example requests

```http
GET | POST https://api.thecarapi.com/api/listVehicles?manufacturer_slug=bmw&model_group_slug=320d&max_mileage=120000
```

```http
GET | POST https://api.thecarapi.com/api/listVehicles?brand=audi&min_year=2019&ordering=price&limit=50
```

### Example response

```json
{
  "success": true,
  "vehicles": [],
  "results": [],
  "total": 210,
  "limit": 50,
  "offset": 0,
  "total_pages": 5,
  "max_page": 5
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `vehicles / results` | object[] | The same array object served twice under two names for compatibility. Read either one, not both. Each row is a standard search result card plus four catalog-shaped keys: vehicle_id (the same value as auction_id — subject to the same 2^53 caveat, prefer auction_id_str), manufacturer_slug (slug of clean_make), model_group_slug (slug of clean_model), and fuel_type (normalized fuel, present only when the row carries one). Rows carry the same 8-photo images preview cap as /api/search. |
| `total / limit / offset / page / page_size / total_pages / max_page` | integer \| null | Standard pagination fields. There is no offset cap — max_page equals total_pages, as on /api/search. Default limit 50. |

**Notes.** A compatibility alias kept for older integrations, also reachable at /listVehicles, and accepted as GET (query string) or POST (JSON body) with the same fields. Not enabled on a new key by default — ask for it. New integrations should use /api/search, which has more filters, cheaper counts, ETags and a stable envelope. This route exists so older clients keep working. ordering understands three fields, each with an optional minus for descending: created_at / created / published_at (default, photo-first then newest), -created_at / -created / -published_at (newest), price / price_eur (price_low), -price / -price_eur (price_high), year / date_first_registration (year_old), -year / -date_first_registration (year_new). An unrecognised value silently falls back to the default order rather than erroring. The response carries no envelope metadata and is sent Cache-Control: no-store, so it is never revalidated with an ETag.

---

# Top offers

Live auctions our pipeline judged to be priced below their market reference. Same deals as /api/search?sort=top_offers, but each card additionally carries the reference the verdict was made against.

## GET /api/top-offers

Feed of auctions priced below their market reference, newest comparison first.

**Scope:** `top-offers`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `site` | Query string | string | no | `ecarstrade` | /api/sites -> sites[].name | Comma-separated source slugs. An unknown value returns 400. |
| `site_exclude` | Query string | string | no | `copart` | /api/sites -> sites[].name | Comma-separated source slugs to leave out. Same rules as on /api/search. |
| `steering` | Query string | enum | no | `left` | left \| lhd \| left-hand \| left_hand, or right \| rhd \| right-hand \| right_hand — any other value is a 400. | Left- or right-hand drive, in any of the spellings /api/search accepts. |
| `brand` | Query string | string | no | `BMW` | /api/search -> results[].clean_make | Exact clean_make, case-insensitive. |
| `model` | Query string | string | no | `3 Series` | /api/search -> results[].clean_model | Exact clean_model, case-insensitive. |
| `country` | Query string | string | no | `DE` | /api/countries -> country_details[].code | Vehicle country code, case-insensitive, matched exactly. Unlike /api/search, the europe pseudo-country is not supported here — it would match nothing. Pass ISO codes. |
| `year_from / year_to` | Query string | integer | no | `2018 / 2024` | Year bounds chosen by your user. | First-registration year bounds. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 150000` | Mileage bounds chosen by your user. | Mileage bounds. |
| `price_from / price_to` | Query string | integer | no | `5000 / 25000` | EUR budget chosen by your user. | Public EUR price bounds. |
| `min_savings_pct` | Query string | integer | no | `20` | Threshold chosen by your application. | Only offers saving at least this percentage. |
| `sort` | Query string | enum | no | `savings_pct` | Choose savings_pct, savings, price_low, price_high, or newest. | Default savings_pct. An unknown value is a 400 listing the valid values. |
| `limit / offset` | Query string | integer | no | `24 / 0` | Pagination state in your application. | Or page_size / page. Default 50, max 100, offset capped at 5000. This envelope carries total, limit, offset and total_pages only — no page, page_size or max_page. Page by offset. |

### Example requests

```http
GET https://api.thecarapi.com/api/top-offers?site=openlane&min_savings_pct=20&limit=24
```

```http
GET https://api.thecarapi.com/api/top-offers?brand=BMW&country=DE&sort=savings&page=2&page_size=20
```

### Example response

```json
{
  "success": true,
  "results": [
    {
      "auction_id": 8842711,
      "site_name": "openlane",
      "car_name_en": "BMW 320d Touring",
      "clean_make": "BMW",
      "clean_model": "3 Series",
      "public_price_eur": 9000,
      "is_top_offer": true,
      "top_offer_savings": 2500,
      "top_offer_savings_pct": 21.7,
      "market_reference": {
        "price_eur": 11500,
        "mileage": 165000,
        "km_difference": -15000,
        "explanation": "Rule: this car must save at least 1,800 EUR — ..."
      }
    }
  ],
  "total": 318,
  "limit": 24,
  "offset": 0,
  "total_pages": 14
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `results[]` | object[] | A standard search result card plus the top-offer fields below. One difference from a search card: no image annotations. These rows carry thumbnail_url as stored, but not served_thumbnail_url, not images[], and not image_status / image_source. Fetch photos from /api/auction/{site}/{id} (vault_gallery) or /api/auction-images. |
| `is_top_offer` | boolean | Always true on this feed. Also present on /api/search cards. |
| `top_offer_savings` | number | Absolute EUR saving against the market reference. |
| `top_offer_savings_pct` | number | Saving as a percentage of the reference price. Default sort key. |
| `market_reference.price_eur` | number | The reference price this car was measured against. |
| `market_reference.mileage` | integer | The reference mileage, so you can judge whether the comparison is like-for-like. |
| `market_reference.km_difference` | integer | This car’s mileage minus the reference. Negative means this car has done fewer kilometres. |
| `market_reference.explanation` | string | The rule that admitted this car to the feed, in words — e.g. the minimum EUR saving it had to clear. |

**Notes.** A car qualifies only when its saving clears a continuous, reference-quality-aware bar — wider price spread, thinner evidence, and slower-selling models all raise it — so this is a shortlist, not everything below average. Rows leave the feed the moment the auction ends, so total shrinks through the day and grows again after each comparison run. The comparable’s own listing URL and identity are not exposed. A non-numeric value on a numeric parameter is a 400, not a dropped filter. Responses are cached for 60 seconds and served with an ETag.

---

# European classifieds

A retail price surface of roughly 9.8M live listings gathered from 681 origin portals — national classifieds sites, dealer groups and manufacturer stock pages — across 39 European countries: mobile.de, kleinanzeigen.de, autoscout24, leboncoin.fr, lacentrale.fr, autosupermarket.it, njuskalo.hr and hundreds more. Every listing keeps its origin portal in source_site, so the same feed answers a national question and a portal-level one. These are retail listings, not auction inventory: no bidding, no auction end date, no detail payload and no gallery, just a single remote thumbnail per listing. They live in their own tables with their own vocabulary (seller type, origin portal), which is why they get their own query surface instead of a site slug on /api/search. Ended and paid-placement ("promoted") rows are excluded from every response, and all three endpoints return 503 while the dataset has not been built yet. Scope: theparking — that is the scope name in the path, not the name of a source.

## Calling it end to end

1. Call GET /api/theparking/facets once and cache it. Everything sits under a facets key: countries, brands, fuels, gearboxes, sellers, sources, value_counts and totals. It takes no filters — it always describes the whole feed. Never hard-code these values: brand, model, fuel, gearbox and seller are compared case-sensitively, so copy the spellings from here.
2. When the user picks a brand, call GET /api/theparking/models?brand=BMW,Audi to fill the model select. Models come back most common first, capped at 400.
3. Query GET /api/theparking/listings with the chosen filters. Multi-value filters are comma-separated: they OR within a parameter and AND across parameters. Page with limit/offset or page/page_size — limit caps at 100. If you do not need a match count, pass include_total=false: it is the fastest way to page this feed.
4. Render each row directly. There is no detail endpoint and no gallery: the listing object you receive is the entire record, with one remote thumbnail in image_url and a deep link out in offer_url.
5. To use it as a market reference, query the same brand, model, and year window you are about to bid on, read the price_eur spread by seller_type, and diff it against the auction lot’s public_price_eur.

## GET /api/theparking/listings

Query retail classifieds aggregated from portals across Europe.

**Scope:** `theparking`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `country` | Query string | string | no | `de,at` | /api/theparking/facets -> countries[].value | Comma-separated country codes, case-insensitive. |
| `brand` | Query string | string | no | `BMW,Audi` | /api/theparking/facets -> facets.brands[].value | Comma-separated brands, exact as published. Compared as stored — case-sensitive. bmw matches nothing where BMW matches. Take the spelling from the facets endpoint. |
| `model` | Query string | string | no | `3 Series` | /api/theparking/models?brand=BMW -> models[].value | Comma-separated models. Compared as stored — case-sensitive. |
| `fuel` | Query string | string | no | `diesel` | /api/theparking/facets -> facets.fuels[].value | Comma-separated normalized fuels. Exact, case-sensitive. |
| `gearbox` | Query string | string | no | `automatic` | /api/theparking/facets -> facets.gearboxes[].value | Comma-separated normalized gearboxes. Exact, case-sensitive. |
| `seller` | Query string | string | no | `dealer` | /api/theparking/facets -> facets.sellers[].value | Comma-separated seller types, such as dealer or private. Exact, case-sensitive. |
| `source` | Query string | string | no | `mobile.de` | /api/theparking/facets -> sources[].value | Comma-separated origin portals, case-insensitive. Unlike site on the auction side, an unrecognised portal is not an error — this vocabulary is drawn from the data itself rather than fixed, so an unknown value simply matches nothing. |
| `source_exclude` | Query string | string | no | `mobile.de` | /api/theparking/facets -> sources[].value | Comma-separated origin portals to leave out, case-insensitive. With ~681 portals in the vocabulary this is the only practical way to say “everything but”. A listing with no recorded portal survives an exclusion. |
| `price_from / price_to` | Query string | integer | no | `5000 / 15000` | EUR budget chosen by your user. | EUR bounds. |
| `year_from / year_to` | Query string | integer | no | `2016 / 2022` | Year bounds chosen by your user. | Year bounds. |
| `kilometers_from / kilometers_to` | Query string | integer | no | `0 / 200000` | Mileage bounds chosen by your user. | Mileage bounds. |
| `q` | Query string | string | no | `touring` | Free text entered by your user. | Case-insensitive substring match on the listing title or its description. The description itself is not returned, so a match can look unexplained — it is text you cannot see. Rare q values are also the usual cause of total_unavailable. |
| `with_photo` | Query string | boolean | no | `true` | Your filter choice. | Only listings that carry a thumbnail. |
| `sort` | Query string | enum | no | `price_low` | Choose newest, price_low, price_high, year_new, year_old, or mileage_low. | Default newest. |
| `limit / offset` | Query string | integer | no | `50 / 0` | Pagination state in your application. | Or page_size / page. Default 50, max 100, offset capped at 5000. This envelope carries total, limit, offset and total_pages only — no page, page_size or max_page. Page by offset. |
| `include_total` | Query string | boolean | no | `false` | Your pagination strategy. | Default true. Pass false to skip the match count entirely — total comes back null and the request is markedly faster. |

### Example requests

```http
GET https://api.thecarapi.com/api/theparking/listings?country=de,at&brand=BMW&price_to=15000
```

```http
GET https://api.thecarapi.com/api/theparking/listings?seller=dealer&source=mobile.de&sort=price_low&with_photo=true
```

```http
GET https://api.thecarapi.com/api/theparking/listings?country=de&source_exclude=mobile.de,kleinanzeigen.de&include_total=false
```

### Example response

```json
{
  "success": true,
  "listings": [
    {
      "reference_id": "tp-91744022",
      "title": "BMW 320d Touring",
      "brand": "BMW",
      "model": "3 Series",
      "engine": "320d",
      "year": 2016,
      "price_eur": 12500.5,
      "mileage_km": 180000,
      "fuel_norm": "diesel",
      "gearbox_norm": "automatic",
      "colour": "black",
      "doors": "5",
      "country": "Germany",
      "country_code": "de",
      "region": "Bayern",
      "seller_type": "dealer",
      "source_site": "mobile.de",
      "photo_count": 12,
      "offer_url": "https://www.theparking.eu/...",
      "image_url": "https://img.leparking.fr/...",
      "published": "2026-07-30",
      "first_seen_at": "2026-07-30T04:11:02",
      "last_seen_at": "2026-08-05T04:09:55"
    }
  ],
  "total": 4412,
  "total_capped": false,
  "total_unavailable": false,
  "limit": 50,
  "offset": 0,
  "total_pages": 89
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `reference_id` | string | Stable identity for the listing, prefixed tp-. Use it to de-duplicate across polls. |
| `title` | string | Listing headline as published by the origin portal. |
| `brand / model / engine` | string | Brand and model as published, plus the engine or trim string when the portal supplies one. |
| `year` | integer | Registration year. |
| `price_eur` | number | Retail asking price in EUR. This is an asking price, not a transaction price. |
| `mileage_km` | integer | Odometer reading in kilometres. |
| `fuel_norm` | string | Normalized fuel, e.g. diesel, petrol, electric, hybrid. Matches the fuel filter vocabulary. |
| `gearbox_norm` | string | Normalized gearbox, e.g. automatic, manual. Matches the gearbox filter vocabulary. |
| `colour` | string | Exterior colour as published. |
| `doors` | string | Door count as published by the portal. |
| `country / country_code` | string | Country the car is listed in — display name and lower-case code. country_code is what the country filter accepts. |
| `region` | string | Sub-national region or state when the portal publishes one. |
| `seller_type` | string | dealer or private. The single most useful axis for separating trade from retail asking prices. |
| `source_site` | string | Origin portal the listing came from, e.g. mobile.de. Matches the source / source_exclude filter vocabulary, ~681 values wide. This is the attribution that makes a country figure readable — half of all German inventory is one portal. |
| `photo_count` | integer | How many photos the origin listing carries. Only one thumbnail is exposed here. |
| `offer_url` | string | Deep link out to the listing, routed through the aggregator that indexes it — the URL is a theparking.eu one, not a direct link on the origin portal. Read source_site, not this URL, to know which portal the listing belongs to. |
| `image_url` | string | Single remote thumbnail on the aggregator’s own CDN. Not copied into our image vault — treat it as a hotlink that disappears with the listing. |
| `published` | date | Date the origin portal published the listing. |
| `first_seen_at / last_seen_at` | timestamp | When our sweep first and most recently observed the listing. Use last_seen_at to judge staleness. |
| `total / limit / offset / total_pages` | integer | Standard pagination envelope for the matching set. total is exact for ordinary filters, and null when total_unavailable is true. |
| `total_capped` | boolean | True when counting stopped at 100,000. Read total as “100,000+” and page by offset rather than trusting total_pages. An unfiltered total is precomputed and never capped. |
| `total_unavailable` | boolean | True when total is null because the count did not finish inside its budget — typically a q= search or a rare source. The listings themselves are complete and correct; only the count is missing, and the verdict is cached for 5 minutes. |

**Notes.** Every multi-value filter is OR within the parameter and AND across parameters — country=de,at&brand=BMW,Audi means (DE or AT) and (BMW or Audi). A non-numeric value on a numeric parameter is a 400, not a dropped filter. Match values exactly: country, source and source_exclude are case-insensitive; brand, model, fuel, gearbox and seller are compared as stored, so take the spellings from /api/theparking/facets rather than typing them. An unknown value is never an error here, it simply matches nothing. Counting is bounded rather than exhaustive on a feed this size: read total together with total_capped and total_unavailable, or skip it with include_total=false. Responses are served with Cache-Control: public, max-age=60 and no ETag, so conditional requests do not help here — include_total=false is the lever that does. There is no detail endpoint for these rows: those 23 keys are the whole record. Returns 503 while the dataset has not been built yet.

## GET /api/theparking/facets

Counted filter vocabulary for the classifieds dataset.

**Scope:** `theparking`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `limit` | Query string | integer | no | `0` | How many values your picker can hold. | Values returned per dimension, default 50. 0 returns all of them — the only way to get the full 681-portal sources list. |

### Example requests

```http
GET https://api.thecarapi.com/api/theparking/facets
```

```http
GET https://api.thecarapi.com/api/theparking/facets?limit=0
```

```bash
curl -H "X-API-Key: $API_KEY" "https://api.thecarapi.com/api/theparking/facets"
```

### Example response

```json
{
  "success": true,
  "facets": {
    "countries": [
      {
        "value": "de",
        "count": 1840221
      }
    ],
    "brands": [
      {
        "value": "BMW",
        "count": 402118
      }
    ],
    "fuels": [
      {
        "value": "diesel",
        "count": 4110882
      }
    ],
    "gearboxes": [
      {
        "value": "manual",
        "count": 5233901
      }
    ],
    "sellers": [
      {
        "value": "dealer",
        "count": 8901233
      }
    ],
    "sources": [
      {
        "value": "mobile.de",
        "count": 913442
      }
    ],
    "value_counts": {
      "countries": 41,
      "brands": 128,
      "fuels": 7,
      "gearboxes": 3,
      "sellers": 2,
      "sources": 683
    },
    "totals": {
      "total": 9714882,
      "total_capped": false,
      "price_min": 50,
      "price_max": 4500000,
      "year_min": 1920,
      "year_max": 2027
    }
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `facets.countries / brands / fuels / gearboxes / sellers / sources` | {value, count}[] | Each dimension is a list of {value, count}, most common first, nested under the facets key rather than at the top level. The values are exactly what the listings feed’s filters match on, so copy them verbatim: brands, fuels, gearboxes and sellers are compared case-sensitively downstream. |
| `facets.value_counts.<dimension>` | integer | How many distinct values that dimension really has, before limit trimmed it. Check value_counts.sources against the length of sources to know whether you are looking at all of them. |
| `facets.totals.total` | integer | Listings in the feed. |
| `facets.totals.total_capped` | boolean | True when counting stopped at 100,000 — read total as "100,000+". |
| `facets.totals.price_min / price_max / year_min / year_max` | number \| integer | EUR and year bounds across the feed, for sizing sliders. |

**Notes.** Takes no filters — it always describes the whole feed. This is the discovery endpoint for every other classifieds filter: always build selects from here rather than hard-coding values, because brand/model/fuel/gearbox/seller are compared case-sensitively. The default trim of 50 matters most for sources (~680 origin portals); value_counts.sources tells you how many exist and limit=0 returns them all. The vocabulary changes only when the feed is rebuilt, so it is cached for an hour and served with Cache-Control: public, max-age=600. One cached vocabulary answers every limit, so asking for limit=0 once and trimming client-side costs the same as asking for limit=10. Returns 503 while the dataset has not been built yet.

## GET /api/theparking/models

List models available for one or more brands.

**Scope:** `theparking`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand` | Query string | string | yes | `BMW,Audi` | /api/theparking/facets -> facets.brands[].value | Comma-separated brand list. Required. Matched exactly and case-sensitively, like the same parameter on the listings feed — pass the spellings from the brands facet. |

### Example requests

```http
GET https://api.thecarapi.com/api/theparking/models?brand=BMW
```

```http
GET https://api.thecarapi.com/api/theparking/models?brand=BMW,Audi
```

### Example response

```json
{
  "success": true,
  "models": [
    {
      "value": "3 Series",
      "count": 8841
    },
    {
      "value": "5 Series",
      "count": 6002
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `models` | {value, count}[] | Model names available for the requested brands, most common first, capped at 400. value goes straight back into the model filter on /api/theparking/listings. |

**Notes.** brand is required — calling without it returns 400. Because brands are published verbatim by each portal, always take the brand value from /api/theparking/facets rather than typing it. Cached for 600 seconds. Returns 503 while the dataset has not been built yet.

---

# Market intelligence

Precomputed price snapshots for a brand, model, and year window — one for the Bulgarian retail market, one for our own auction inventory. Scope: market. Neither is enabled on a new key by default; ask for them. Both are read-only lookups into snapshots built by a pipeline on its own schedule. A 404 therefore means "no snapshot exists for that window", which is the normal answer for a thin brand/model/year combination — not an error to retry. Neither route carries envelope metadata; read X-Request-ID from the header.

## GET /api/cars-bg-market

Cars.bg Bulgarian retail market snapshot.

**Scope:** `market`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / make` | Query string | string | yes | `BMW` | /api/brands -> brands[].name | Brand name. Either spelling. Resolved through a fuzzy brand matcher, so display names work. |
| `model` | Query string | string | yes | `320d` | /api/models?brand=bmw -> models[].name | Model name. Resolved to one or more catalogue model ids. |
| `year` | Query string | integer | yes | `2019` | Centre year chosen by your application. | The centre year. |
| `flex` | Query string | integer | no | `2` | Tolerance chosen by your application. | Year tolerance ± (0–10, default 1). A value outside that range is a 400. |

### Example requests

```http
GET https://api.thecarapi.com/api/cars-bg-market?brand=BMW&model=320d&year=2019
```

```http
GET https://api.thecarapi.com/api/cars-bg-market?make=Audi&model=A4&year=2020&flex=2
```

### Example response

```json
{
  "success": true,
  "snapshot": {
    "brand_id": "12",
    "model_ids": [
      "4411"
    ],
    "year_center": 2019,
    "year_flex": 2,
    "year_from": 2017,
    "year_to": 2021,
    "listing_count": 84,
    "avg_price_eur": 24310.5,
    "median_price_eur": 23900,
    "min_price_eur": 15500,
    "max_price_eur": 41000,
    "offers": [],
    "raw_matches": [],
    "computed_at": "2026-08-27T02:14:11",
    "updated_at": "2026-08-27T02:14:11"
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `year_from / year_to` | integer | The window year ± flex actually resolved to. |
| `listing_count` | integer | How many retail listings the aggregate is built from. Read this first — a median over four cars is not a market price. |
| `avg_price_eur / median_price_eur / min_price_eur / max_price_eur` | number | EUR aggregates as JSON numbers. Prefer the median; retail asking prices have a long right tail. |
| `offers` | object[] | The listings behind the aggregate. |
| `raw_matches` | object[] | Everything the model matcher considered, including rows the aggregate excluded. Useful for auditing a surprising number. |
| `computed_at / updated_at` | timestamp | When the snapshot was built. It is not recomputed per request. |

**Notes.** Cars.bg (Bulgarian retail market) snapshot: what cars of this specification are asked for on the Bulgarian classifieds market. This is the reference the top-offers verdicts are ultimately measured against. brand/model/year missing → 400. flex outside 0–10 → 400. Brand or model that the matcher cannot resolve → 404 (Unknown brand / Unknown model). Resolved fine but no snapshot built for that window → 404. The market dataset being unavailable altogether → 503. No envelope metadata — read X-Request-ID from the header.

## GET /api/auction-market

Auction-market price snapshot for the same brand, model, and year window.

**Scope:** `market`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand / make` | Query string | string | yes | `BMW` | /api/brands -> brands[].name | Brand name. Matched against the normalized clean_make. |
| `model` | Query string | string | yes | `320d` | /api/models?brand=bmw -> models[].name | Model name. Matched against clean_model. |
| `year` | Query string | integer | yes | `2019` | Centre year chosen by your application. | The centre year. |
| `flex` | Query string | integer | no | `2` | Tolerance chosen by your application. | Year tolerance ± (0–10, default 1). |
| `scope` | Query string | enum | no | `active` | Choose all, active, or inactive. | Default all. Anything else is a 400. Each is a separately built snapshot, so active can exist while inactive does not. |

### Example requests

```http
GET https://api.thecarapi.com/api/auction-market?brand=BMW&model=320d&year=2019&scope=active
```

```http
GET https://api.thecarapi.com/api/auction-market?brand=Kia&model=EV6&year=2023&flex=1
```

### Example response

```json
{
  "success": true,
  "snapshot": {
    "clean_make": "BMW",
    "clean_model": "320d",
    "year_center": 2019,
    "year_flex": 1,
    "year_from": 2018,
    "year_to": 2020,
    "scope": "all",
    "auction_count": 412,
    "avg_price_eur": 18220.4,
    "median_price_eur": 17800,
    "p10_price_eur": 11900,
    "p25_price_eur": 14750,
    "p75_price_eur": 21400,
    "p90_price_eur": 26100,
    "min_price_eur": 6200,
    "max_price_eur": 44900,
    "first_seen_min": "2026-01-04T00:00:00",
    "first_seen_max": "2026-08-22T00:00:00",
    "auctions": [],
    "computed_at": "2026-08-27T02:41:03",
    "updated_at": "2026-08-27T02:41:03"
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `scope` | enum | Echoes the request: active restricts to lots still open, inactive to closed ones, all covers both. |
| `auction_count` | integer | Lots behind the aggregate. Sanity-check it before quoting a percentile. |
| `p10_price_eur / p25_price_eur / p75_price_eur / p90_price_eur` | number | Percentiles — the reason to prefer this over the Cars.bg snapshot when you want a spread rather than a point estimate. A wide p10–p90 band means the specification is not pinning the price down. |
| `first_seen_min / first_seen_max` | timestamp | The observation window the lots span. A snapshot whose window is eight months wide is not a spot price. |
| `auctions` | object[] | The individual lots the aggregate was computed from. |

**Notes.** The same question asked of auction inventory rather than retail: what lots of this specification have cost at auction. Missing brand/model/year → 400; flex outside 0–10 → 400; invalid scope → 400; no snapshot for that window → 404. Retail and auction prices are not comparable line for line — the Cars.bg figure is a Bulgarian asking price including everything a retail seller bundles; the auction figure is a lot price before fees, transport, duty and VAT. Put the auction figure through the import calculator before setting the two side by side. No envelope metadata — read X-Request-ID from the header.

---

# Import calculator

Estimate the landed cost of importing a vehicle. These are estimates, not a binding quote.

## GET /api/calculator/countries

List supported origin and destination countries with EU membership and VAT rates.

**Scope:** `calculator`

### Example requests

```http
GET https://api.thecarapi.com/api/calculator/countries
```

```bash
curl -H "X-API-Key: $API_KEY" "https://api.thecarapi.com/api/calculator/countries"
```

### Example response

```json
{
  "success": true,
  "countries": [
    {
      "code": "DE",
      "name": "Germany",
      "eu": true,
      "vat": 0.19
    },
    {
      "code": "KR",
      "name": "South Korea",
      "eu": false,
      "vat": 0
    }
  ]
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `code` | string | Country code. The United Kingdom is UK here, not GB — this list is the calculator’s own country vocabulary and is separate from the vehicle-origin codes on /api/countries, where the same country is canonicalised to GB. If you feed a vehicle’s country_code straight into the calculator, map GB to UK on the way. |
| `eu` | boolean | True for all 27 EU member states; false for the rest. |
| `vat` | number | VAT as a fraction (0.19 = 19%), unlike /api/calculator/calculate which returns rates as percentages. |

**Notes.** The 36 supported origin/destination countries. This is the authoritative list: a code absent from it is a 400 on /api/calculator/calculate, so read it once at startup instead of hardcoding. An unknown origin or destination is never silently substituted.

## POST /api/calculator/calculate

Estimate duty, VAT, fees, and the landed total for one lot price.

**Scope:** `calculator`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `price` | Query string | number | yes | `15000` | /api/search -> results[].public_price_eur | Lot price. Must be between 0 and 10,000,000. |
| `origin` | Query string | string | no | `KR` | /api/calculator/countries -> countries[].code | Origin country code. Default KR. |
| `destination` | Query string | string | no | `BG` | /api/calculator/countries -> countries[].code | Destination country code. Default BG. |
| `car_type` | Query string | string | no | `standard` | Choose standard or classic. | Default standard. |
| `currency` | Query string | string | no | `EUR` | Currency label used by your application. | Echoed back in the response. Default EUR. |
| `site_name` | Query string | string | no | `ecarstrade` | /api/sites -> sites[].name | Source whose fee model to price against: encar, openlane, auto1, schadeautos, copart or ecarstrade. Omitted, it defaults to encar when origin is KR and otherwise falls back to a generic model that is not what any of our own listings are priced with. japanauction has no profile and takes the generic path too. Send it whenever you know it. |
| `db_tax / db_delivery` | Query string | number | no | `450` | Precomputed values from your own records. | Optional auction-fee and delivery overrides, when you already hold those figures. |
| `is_margin` | Query string | boolean | no | `false` | /api/auction/{site}/{id} -> auction.is_margin | eCarsTrade only: whether the car sells under the VAT margin scheme, which changes whether VAT is added. Default false. The vehicle own value is published as the is_margin field on the detail endpoints. |

### Example requests

```http
POST https://api.thecarapi.com/api/calculator/calculate
{
  "price": 15000,
  "origin": "KR",
  "destination": "BG",
  "site_name": "encar"
}
```

```http
POST https://api.thecarapi.com/api/calculator/calculate
{
  "price": 9800,
  "origin": "BE",
  "destination": "BG",
  "site_name": "ecarstrade"
}
```

### Example response

```json
{
  "success": true,
  "currency": "EUR",
  "breakdown": {
    "lot_price": 15000,
    "auction_fee": 0,
    "trucking": 0,
    "shipping": 1800,
    "our_fee": 700,
    "subtotal_customs_value": 16800,
    "duty_rate": 10,
    "duty_amount": 1680,
    "vat_rate": 20,
    "vat_amount": 3696,
    "customs_agency": 800,
    "custom_clearance_total": 6176,
    "estimated_total": 23676
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `lot_price` | number | The price you sent, echoed. |
| `auction_fee` | number | The auction house’s own buyer fee for that source and lot price. |
| `trucking` | number | Inland haulage, on the generic model only; 0 under a source profile, where it is folded into shipping. |
| `shipping` | number | Transport to the destination. Your db_delivery overrides it when supplied. |
| `our_fee` | number | Our service fee. Charged outside the duty/VAT base. |
| `subtotal_customs_value` | number | lot_price + auction_fee + trucking + shipping — the value duty is assessed on. |
| `duty_rate / duty_amount` | number | Rate as a percentage (10.0), and the amount. 0 for a car already in free circulation in the EU. |
| `vat_rate / vat_amount` | number | Percentage and amount. VAT is charged on subtotal_customs_value + duty_amount, not on the lot price alone. |
| `customs_agency` | number | Fixed post-import costs outside the duty/VAT base (port clearance, agency work). |
| `custom_clearance_total` | number | duty_amount + vat_amount + customs_agency. |
| `estimated_total` | number | subtotal_customs_value + custom_clearance_total + our_fee. The landed figure. |

**Notes.** Duty and VAT rates are returned as percentages. Missing price → 400. A price that is not a number, is negative, or exceeds 10,000,000 → 400. A car_type other than standard or classic → 400. An origin or destination code not in /api/calculator/countries → 400 naming it. For a source with a fee profile, sending price and site_name reproduces the same model that computed that lot’s buynow_final / current_final, so the two agree. For copart, price a running lot from its final_price, which tracks the live high bid; current_price trails it. For ecarstrade, pass the lot’s own is_margin value from the detail response — omitted, it prices as VAT-deductible, which is the dearer quote, so a caller who leaves it out is never under-quoted. classic relief (no duty, reduced VAT) applies only where duty applies at all, which means an import into the EU from outside it. These are estimates, not a binding quote. No envelope metadata — read X-Request-ID from the header.

---

# SEO helpers

Build popular landing pages and resolve brand/model URL slugs.

## GET /api/seo/popular-searches

List the most common live brand/model searches.

**Scope:** `seo`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `limit` | Query string | integer | no | `50` | Result count chosen by your application. | Number of results from 1 to 200. Default 20. |

### Example requests

```http
GET https://api.thecarapi.com/api/seo/popular-searches
```

```http
GET https://api.thecarapi.com/api/seo/popular-searches?limit=50
```

### Example response

```json
{
  "success": true,
  "results": [
    {
      "brand": "BMW",
      "model": "320d",
      "brand_slug": "bmw",
      "model_slug": "320d",
      "count": 210
    }
  ]
}
```

## GET /api/seo/brand-model-from-slug

Resolve brand and model slugs to their canonical display names.

**Scope:** `seo`

### Parameters

| Parameter | Send in | Type | Required | Example | Where the value comes from | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `brand_slug` | Query string | string | yes | `bmw` | /api/seo/popular-searches -> results[].brand_slug | Brand slug, such as bmw. |
| `model_slug` | Query string | string | yes | `320d` | /api/seo/popular-searches -> results[].model_slug | Model slug, such as 320d. |

### Example requests

```http
GET https://api.thecarapi.com/api/seo/brand-model-from-slug?brand_slug=bmw&model_slug=320d
```

```http
GET https://api.thecarapi.com/api/seo/brand-model-from-slug?brand_slug=volvo&model_slug=xc60
```

### Example response

```json
{
  "success": true,
  "brand": "BMW",
  "model": "320d"
}
```

**Notes.** Returns 404 unless the slug pair identifies exactly one live brand/model combination.

---

# Health & contract

Check service dependencies and inspect the API index.

## GET /api/health/live

Unauthenticated process liveness probe.

**Scope:** `none — no API key required`

### Example requests

```http
GET https://api.thecarapi.com/api/health/live
```

### Example response

```json
{
  "status": "ok",
  "service": "car-details-api",
  "contract_version": "2026-08-19"
}
```

**Notes.** One of only two endpoints in the whole API that need no key. Always 200 while the process is up. This is the right target for an uptime monitor — it says nothing about whether the data behind the API is reachable, which is what /api/health/ready and /api/health are for.

## GET /api/health/ready

Unauthenticated readiness probe — is the data layer reachable?

**Scope:** `none — no API key required`

### Example requests

```http
GET https://api.thecarapi.com/api/health/ready
```

### Example response

```json
{
  "status": "ready"
}
```

**Notes.** Unauthenticated readiness: 200 {"status": "ready"} once the service can reach its database, 503 {"status": "not_ready"} until then. Use live for a liveness probe and ready for a readiness probe; neither consumes quota. No key required.

## GET /api/health

Return service, data-feed and schema health.

**Scope:** `ops`

### Example requests

```http
GET https://api.thecarapi.com/api/health
```

```bash
curl -H "Authorization: Bearer $API_KEY" "https://api.thecarapi.com/api/health"
```

### Example response

```json
{
  "status": "healthy",
  "service": "car-details-api",
  "worker_pid": 41,
  "contract_version": "2026-08-19",
  "typesense": {
    "enabled": true,
    "healthy": true
  },
  "public_auction_feed": {
    "enabled": true,
    "fresh": true
  }
}
```

**Notes.** status is healthy or degraded. A degraded service still answers 200, with the reason under schema_findings — so alert on the status field, not on the HTTP code. typesense.healthy being false is what turns free-text ?search= into a plain database match rather than a full-text one, and is the usual explanation for a text search returning less than you expected.

## GET /api/contract

Machine-readable schema catalog, pagination limits, and live-price capability.

**Scope:** `ops`

### Example requests

```http
GET https://api.thecarapi.com/api/contract
```

```bash
curl -H "X-API-Key: $API_KEY" "https://api.thecarapi.com/api/contract"
```

### Example response

```json
{
  "success": true,
  "version": "2026-08-19",
  "schemas": {
    "search_result_card": {
      "required": [],
      "optional": []
    }
  },
  "pagination": {
    "max_limit": 100,
    "max_offset": 5000
  },
  "live_prices": {
    "enabled": true,
    "sites": [
      "openlane",
      "ecarstrade"
    ],
    "ttl_seconds": 120
  }
}
```

### Response fields

| Field | Type | Meaning |
| --- | --- | --- |
| `version` | string | The contract version this deployment serves. Matches the contract_version on response bodies that carry one. This response itself carries no envelope metadata — version is the contract version. |
| `schemas` | object | Required and optional keys per response shape. This is the authoritative machine-readable schema; prefer it to hardcoding field lists. As of 2026-08-19, auction_detail.optional lists vault_gallery and brands_models_facets.optional lists errors — gate on those rather than comparing version strings. |
| `pagination.max_limit` | integer | The page-size ceiling. Clamp your own paging to it rather than discovering it by being clamped. |
| `pagination.max_offset` | integer | The offset cap on the capped endpoints (catalog, top-offers, theparking listings). Search and listVehicles have no offset cap. |
| `live_prices` | object | Whether this deployment reads a running auction’s bid when its detail is requested, which sources it covers, and how long a refreshed price stays current ({ enabled, sites, ttl_seconds }). The values are illustrative, not guaranteed — read them at startup rather than hardcoding them. The whole block is absent where the feature does not exist at all, and that absence is the only way to tell “live prices are unavailable” from “this car has none right now”, because both look identical in a detail response. |

**Notes.** Read this once at startup. It is the correct answer to “does this deployment support live prices, and for which sources” — a missing live_price block on a detail response is not an error and never distinguishes the two cases on its own. This response carries no envelope metadata — its own version field is the contract version.

## GET /

Return the human-readable API index and service version.

**Scope:** `ops`

### Example requests

```http
GET https://api.thecarapi.com/
```

```bash
curl -H "X-API-Key: $API_KEY" "https://api.thecarapi.com/"
```

### Example response

```json
{
  "service": "Car Details API",
  "version": "2.0.0",
  "endpoints": {
    "GET /api/health": "Health check endpoint"
  },
  "typesense": {
    "enabled": true,
    "running": true
  }
}
```

**Notes.** A smoke-test target, not a discovery document — this reference and GET /api/contract are what describe the surface.

---

# Code examples

Minimal clients in cURL, TypeScript, Python and PHP, plus recipes for pagination, conditional requests and detail pages.

There is no required SDK. Any HTTP client that can send headers and parse JSON will work. The examples below are complete — paste one, set `THECARAPI_KEY`, and it runs.

## cURL

```bash
export THECARAPI_KEY="your_key"

curl -sS --compressed \
  -H "X-API-Key: $THECARAPI_KEY" \
  "https://api.thecarapi.com/api/search?brand=bmw&fuel=Diesel&year_from=2018&sort=price_low&limit=24" \
   | jq '.results[] | {auction_id_str, site_name, clean_make, clean_model, public_price_eur}'
```

## TypeScript

```typescript
const BASE = "https://api.thecarapi.com";

interface SearchResponse {
  success: boolean;
  results: SearchCard[];
  total: number | null;
  limit: number;
  offset: number;
  max_page: number | null;
  total_pages: number | null;
  contract_version: string;
  request_id: string;
}

interface SearchCard {
  auction_id: number;
  auction_id_str: string;
  site_name: string;
  clean_make: string;
  clean_model: string;
  registration_year: number;
  mileage: number;
  /** Prices are JSON numbers, never strings. */
  public_price_eur: number;
  thumbnail_url: string | null;
}

async function search(params: Record<string, string | number>): Promise<SearchResponse> {
  const url = `${BASE}/api/search?${new URLSearchParams(
    Object.entries(params).map(([k, v]) => [k, String(v)]),
  )}`;

  const res = await fetch(url, {
    headers: {
      "X-API-Key": process.env.THECARAPI_KEY!,
      "Accept-Encoding": "gzip",
    },
  });

  if (!res.ok) {
    throw new Error(`${res.status} — request ${res.headers.get("X-Request-ID")}`);
  }
  return res.json();
}

const page = await search({ brand: "bmw", fuel: "Diesel", limit: 24 });
console.log(page.results.length, "of", page.total);
```

## Python

```python
import os
import requests

BASE = "https://api.thecarapi.com"

session = requests.Session()
session.headers.update({"X-API-Key": os.environ["THECARAPI_KEY"]})
# requests negotiates gzip and decompresses transparently.

response = session.get(
    f"{BASE}/api/search",
    params={"brand": "bmw", "fuel": "Diesel", "year_from": 2018, "limit": 24},
    timeout=30,
)
response.raise_for_status()
payload = response.json()

for car in payload["results"]:
    print(car["site_name"], car["auction_id_str"], car["public_price_eur"])
```

## PHP

```php
<?php
$client = new GuzzleHttp\Client([
    'base_uri'       => 'https://api.thecarapi.com',
    'headers'        => ['X-API-Key' => getenv('THECARAPI_KEY')],
    'decode_content' => 'gzip',   // Guzzle needs this told to it explicitly
    'timeout'        => 30,
]);

$response = $client->get('/api/search', [
    'query' => ['brand' => 'bmw', 'fuel' => 'Diesel', 'limit' => 24],
]);

$payload = json_decode((string) $response->getBody(), true);
foreach ($payload['results'] as $car) {
    printf("%s/%s — €%s\n", $car['site_name'], $car['auction_id_str'], $car['public_price_eur']);
}
```

## Recipe: page through a result set

```python
def iter_results(session, **filters):
    """Yield every card, respecting the depth policy rather than guessing at it."""
    offset, limit = 0, 100
    while True:
        r = session.get(f"{BASE}/api/search",
                        params={**filters, "limit": limit, "offset": offset,
                                "include_total": "false"},
                        timeout=30)
        r.raise_for_status()
        rows = r.json()["results"]
        if not rows:
            return
        yield from rows
        offset += limit
        # /api/search and /api/listVehicles have no offset cap. Catalog,
        # top-offers and theparking listings stop at 5000 and a page too deep
        # answers 400. Read pagination from /api/contract.
```

_`include_total=false` skips the count, which is the expensive half of a deep search._

## Recipe: poll a search page without re-downloading it

```javascript
let etag = null;
let cached = null;

async function poll(url) {
  const res = await fetch(url, {
    headers: {
      "X-API-Key": process.env.THECARAPI_KEY,
      ...(etag ? { "If-None-Match": etag } : {}),
    },
  });

  if (res.status === 304) return cached;   // nothing changed, no body transferred

  etag = res.headers.get("ETag");          // opaque — echo it back exactly
  cached = await res.json();
  return cached;
}
```

## Recipe: a vehicle detail page in one request

```javascript
const { auction } = await getJson(`/api/auction/${site}/${id}`);

const photos = auction.vault_gallery?.images ?? [];
const stillComing = auction.vault_gallery?.pending ?? 0;   // render placeholders

const price = auction.public_price_eur;                     // already live if it could be
const asOf = auction.live_price
  ? new Date(auction.live_price.fetched_at * 1000)          // seconds → ms
  : null;

// Only if the refresh missed the request budget, read exactly once more.
if (!auction.live_price && auction.live_price_pending) {
  setTimeout(() => refetch(site, id), 2000);
}
```

_`vault_gallery` is the same body `/api/auction-images/{site}/{id}` returns, so the second request is unnecessary._

---

# Recipes

Six end-to-end flows that cover most of what the API is for. Each is a complete sequence, not a fragment.

Set `API=https://api.thecarapi.com` and `KEY=your_key` first.

## 1. Build a filter sidebar in one request

Six dimensions, one round trip, one unit of quota:

```bash
curl -sS -H "X-API-Key: $KEY" --compressed \
  "$API/api/facets?fields=brands,years,fuels,gearboxes,countries,sites"
```

Then narrow it as the user picks. Facets **cross-filter**: pass the filters already chosen and every remaining dimension re-counts against them, while each dimension still ignores its own filter so the user can change their mind.

```bash
# After "BMW, under EUR 15,000": what fuels and years are left?
curl -sS -H "X-API-Key: $KEY" \
  "$API/api/facets?fields=fuels,years&brand=bmw&price_to=15000"
```

Models are per-brand and stay on their own route:

```bash
curl -sS -H "X-API-Key: $KEY" "$API/api/models?brand=bmw&ordering=-count&limit=25"
```

Cache the sidebar. Facets are served `max-age=600` with an `ETag`; store it and send `If-None-Match` and the refresh costs an empty `304`.

## 2. Search, page it, and keep the totals honest

```bash
curl -sS -H "X-API-Key: $KEY" --compressed \
  "$API/api/search?brand=bmw&fuel=Diesel&year_from=2018&sort=price_low&limit=50&page=1"
```

- **Paging.** Pick one spelling and stay with it — `page`+`page_size`, or `offset`+`limit`. Mixing them is a `400`. Loop to `total_pages`; a filtered search's `total` and its rows now agree, so you will not run into short pages.
- **Counting is the expensive half.** If you are streaming rather than showing a page count, send `include_total=false` and skip it entirely. If you want only the number, send `count_only=true` and get no rows.
- **Persist `site_name` + `auction_id_str`**, never `auction_id` on its own — japanauction ids exceed 2^53.

```bash
# "How many diesel BMWs are there?" — one cheap call, no rows.
curl -sS -H "X-API-Key: $KEY" \
  "$API/api/search?brand=bmw&fuel=Diesel&count_only=true" | jq .total
```

## 3. Open one car: detail, live bid, photos

One request gets specification, condition and gallery together:

```bash
curl -sS -D headers.txt -H "X-API-Key: $KEY" --compressed \
  "$API/api/auction/openlane/11409652" -o car.json
```

Then branch on three things, in this order:

```text
live_price          present  -> that IS the current bid; render it
live_price_pending  present  -> read once more after ~2s, then stop
neither                      -> the cycle price is the price; do not retry

is_blind: true      -> there will never be a price. Show estimated_value_eur,
                       labelled as the auction house's estimate.
details_pending: t  -> vehicle_details is not there yet. Re-read on the short
                       max-age the response carries; render the rest meanwhile.
vault_gallery.pending > 0 -> more photos are coming. Poll on max-age=10.
```

Photo URLs are paths, so join them to the base:

```bash
jq -r '.auction.vault_gallery.images[] | .served_url // .remote_url' car.json \
  | sed "s|^/|$API/|"
```

You do not need `/api/auction-images` as well — `vault_gallery` is that same body embedded. Use the separate route only when you want the gallery alone.

## 4. Shop the discounts, then price the import

Top offers already carry the market reference the verdict was made against:

```bash
curl -sS -H "X-API-Key: $KEY" \
  "$API/api/top-offers?site=openlane&min_savings_pct=20&sort=savings&limit=10"
```

Take one and cost it landed. Read `is_margin` off the detail response, and send the source so the fee model matches the one that priced the listing:

```bash
curl -sS -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"price":9000,"site_name":"openlane","origin":"DE","destination":"BG"}' \
  "$API/api/calculator/calculate" | jq .breakdown.estimated_total
```

Compare that landed total with the retail market, not with the lot price:

```bash
curl -sS -H "X-API-Key: $KEY" \
  "$API/api/cars-bg-market?brand=BMW&model=320d&year=2019&flex=1" \
  | jq '{n: .snapshot.listing_count, median: .snapshot.median_price_eur}'
```

Check `listing_count` before you trust the median. Market snapshots are not enabled on a new key by default — ask for them.

## 5. Track one car over time

Price movements for a lot you are watching:

```bash
curl -sS -H "X-API-Key: $KEY" \
  "$API/api/auction/openlane/11409652/price-history" \
  | jq '.history[] | {observed_at, current_price, changed_fields}'
```

And its life before this listing, by VIN — this is what shows a car that has been through auction more than once:

```bash
curl -sS -H "X-API-Key: $KEY" \
  "$API/api/vin/WBA8E9G50GNU12345/history" \
  | jq '.auctions[] | {site_name, last_seen_at, mileage, public_price_eur, archived}'
```

VIN history is not enabled on a new key by default — ask for it. `event_type` on price history is `initial`, `baseline` or `change` — there is no `price_change` value.

## 6. Mirror the whole feed, cheaply

To keep a local copy in sync rather than to answer a query:

```bash
# Unfiltered = no depth limit. Walk it at the maximum page size.
curl -sS -H "X-API-Key: $KEY" -H "Accept-Encoding: gzip" --compressed \
  "$API/api/search?limit=100&offset=0&include_total=false"
```

Four things make this an order of magnitude cheaper:

1. `--compressed` — roughly an eighth of the bytes.
2. `include_total=false` — the count is the expensive half of a search.
3. Store each page's `ETag` and send `If-None-Match` on the re-walk; unchanged pages answer `304` with no body.
4. Do **not** expand every card. Hydrate detail only for rows whose `last_changed_at` moved, or that you actually display.

Search results are always cycle-priced, never live-priced. That is what makes them fast enough to page — see [what stays on cycle prices](https://thecarapi.com/docs/live-prices).

---

# Agents & machine formats

Every page of this reference as Markdown, plain text, JSON and OpenAPI — and how to point a coding agent at it.

This documentation is generated from structured data, so it can be served in whatever shape reads it. If you are integrating with a coding agent or an LLM, take one of the files below rather than parsing these HTML pages.

## Formats

| Format | URL | Use it for |
| --- | --- | --- |
| Markdown, one page | `https://thecarapi.com/docs/<section>.md` | Pasting a single section into a prompt or a repo. |
| Markdown, everything | `https://thecarapi.com/docs/thecarapi-api-reference.md` | The entire reference as one file, front matter included. |
| Plain text, everything | `https://thecarapi.com/docs/thecarapi-api-reference.txt` | Context windows and tools that choke on Markdown tables. |
| JSON | `https://thecarapi.com/docs/thecarapi-api-reference.json` | Programmatic access to sections, endpoints, parameters and response fields. |
| OpenAPI | `https://thecarapi.com/openapi.json` | Client generation, Postman/Insomnia import, agent tool definitions. The API’s published spec is OpenAPI 3.0.3; this generated file is 3.1 describing the same surface. |
| Postman collection | `https://thecarapi.com/thecarapi.postman_collection.json` | Import straight into Postman — every endpoint, grouped and described. |
| `llms.txt` | `https://thecarapi.com/llms.txt` | The site index in the llms.txt convention. |
| Docs index for agents | `https://thecarapi.com/docs/llms.txt` | Just the documentation tree, with a one-line summary per section. |

Every docs page also advertises its Markdown twin in the head as `<link rel="alternate" type="text/markdown">`, and the **Copy** and **Download** controls at the top of each page produce exactly these files client-side.

## Fetching them

```bash
# One section
curl -sS https://thecarapi.com/docs/live-prices.md

# The whole reference, as Markdown
curl -sS https://thecarapi.com/docs/thecarapi-api-reference.md

# The endpoint surface, as OpenAPI
curl -sS https://thecarapi.com/openapi.json | jq '.paths | keys'
```

_These are static files. No API key is needed to read the documentation._

## Giving an agent the right context

The full reference is large. For most tasks an agent needs three things: the conventions, the data dictionary, and the one endpoint group it is calling. Fetching those three sections is cheaper and more accurate than dropping the whole document into a context window.

```text
You are integrating with TheCarApi.

Read these first:
  https://thecarapi.com/docs/conventions.md     — envelope, ETags, pagination, rate limits
  https://thecarapi.com/docs/errors.md          — status codes and what is worth retrying
  https://thecarapi.com/docs/recipes.md         — six complete flows, end to end
  https://thecarapi.com/docs/<group>.md         — the endpoint group you are calling
  https://thecarapi.com/openapi.json            — machine-readable parameter and response shapes

Rules that are not obvious from the schema:
  - A vehicle is addressed by site_name + auction_id, e.g. encar/38112900.
  - Prices are JSON numbers, never strings.
  - An absent live_price block is normal, not an error.
  - Never hardcode the source list; read /api/sites and /api/contract at startup.
  - Retry 429/503 honouring Retry-After. Never retry 400/401/403/404.
```

_A starting prompt. Swap `<group>` for `search`, `auctions`, `car-details` and so on._

## Crawling policy

`robots.txt` allows search and AI citation crawlers and disallows bulk training harvesters. The documentation is public and unauthenticated; the API behind it is not. Nothing in these files is a credential, and no endpoint here will answer without a key.

> **Generated, not hand-maintained** — Every format on this page is emitted from the same source at build time, so they cannot drift from each other or from the HTML. If a section changes, all seven change with it.
