---
title: "Responses & pagination"
description: "The response envelope, caching and conditional requests, compression, pagination aliases, and rate-limit headers."
canonical: "https://thecarapi.com/docs/conventions"
contract_version: "2026-08-19"
api_base: "https://api.thecarapi.com"
source: "https://thecarapi.com/docs/conventions.md"
---

# 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-08-19`. |
| `request_id` | string | Correlation id, also returned in `X-Request-ID`. |
| `server_time` | timestamp | UTC ISO-8601 response time. |
| `data_updated_at` | timestamp | Time 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

| Parameter | Meaning |
| --- | --- |
| `limit` / `page_size` | Page size, maximum 100. Do not send both aliases. |
| `offset` | Zero-based row offset. Do not combine with `page`. |
| `page` | One-based page number. |
| `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. |

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.

> **Paging a randomized sort** — When the effective sort is `random`, the response carries `random_seed`. Pass it back as `seed=` on every subsequent page or you will re-shuffle the deck between pages and see duplicates.

## 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`._
