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-09-03. |
request_id | string | Correlation id, also returned in X-Request-ID. |
server_time | timestamp | UTC ISO-8601 response time. |
data_updated_at | timestamp | Route-provided data timestamp; defaults to server_time when no data timestamp is supplied. It is not a guaranteed source refresh time. |
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.
{
"success": true,
"results": [ /* … */ ],
"total": 18342,
"limit": 24,
"offset": 0,
"contract_version": "2026-09-03",
"request_id": "0f2c1b9e-4a77-4c31-9a0b-6d2f0a1c8e55",
"server_time": "2026-08-19T09:41:02Z",
"data_updated_at": "2026-08-19T06:00:00Z"
}Freshness and missing values
| Signal | Meaning | Client behavior |
|---|---|---|
server_time / data_updated_at | Response time and route metadata; data_updated_at can default to server_time. | Do not use these alone to claim that the source was just checked. |
first_seen_at, last_seen_at, last_available_at, last_changed_at | Stored observations and changes to this listing, where available and permitted. | Use alongside updated_at; inventory refresh, detail enrichment and image processing are separate. |
live_price.fetched_at | When the overlay bid was read, in Unix seconds. | Display its age. It may be reused within the live-price TTL. |
details_pending / live_price_pending | Detail enrichment or a bid refresh is still running. | Render known card data and use a bounded delayed follow-up; do not create a tight polling loop. |
| Missing field / null / empty array | A permission, missing source evidence, or incomplete enrichment can affect availability. | Keep unknown distinct from zero, false, accident-free, and no service history. |
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.
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.
Keep cached bodies and ETags scoped to the full request URL and API-key identity. Never reuse one customer’s response for another key: source and field grants can differ. A 304 has no JSON body, so retain the previously cached body and do not call response.json() on it.
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.
// 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/classifieds/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/classifieds/listings). limit is capped at 100 across the API — larger values are clamped down rather than rejected.
Depth policy
- No offset cap.
/api/searchand/api/listVehiclespage to any depth, filtered or not, somax_pageon those responses is simplytotal_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 back503asking you to narrow it. Pages past offset 5000 are also not cached, so they cost more. - Capped at offset 5000.
/api/catalog/*,/api/top-offersand/api/classifieds/listings. A deeper page is a400naming 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.
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.
async function call(url, init = {}) {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(url, init);
if (res.status === 429 || res.status >= 500) {
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");
}429, 503 and 5xx. Never retry 400, 401, 403 or 404.