contract 2026-08-19
Conventions

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.

FieldTypeMeaning
contract_versionstringSchema contract, currently 2026-08-19.
request_idstringCorrelation id, also returned in X-Request-ID.
server_timetimestampUTC ISO-8601 response time.
data_updated_attimestampTime the underlying data was last refreshed.
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

ParameterMeaning
limit / page_sizePage size, maximum 100. Do not send both aliases.
offsetZero-based row offset. Do not combine with page.
pageOne-based page number.
total / total_pagesNull when totals are suppressed.
max_pageDeepest reachable page under the current depth policy.
include_totalSet false to skip counting. Cheaper on large result sets.

Public keys are capped at limit=100 and offset=5000. Invalid alias combinations return 400. Read the real caps from pagination on /api/contract rather than hardcoding them.

Rate limits

Quotas use fixed minute, hour, day or month windows. Inspect X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A 429 response also includes Retry-After — honour it rather than backing off on a schedule of your own.

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.