For developers
Vehicle Analytics API
The Vehicle Analytics API gives you the same market intelligence the CarRadar analytics console shows, for vehicles you describe in JSON. You send a stock list, we match each car to comparable listings across a whole market and return price bands, how the car sits against those comparables, how fast the group sells, margin potential, a price trend and the comparable listings themselves.
It is built for dealers, fleet buyers and marketplaces who want these figures inside their own systems instead of on a screen.
Getting a key
API keys are issued by hand, one per customer. Write to [email protected] with what you plan to price, roughly how many vehicles per month, and which markets. We reply with a key, your per-request price, and a webhook secret if you want one.
You do not need a paid CarRadar plan to use the API. API access is priced on its own, and a free account is enough to hold a key. If you already have an Analyst or Fleet plan, your key can be issued with the extra data those plans include.
Once your key is live, your monthly usage also appears in Account > Wallet.
Base URL and authentication
https://carradar.app/api/v1
Every request carries the key as a bearer token. There are no other credentials, no signing and no session.
Authorization: Bearer crk_live_...
How it works
Markets, not marketplaces
You price against a country, and the country's whole listing pool is used. There is deliberately no option to pick a single marketplace: a dealer competes against every listing a buyer can see, not against one site's slice of it, so a single-site median would describe a market you do not actually sell into. Pooling also handles the same physical car being advertised in several places.
| market | covers |
|---|---|
RS | Serbia |
BA | Bosnia and Herzegovina |
HR | Croatia |
SI | Slovenia |
DE | Germany |
NLBE | Netherlands and Belgium |
Groups
Each vehicle you send is resolved to a group: the set of comparable listings it is measured against. Vehicles that resolve to the same group are computed once, which is why a batch of twenty near-identical cars is fast. The group we used is echoed back on every result, so you can always see what a number was measured against.
Mileage
Mileage is compared against a range around the odometer you send. The range we used is returned on every result as mileageMin and mileageMax, so you can always see what a figure was measured against.
Send mileage: 0 only when the car really has no kilometres on it, and leave the field out when you do not know the odometer. Those are different statements and we treat them differently: an omitted odometer applies no mileage filter at all. If you want to control the range yourself, send mileageMin and mileageMax, which are honoured exactly.
Whenever you filter by mileage, listings whose odometer is not recorded are left out of the comparison. Some marketplaces publish a zero instead of admitting the figure is missing, and counting those as low-mileage cars would distort the result.
Year banding
Send the car's own year and we resolve the comparison window from the data: that year alone when it holds enough listings, otherwise one year either side.
Widening stops at a generation change. If the neighbouring year's prices sit far enough below to indicate a different generation rather than ordinary depreciation, we refuse to widen into it and tell you so with discontinuityBlocked: true. Without that guard a facelift year drags the median down and every figure built on it is quietly wrong.
If you would rather control the window yourself, send yearFrom and yearTo instead. An explicit range is honoured exactly, with no widening.
Engines
Displacement is matched tolerantly. Marketplaces record the same engine in incompatible ways, so 1968, 1995, 2000 and 2.0 all resolve to the same comparison. Send whatever your own system holds.
Billing
One vehicle in the payload is one request. That holds however the answer is delivered and whatever it resolves to.
- Repeats count. If two cars in the same batch land in the same group, or you send the same car again next week, each one is still a request. You get the same answer, so the price is the same.
- Reading a job is free, and so is checking usage. You are charged for computing an answer, not for collecting one.
- A request that fails validation is not charged, because nothing was computed.
Look up a few vehicles
The direct route, for up to three vehicles. When the figures are already computed it answers in about 150 ms with the full result.
curl -X POST https://carradar.app/api/v1/vehicle-api/vehicles:lookup \
-H "Authorization: Bearer crk_live_..." \
-H "Content-Type: application/json" \
-d '{
"market": "RS",
"vehicles": [
{
"ref": "stock-1",
"brand": "Volkswagen",
"family": "Golf",
"generation": "Golf 7",
"year": 2016,
"mileage": 145000,
"engine": "1968",
"fuel": "diesel"
}
]
}'{
"status": "done",
"servedInline": true,
"vehicles": 1,
"results": [
{
"ref": "stock-1",
"cached": false,
"group": {
"country": "RS",
"brand": "Volkswagen",
"family": "Golf",
"generation": "Golf 7",
"yearFrom": 2014,
"yearTo": 2016,
"mileageMin": 125000,
"mileageMax": 175000,
"engine": "1968",
"fuel": "diesel"
},
"yearBand": {
"from": 2014, "to": 2016, "n": 11,
"widened": true, "discontinuityBlocked": true
},
"analytics": { /* the nine blocks, shown in full further down */ }
}
]
}When the figures are not cached, this route does not make you wait. It falls back to a queued job and answers immediately with a job id, still 200. Check servedInline rather than the status code, and collect the result from the job endpoint a few seconds later.
{
"jobId": "4c9d132e-1de9-4f7c-99e2-f3106b873c68",
"vehicles": 1,
"groups": 1,
"status": "queued",
"estimatedSeconds": 2,
"servedInline": false,
"reason": "cold"
}Submit a batch
Up to 50 vehicles per call. Returns 202 Accepted with a job id straight away; the work runs in the background. A 200-car inventory is four calls.
estimatedSeconds is an upper bound based on how many distinct groups your batch collapsed to, so it is usually pessimistic. Note the example: three vehicles, two groups, because the two X5s are close enough to be measured against the same comparables. All three are still billed.
curl -X POST https://carradar.app/api/v1/vehicle-api/jobs \
-H "Authorization: Bearer crk_live_..." \
-H "Content-Type: application/json" \
-d '{
"market": "HR",
"webhookUrl": "https://your-server.example/carradar",
"vehicles": [
{ "ref": "A-101", "brand": "BMW", "family": "X5",
"year": 2018, "mileage": 160000, "engine": "2.0",
"fuel": "diesel", "transmission": "automatic" },
{ "ref": "A-102", "brand": "BMW", "family": "X5",
"year": 2018, "mileage": 168000, "engine": "2.0",
"fuel": "diesel", "transmission": "automatic" },
{ "ref": "A-103", "brand": "Volkswagen", "family": "Passat",
"year": 2017, "mileage": 210000, "keyword": "highline -dsg" }
]
}'HTTP/1.1 202 Accepted
{
"jobId": "7f1c0a2e-5d31-4a6e-9c0b-2b41d7e5a913",
"vehicles": 3,
"groups": 2,
"status": "queued",
"estimatedSeconds": 4
}Read a job
Returns whatever is finished so far, so a job that is half done is still useful. status moves through queued, running, then done or failed, and while it is not finished the response carries a Retry-After header telling you when to come back.
curl https://carradar.app/api/v1/vehicle-api/jobs/7f1c0a2e-5d31-4a6e-9c0b-2b41d7e5a913 \ -H "Authorization: Bearer crk_live_..."
{
"jobId": "7f1c0a2e-5d31-4a6e-9c0b-2b41d7e5a913",
"status": "running",
"groups": 2,
"groupsDone": 1,
"vehicles": 3,
"results": [
{ "ref": "A-101", "cached": false, "group": { /* ... */ },
"yearBand": { "from": 2017, "to": 2018, "n": 24 },
"analytics": { /* ... */ } },
{ "ref": "A-102", "cached": true, "group": { /* ... */ },
"yearBand": { "from": 2017, "to": 2018, "n": 24 },
"analytics": { /* ... */ } }
]
}
// A-102 shares a group with A-101, so it is marked cached and reuses the
// same computation. Both are still billed. A-103 is in the group that has
// not finished yet, which is why it is absent from results so far.Results are keyed by your ref, never by array order, so you can join them to your own records as they arrive. Results stay readable for 7 days.
Check your usage
Defaults to the current calendar month. deduped counts the requests that reused a group another vehicle had already computed: they are still charged, and this is where you can see how many there were.
{
"month": "2026-08",
"plan": "monthly",
"calls": 7,
"deduped": 0,
"billedCents": 70,
"pricePerCallCents": 10,
"limit": 5000,
"remaining": 4993,
"resetsAt": "2026-09-01T00:00:00.000Z"
}On a monthly plan, limit and remaining are filled in and requests past the limit are refused. On pay as you go both are null and there is no ceiling.
The vehicle object
Only ref, brand and family are required. Everything else narrows the comparison; the more you send, the tighter and more useful the answer.
| field | type | meaning |
|---|---|---|
ref | string | Your own identifier, echoed back untouched. Must be unique within one call. |
brand | string | Manufacturer, e.g. "Volkswagen". |
family | string | Model family, e.g. "Golf". Not a trim or engine code. |
generation | string | Optional, e.g. "Golf 7". Narrows the comparison sharply when you know it. |
year | integer | The car's own year. The window is resolved from data, guarded against generation changes. |
yearFrom | integer | Explicit window, honoured exactly. Overrides year, no widening. |
yearTo | integer | The other end of the explicit window. |
mileage | integer | Odometer in km, compared against a range around this figure. Send 0 only for a car with no kilometres on it; omit the field when the odometer is unknown. |
mileageMin | integer | Explicit mileage window, honoured exactly. Overrides the band from mileage. |
mileageMax | integer | The other end of the explicit window. |
engine | string | Displacement in cc ("1968") or litres ("2.0"). Matched tolerantly. |
fuel | string | "diesel", "petrol", "hybrid", "electric". |
transmission | string | "manual" or "automatic". |
keyword | string | Free text matched against the listing title and engine. Every token must match; a leading "-" excludes. Example: "amg" narrows a C-Class to AMG cars, "-amg" gives you everything else, and "tdi -dsg" is every TDI that is not a DSG. |
trendWindow | string | "3m", "6m", "1y" or "max". Defaults to 6m and is clamped to the history we actually hold. |
includeLowSample | boolean | Include thin trend buckets, each flagged lowSample. Off by default. |
The keyword field is the same grammar the analytics console uses, so anything you can filter to on screen you can filter to here.
What comes back
Each result carries nine blocks. Values that used to repeat across blocks live once in group.
"analytics": {
"group": { "n": 11, "activeN": 5, "priceWindowDays": 30, "thinSample": false },
"overview": { "median": 11500, "p10": 11000, "p25": 11275, "p75": 12800, "p90": 13500,
"dealsN": 2, "dealsPct": 40, "velocityDays": 28, "velocityTier": "moderate",
"hpsAvg": 25.8, "confidence": 78, "opportunityScore": 38,
"decision": { "action": "AVOID", "titleText": "Avoid",
"summaryText": "This group is priced tightly and moving slowly...",
"strategyText": "Look for a different group, or wait..." },
"bestPocket": { "yearFrom": 2014, "yearTo": 2015,
"mileageBand": { "min": 150000, "max": 175000 } } },
"market": { "bands": { "p10": 11000, "p50": 11500, "p90": 13500 },
"depth": { "supply": 5, "freshPct": 0, "stalePct": 40, "status": "undersupplied" },
"liquidity": { "score": 62, "grade": "moderate", "estimated": true },
"depreciation": [ { "year": 2016, "median": 12500, "count": 2 } ] },
"opportunity": { "yearBand": { "from": 2014, "to": 2016, "widened": false },
"density": { "outstanding": 0, "strong": 2, "normal": 1, "overpriced": 2 },
"scatter": { "points": [ { "mileage": 175000, "price": 11000, "year": 2015 } ] },
"steals": [ /* the standout buys, at most 5 */ ] },
"timing": { "hps": { "avg": 25.8, "distribution": [40, 40, 20, 0, 0],
"highPressurePct": 20, "coverageN": 5 },
"decay": [ { "bucket": "15-30", "retentionPct": 98.3, "count": 3 } ] },
"profit": { "velocityDays": 28, "turnsPerMonth": 1.1,
"flip": { "buyPrice": 11275, "sellPrice": 11500,
"marginGrossPct": 2, "marginNetPct": -4.8,
"roiPct": -4.8, "estNetProfitEur": -545 } },
"fleet": { "readiness": { "score": 54, "verdict": "conditional",
"components": { "supply": 9, "priceIqr": 73,
"yearSpan": 100, "liquidity": 71 } },
"window": { "signal": "neutral", "hpsAvg": 25.8, "stabilityScore": 75 },
"costSim": { "unitPrice": 11275, "medianPrice": 11500,
"negotiationPerVehicleEur": 200, "allowedCounts": [5] },
"uniformity": {}, "engineConfig": {} },
"trend": { "window": "6m", "bucket": "week", "availableWindows": ["3m", "6m"],
"historyStart": "2026-02-25",
"points": [ { "t": "2026-03-09", "median": 11999, "p25": 10625, "p75": 13700,
"n": 11, "dropN": 4, "exitN": 1, "ageDays": 15 } ] },
"marketPower": { "level": "competitive", "comparableListings": 11,
"statsAvailable": true,
"text": "11 comparable listings. Enough supply for the statistics to be dependable." }
}| block | answers |
|---|---|
group | How many comparables, how many still active, over what window. |
overview | The headline. Price percentiles, how many are underpriced, how fast it moves, and a plain verdict with a suggested strategy. |
market | Price bands, whether the market is over or undersupplied, how liquid it is, and a depreciation curve by year. |
opportunity | Where this car sits against the comparables: a price-vs-mileage scatter, how many are underpriced or overpriced, and the standout buys. |
timing | Selling pressure. The High Pressure Score distribution and how listings survive by age bucket. |
profit | Margin. Buy price, sell price, gross and net margin, ROI and estimated profit on a flip. |
fleet | Whether the group can absorb a multi-car purchase: readiness score, stability, and a cost simulation. |
trend | Price over time, with how many listings dropped price and how many left the market in each bucket. |
marketPower | How contested the group is, and whether the statistics are dependable. |
Prices are in EUR. hps is the High Pressure Score, our measure of how hard a listing is being pushed to sell.
Thin markets are an answer, not an error
When a group has very few comparables we still return everything we have, flagged rather than withheld. A market with almost nothing in it is not missing data: it is pricing power, and it is often the most valuable thing the API can tell you.
marketPower.level is one of uncontested (nothing comparable is listed at all), monopoly (too few for a meaningful median), thin (statistics are returned but orientational) or competitive. statsAvailable tells you whether the percentiles are worth leaning on, and text is a ready sentence you can show to a user. When the statistics cannot carry the answer, we add the comparable listings themselves so you can look at the actual cars.
"marketPower": {
"level": "monopoly",
"comparableListings": 2,
"statsAvailable": false,
"text": "Only 2 comparable listings in this market. Too few for a meaningful median, which is itself the signal: you are close to setting the price rather than meeting it."
},
"listings": [
{ "siteId": "29480028", "source": "pa", "title": "BMW X5 3.0d",
"url": "https://...", "price": 21500, "mileage": 180000,
"year": 2016, "hps": 51, "daysListed": 12 }
]Webhooks
Send webhookUrl with a job and we push results to it instead of making you poll. You get one call per group as it finishes and one when the job is done. A partial carries only that group's vehicles, not the whole accumulated result.
POST https://your-server.example/carradar
Content-Type: application/json
X-CarRadar-Job: 7f1c0a2e-5d31-4a6e-9c0b-2b41d7e5a913
X-CarRadar-Signature: sha256=9f2b1c...
{
"jobId": "7f1c0a2e-5d31-4a6e-9c0b-2b41d7e5a913",
"event": "group.completed",
"status": "running",
"vehicles": 3,
"groups": 2,
"groupsDone": 1,
"results": [ { "ref": "A-101", "cached": false, "analytics": { /* ... */ } } ]
}The URL must be https and must resolve to a public address. Every delivery is signed with the webhook secret issued alongside your key, as an HMAC-SHA256 of the exact bytes we sent. Verify it against the raw body, before any JSON parsing, or re-serialisation will change the bytes and the signature will never match.
import crypto from "node:crypto";
app.post("/carradar", express.raw({ type: "application/json" }), (req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.CARRADAR_WEBHOOK_SECRET)
.update(req.body) // the RAW bytes, not the parsed object
.digest("hex");
const got = req.get("X-CarRadar-Signature") ?? "";
const ok =
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // answer fast, process after
});Answer with any 2xx. A non-2xx is retried with exponential backoff. A webhook that is slow or down never delays your analytics: delivery runs on its own queue, and the results are always readable from the job endpoint regardless.
Claude and other AI assistants (MCP)
The same backend speaks the Model Context Protocol, so you can connect it to Claude, ChatGPT, Gemini or any other MCP client and ask about your stock in plain language instead of writing code. The assistant calls the same endpoints with the same key and the same billing.
https://carradar.app/api/v1/vehicle-api/mcp
There are two ways to authenticate, and which one you use is decided by the client, not by us. A client that lets you set a request header sends your API key. A client that does not, which includes every assistant that runs in a browser, signs in through OAuth instead. Both end up on the same key, the same limits and the same ledger.
Claude in the browser
Open Settings, then Connectors, then Add custom connector, and paste the address above. Leave the OAuth fields empty: Claude registers itself automatically. Claude will send you to CarRadar to sign in, then show you what the connector is asking for and which API key it will bill. Approve it and the tools appear in your next conversation. Nothing is charged for connecting.
If your organisation prefers one fixed OAuth client rather than a new registration per connection, open Advanced settings in the same dialog and paste a client ID and client secret. Ask us for a pair and we will issue one scoped to your organisation. This is optional: without it, automatic registration is used and everything works the same.
Claude Code and desktop clients
These let you set an Authorization header, so they take the API key directly and skip the sign-in entirely.
claude mcp add --transport http carradar \ https://carradar.app/api/v1/vehicle-api/mcp \ --header "Authorization: Bearer crk_live_..."
{
"mcpServers": {
"carradar": {
"type": "http",
"url": "https://carradar.app/api/v1/vehicle-api/mcp",
"headers": { "Authorization": "Bearer crk_live_..." }
}
}
}To check it worked, ask the assistant what it has used this month. If it answers with a usage figure, the key is being sent correctly.
ChatGPT, Gemini and everything else
Any client that implements the MCP authorization specification connects with nothing but the address above: it discovers the rest, registers itself and runs the sign-in. If a client asks you to fill in the OAuth details by hand, these are the values it wants.
server url https://carradar.app/api/v1/vehicle-api/mcp authorization url https://carradar.app/oauth/authorize token url https://carradar.app/api/v1/oauth/token registration url https://carradar.app/api/v1/oauth/register revocation url https://carradar.app/api/v1/oauth/revoke issuer https://carradar.app scope vehicle-api pkce S256, required client authentication none, client_secret_post or client_secret_basic
A client that cannot register itself needs a client ID, and a confidential client needs a secret with it. Ask for either at [email protected] and say which assistant it is for, so the callback address is registered correctly. Native clients that listen on a loopback address, such as Claude Code, are matched without regard to the port they happen to bind.
What the assistant can do
| tool | does | cost |
|---|---|---|
lookup_vehicle | Full analytics for up to 3 vehicles. Answers straight away when cached. | 1 per vehicle |
submit_vehicle_batch | Queues up to 50 vehicles. | 1 per vehicle |
get_vehicle_job | Collects a batch, including partial results. | free |
get_api_usage | Reports spend and what is left this month. | free |
In practice you can ask things like "what is a 2016 Golf 7 2.0 TDI with 145.000 km worth in Serbia, and is it moving?" and the assistant will fetch the figures and read them back to you.
The endpoint speaks Streamable HTTP and supports protocol revisions from 2025-03-26 through 2026-07-28, so any current MCP client works, not only Claude.
Disconnecting
Remove the connector in the assistant and it revokes its own access. Revoking the API key also ends every connection made against it, so if a key is ever exposed, revoking it is enough on its own.
If a connector will not connect
Assistants report almost every failure as one of two sentences, so the message rarely says which step broke. These three requests do, and none of them needs a credential. Run them in order and stop at the first that does not answer as described.
# 1. the endpoint answers, and says how to authenticate curl -i -X POST https://carradar.app/api/v1/vehicle-api/mcp # expect 401 with a WWW-Authenticate header naming resource_metadata # 2. the resource metadata that header points at curl -i https://carradar.app/.well-known/oauth-protected-resource/api/v1/vehicle-api/mcp # 3. the authorization server it names curl -i https://carradar.app/.well-known/oauth-authorization-server
- Told to sign in again immediately after signing in. The account has no active API key, or the key behind the connection was revoked. Reconnecting will not fix it; ask us for a key.
- The address was typed by hand. Use exactly the address above, with no trailing slash and no
www. A connector URL that redirects loses the credential on the way. - Everything above answers correctly and it still fails. Send us the reference id the assistant shows, which for Claude starts with
ofid_, and the time you tried, and we will match it against our own logs.
Limits and errors
| limit | value | why |
|---|---|---|
| Vehicles per batch | 50 | The only cap you have to think about. |
| Vehicles per inline lookup | 3 | Above this, use a batch. |
| Batch submissions | 30 per minute | Per key. |
| Inline lookups | 60 per minute | Per key. |
| Job reads | 240 per minute | Per key. Free. |
| Queued work | 100 groups | Your own backlog ceiling. Submit again once it drains. |
| Result retention | 7 days | After that a job id returns 404. |
Rate limits are counted per key, not per IP, so several servers behind one address do not eat each other's allowance.
HTTP/1.1 429 Too Many Requests
{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests"
}| status | meaning |
|---|---|
| 400 | The payload is malformed. The message names the field. |
| 401 | Missing, unknown or revoked key. |
| 402 | Monthly plan exhausted. Usage tells you when it resets. |
| 404 | No such job, or a job belonging to another key. |
| 413 | Too many vehicles in one call. |
| 429 | Rate limited, or your own backlog is full. The message says which. Back off and retry. |
There is a short nightly window (roughly 04:45 to 05:30 CET) when our data is rebuilt and new work waits rather than starting. Jobs submitted then are accepted normally and run once it passes, so you will see a longer wait rather than an error.
Using the data
The analytics are yours to use inside your business: pricing your stock, valuing trade-ins, building your own dashboards and reports.
What you may not do is rebuild the underlying listing database or resell the raw data as a competing product. Listing counts in every response are capped for exactly this reason. The full wording is in the API section of our Terms of Service, and how we handle data sent to the API is in our Privacy Policy.
The vehicle descriptions you send are not personal data as long as you keep them that way. Do not put customer names, phone numbers, plates or VINs tied to an owner in the ref field: use your own stock number instead.