API documentation
One endpoint that matters: send a vehicle, get back a market value estimate and the realized comparable sales it was built from. Base URL https://api.residualauto.com.
Machine-readable spec: the full OpenAPI 3 schema lives at api.residualauto.com/openapi.json (point your client generator or Postman at it), and interactive try-it-out docs at api.residualauto.com/docs.
Authentication
Pass your key in the X-API-Key header on every request. Create a free account on the pricing page, then find or issue keys under For developers on your account page. Keys are bound to your account, rate-limited per minute, and metered against a monthly quota: one successful call to /v1/valuations is one valuation. Your monthly count is of distinct vehicles, so re-running the same VIN in the same month doesn't add to it. A missing or revoked key returns 401; an exhausted quota or rate limit returns 429.
A response carries a content_tier. On a paid plan it is "report" and the payload is complete. On a free tier it is "estimate": comps comes back empty and content_note says so. The estimate, the calibrated range, n_comparables and comp_window are identical at every tier — the number never depends on what you pay.
Create a valuation
POST /v1/valuations
curl -s https://api.residualauto.com/v1/valuations \
-H "X-API-Key: $RESIDUAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"vin": "4T1B11HK5KU211111",
"odometer": 78421,
"primary_damage": "Front End",
"title_group": "SALVAGE TITLE",
"state": "TX",
"acv_estimate": 14200,
"repair_cost": 9800,
"condition_grade": "Run & Drive"
}' Or the same call from Python (no SDK needed):
import requests
r = requests.post(
"https://api.residualauto.com/v1/valuations",
headers={"X-API-Key": API_KEY},
json={
"vin": "4T1B11HK5KU211111",
"odometer": 78421,
"primary_damage": "Front End",
"title_group": "SALVAGE TITLE",
"acv_estimate": 14200,
"repair_cost": 9800,
},
timeout=30,
)
r.raise_for_status()
val = r.json()
print(val["estimate"], val["input_tier"], len(val["comps"])) Identity: VIN or year/make/model
Every request needs enough identity to value against: either a vin (decoded server-side via the federal vPIC database to fill year, make, model, and engine specs) or explicit year + make + model. Fields you pass explicitly always win over VIN-decoded values. Requests without either return 422.
Basis
Estimates are quoted on a Copart basis: the auction house is asserted server-side, not taken as an input, so every response answers the same question — what this vehicle realizes at Copart salvage auction. The number is not venue-neutral and is not a quote for another auction platform. Every response carries that basis in the basis field, alongside the comparable count and sale-date window it was measured over.
Input tiers — completeness drives accuracy
The model tolerates missing fields, but its measured error depends heavily on what you send. Every response declares which tier the request landed in (input_tier) so downstream systems can weight the estimate appropriately.
| Tier | What you send |
|---|---|
| listing-grade | Identity + damage/title + any of: ACV or repair estimate, condition grade, auction branch |
| identity-only | VIN or year/make/model, little else |
Queries that carry listing context measure materially lower error than identity-only queries. The practical rule: if the vehicle is or was listed at auction, copy everything visible on the listing page into the request. If you're an insurer valuing a fresh total loss, your own ACV and repair estimate are the two highest-value fields.
We don't quote a per-tier error rate. Every response names the tier your query hit, so you can see which inputs it ran on.
Request fields
| Field | Type | Notes |
|---|---|---|
| vin | string | 11–17 chars; decoded server-side |
| year | int | Required if no VIN |
| make | string | Required if no VIN; case-insensitive |
| model | string | Required if no VIN |
| trim | string | |
| odometer | number | Miles |
| primary_damage | string | e.g. Front End, Side, Water/Flood — full list at GET /v1/form-vocabulary |
| secondary_damage | string | See GET /v1/form-vocabulary |
| title_group | string | SALVAGE TITLE, CLEAN TITLE, NON-REPAIRABLE. The short forms salvage/clean are accepted synonyms |
| state | string | Two-letter US state |
| source | string | Ignored — estimates are quoted on a Copart basis and the auction house is asserted server-side. Still accepted so existing callers do not break; see basis on the response |
| acv_estimate | number | Pre-loss actual cash value, USD |
| retail_estimate | number | The auction listing's estimated retail value, USD |
| repair_cost | number | Estimated repair cost, USD |
| auction_location | string | Auction yard/branch, e.g. TX - DALLAS |
| condition_grade | string | e.g. Run & Drive, Stationary — full list at GET /v1/form-vocabulary |
| has_keys | bool | JSON true/false |
| fuel_type, drive_type, transmission, color, engine, body_style, sale_type, insurance_value, engine_hp, displacement_l, engine_cylinders | — | Optional context; send if known |
Unknown fields are rejected (422) rather than ignored, so typos fail loudly.
Unknown values are not rejected: a categorical value we do not carry is matched case-insensitively and, failing that, passed through, so a spelling we have never seen weakens that one field rather than the call. GET /v1/form-vocabulary — no key needed — publishes the values the serving model actually carries, with the vintage they were cut from. It is the same list our own lookup form draws its dropdowns from, so a value taken from it needs no matching at all.
Response
{
"estimate": 6425.0, // point estimate of realized sale price, USD — equals p50
"currency": "USD",
"interval": { // calibrated range; kept for compatibility, prefer "quantiles"
"p10": 4980.0, "p50": 6425.0, "p90": 8210.0
},
"quantiles": null, // full calibrated vector once the serving bundle exposes it
"band_calibration": null, // which calibration profile produced the band + its cohort n/window; null = pooled
"n_comparables": 40,
"comp_window": { // when the attended sales reach back to; null if the bundle omits it
"oldest_sale_date": "2024-11-19", "newest_sale_date": "2026-07-21",
"span_days": 609, "recency_days": 631
},
"comps": [ // every attended sale on a paid tier, ranked: class, then make, then similarity
{
"year": "2019", "make": "Toyota", "model": "Camry",
"primary_damage": "FRONT END", "title_group": "SALVAGE TITLE",
"odometer": "81250", "sale_date": "2026-06-18",
"realized_price": 6100.0, "similarity": 0.94, // deprecated: ordering only, prefer match_reason
"match_reason": [ // what this row shares with the subject; null if the bundle carries no rules
{"key": "model", "text": "same make & model"},
{"key": "damage", "text": "same damage type"},
{"key": "odometer", "text": "odometer within 7K mi"},
{"key": "recency", "text": "sold 5 months ago"}
],
"same_model_recent": true, // same make & model, model year within 3; null if the bundle computes no flags
"vehicle_class": "passenger vehicle", "body_type": "sedan"
}
],
"subject_class": "passenger vehicle", // derived coarse class of the subject
"subject_body_type": "sedan", // finer tier: pickup / suv / sedan / van / …
"comp_note": null, // set when displayed comps are off-type or unclassified
"input_tier": "listing-grade",
"content_tier": "report", // "report" (paid) or "estimate" (free tiers, no comps attached)
"content_note": null, // names the withheld attachment when content_tier is "estimate"
"accuracy_note": "…", // plain-language note on expected error for this tier
"basis": "…", // the measurement basis quoted: realized Copart salvage-auction outcomes
"model": {
"tag": "…", "trained_through": "2026-07-02", "version_id": "…"
},
"disclaimer": "…"
} Every response is stamped with the model version that produced it, so estimates are reproducible and auditable after model updates.
Ranges
estimate is the median of the predicted price distribution, so it equals p50 by construction. Quantiles are conformal-calibrated per cohort against realized sales.
quantiles carries the full vector (p5, p10, p25, p50, p75, p90, p95) so you can choose your own presentation. The two bands reports draw are the inner 50% (p25–p75) and the outer 90% (p5–p95). 9 out of 10 sales land inside the outer band. Measured on 74,824 sales the model never trained on, Aug 31–Sep 5, 2026. It's a rate across many estimates, not a guarantee for any one vehicle. The inner band's 50% is a nominal level and we make no frequency claim for it: quote it as nominal, and say so, in anything customer-facing. How we measure covers how coverage is measured. It is null while the serving bundle exposes only p10/p50/p90 — check for it rather than assuming it is present.
interval keeps its {p10, p50, p90} shape for existing integrations and is populated from the same calibrated vector. It will not be renamed or removed inside /v1; prefer quantiles for new work.
comp_window dates the full set of neighbors behind the estimate — all n_comparables. Neighbors are selected by similarity, not by a time box, so the window describes where the evidence happened to fall; it is not a filter you can set.
On a paid tier comps carries every one of those neighbors, ranked closest first, so len(comps) == n_comparables and the window is the window of the rows you were sent. Free tiers carry none of them and say so in content_note; a tier is a view of one retrieval, never a different selection, so any narrower rung is the first rows of this same ordering. The count is the model’s k, not a page size — there is no parameter to ask for more or fewer.
The two day counts answer different questions and are not interchangeable. span_days is newest_sale_date − oldest_sale_date: how far apart the sales are from each other, anchored to nothing — sixteen sales a week apart three years ago have a span of seven days. recency_days is request date − oldest_sale_date: how far back the evidence reaches from the moment you asked. Any sentence of the form “the last N days” is recency_days, and our own surfaces state it that way. It is null when the dates cannot carry it, and the whole object is null when the serving bundle does not report a window.
Why a comp is comparable
match_reason states what the subject and that comp both carry: same make and model, same damage type, odometer within a stated band, how long ago it sold. It is an ordered list capped at three matched facts plus recency, and each entry is a key (a stable rule id, safe to branch on) and text (the wording we display). A chip is minted only when both sides carry the fact, so a sparse query returns fewer chips rather than a guess, and numeric bands round away from the subject — a 7.2K-mile gap is stated as “within 8K”, never “within 7K”. Every entry is therefore checkable against the comp row printed beside it.
same_model_recent is a boolean on the same comp, and it is not a chip. It is true when the comp carries the subject's make and model and its model year is within 3 of the subject's, inclusive; a missing value on either side is never a match. It is computed on the full rule set rather than on the chips, so it stays true on a row whose year chip lost its slot to the cap: count the flag, never the chips. Whether two spellings of a model name are the same model is decided by the serving bundle's identity rule, and this field is the only place that decision is published — re-deriving it from the served make, model and year can disagree with the chips drawn beside it. It is null when the serving bundle computes no flags, and null is not false.
similarity is deprecated in favour of it. The score is a distance in the model's retrieval space: it is unitless, it orders the rows correctly, and its meaning changes at every model release, so two reports’ scores are not comparable and it is not a number to publish. Like interval, it keeps its shape and is not removed inside /v1; our own comp tables print the chips instead of it as soon as a response carries them. match_reason is null when the serving bundle carries no chip rules — check for it rather than assuming it is present.
Other endpoints
| Endpoint | Auth | Returns |
|---|---|---|
| GET /v1/health | none | Service status and model tag |
| GET /v1/model | key | Model tag, training cutoff, version id |
Errors
| Status | Meaning |
|---|---|
| 401 | Missing, invalid, or revoked API key |
| 422 | Insufficient identity (need VIN or year+make+model), or unknown field |
| 429 | Per-minute rate limit or monthly quota exceeded |
| 502 | VIN decode unavailable — retry, or send year/make/model directly |
Terms of use
Responses are market value estimates from a statistical model, not appraisals, and every response carries the full disclaimer. Estimates and comps are licensed for your internal valuation workflows and client deliverables; bulk redistribution or resale of the underlying comp data requires a separate agreement.