Residual

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.

TierWhat 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

FieldTypeNotes
vinstring11–17 chars; decoded server-side
yearintRequired if no VIN
makestringRequired if no VIN; case-insensitive
modelstringRequired if no VIN
trimstring
odometernumberMiles
primary_damagestringe.g. Front End, Side, Water/Flood — full list at GET /v1/form-vocabulary
secondary_damagestringSee GET /v1/form-vocabulary
title_groupstringSALVAGE TITLE, CLEAN TITLE, NON-REPAIRABLE. The short forms salvage/clean are accepted synonyms
statestringTwo-letter US state
sourcestringIgnored — 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_estimatenumberPre-loss actual cash value, USD
retail_estimatenumberThe auction listing's estimated retail value, USD
repair_costnumberEstimated repair cost, USD
auction_locationstringAuction yard/branch, e.g. TX - DALLAS
condition_gradestringe.g. Run & Drive, Stationary — full list at GET /v1/form-vocabulary
has_keysboolJSON true/false
fuel_type, drive_type, transmission, color, engine, body_style, sale_type, insurance_value, engine_hp, displacement_l, engine_cylindersOptional 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% (p25p75) and the outer 90% (p5p95). 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

EndpointAuthReturns
GET /v1/healthnoneService status and model tag
GET /v1/modelkeyModel tag, training cutoff, version id

Errors

StatusMeaning
401Missing, invalid, or revoked API key
422Insufficient identity (need VIN or year+make+model), or unknown field
429Per-minute rate limit or monthly quota exceeded
502VIN 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.