Overview
The CLCAuto Public API exposes read-only listing and dealer data, plus endpoints for dealers to push inventory automatically. Individuals and dealers use the same endpoints; only the write quota differs.
https://www.clcauto.com/api/v1- Requests and responses are JSON. Field names are English; enum fields come back as { value: English slug, label: text translated by `lang` }.
- CORS is open (`*`), so read endpoints can be called directly from browsers and mini-programs.
- List endpoints use cursor pagination: pass the returned `next_cursor` for the next page; `limit` is capped at 100.
AI format
Copy the whole text below and paste it into ChatGPT, Claude or any AI assistant, then tell it what you want in plain words (e.g. "upload the cars in this CSV to my account"). It will know how to call the API, convert units, and when a dry run is required first.
<API_KEY> in the text is a placeholder: give the AI the key you created under "Manage API keys" separately, and never paste it anywhere public.
# CLCAuto (常來車網) Public API — instructions for AI assistants
You are helping a user work with the CLCAuto Public API. CLCAuto is a non-profit, multilingual used-car marketplace in North America. Follow these rules exactly.
## Connection
- Base URL: https://www.clcauto.com/api/v1
- All requests and responses are JSON (UTF-8). CORS is open.
- Auth header: Authorization: Bearer <API_KEY> (keys start with clc_live_). The user creates keys at https://www.clcauto.com/zh/developers/keys. Never print, log or echo the key back. If the user has not given you a key, use the literal placeholder <API_KEY> and ask them to fill it in.
- Read endpoints work without a key (20 requests/min per IP, contact info redacted). Write endpoints require a key with scope write:inventory (image endpoint: write:images).
- Optional query ?lang=zh|en|ko|ja|es|fr controls the "label" text of enum fields.
## Units and conventions
- price: integer in the listing's currency (0 = "call for price"); currency: USD (default) | CAD | AUD | NZD. mileage: integer in mileage_unit: mi (default) | km. year: model year.
- Responses always include currency and mileage_unit; price_min / price_max filters are USD and mileage_max is miles (non-USD / km listings are converted at fixed approximate rates before comparing).
- Enum fields are SENT as English slugs and RETURNED as { "value": slug, "label": translated text }:
fuel_type: gasoline | diesel | electric | hybrid | phev | hydrogen
transmission: automatic | manual | cvt | dct | amt | single_speed
drivetrain: fwd | rwd | awd | 4wd
title_status: clean | rebuilt | salvage | lemon_buyback | ppsr_clear | written_off
condition.tires_condition: new | good | fair | needs_replacement
condition.known_issues (array): engine | transmission | air_conditioning | electrical | oil_leak | rust | accident_repair | suspension
- Lists are cursor-paginated: pass cursor = previous next_cursor; limit ≤ 100 (default 20).
- Error body: { "error": { "code": string, "message": string, "details"?: [{ "field": string, "issue": string }] } }
400 bad_request / validation_failed · 401 unauthorized / key_revoked / key_expired · 403 insufficient_scope / ip_not_allowed / forbidden / account_banned · 404 not_found · 409 conflict / listing_blocked · 429 rate_limited / quota_exceeded · 500 internal_error
- On 429 rate_limited: wait Retry-After seconds, then retry once. On 429 quota_exceeded: STOP; body.quota.resets_at (UTC midnight) says when to continue. Never retry in a tight loop.
- On 409 listing_blocked: the listing was removed by moderation or an administrator and cannot be relisted. Do not retry; tell the user.
## Quotas (per account per UTC day, shared by all of the account's keys)
- personal tier: 10 new listings, 30 changes per day.
- dealer tier (account has a verified store): 2000 writes per day total, max 500 active listings.
- Every write response includes a "quota" object and headers X-Quota-Inserts-Remaining / X-Quota-Updates-Remaining. GET /me shows the current tier and quota.
## Endpoints
GET /cars filters: make, model, year_min, year_max, price_min, price_max, mileage_max, fuel_type, transmission, drivetrain, location, seller_id (key required), updated_since (ISO 8601; listings whose visible content changed since then — compare with updated_at), sort (newest|price_asc|price_desc|mileage_asc|year_desc), limit, cursor, lang
GET /cars/{id} description / features in lang once translated (description_translated tells you); description_original and features_original are the seller's own text, description_lang its ISO 639-1 language (null when not detected or undeterminable)
GET /dealers filters: location, limit, cursor
GET /dealers/{id} store + up to 50 active cars
GET /meta/makes make → models → counts, plus the full enum catalog
GET /me key, owner, tier, verified stores, quota
GET /inventory the account's own listings (all statuses); filters: status, external_id, limit, cursor
GET /inventory/{external_id}
PUT /inventory/{external_id} idempotent create-or-update → 201 created / 200 updated. After a 409 conflict with existing_id (same VIN already posted manually) you may re-send with ?adopt=true to attach external_id to that listing.
DELETE /inventory/{external_id} ?reason=sold (default) | removed
POST /inventory/batch { "items": [ListingInput, ...] } ≤ 100 items; per-item results; earlier successes are kept when a later item fails
POST /inventory/reconcile { "active_external_ids": [...], "dry_run": true|false } — every listing NOT in the list is marked removed. ALWAYS call with dry_run:true first and show the user what would be removed. An empty list additionally requires "confirm_empty": true.
POST /inventory/{external_id}/images { "images": ["https://...", ...] } ≤ 30, https only, first image = cover
## ListingInput (body of PUT, and each item of /inventory/batch)
Required: vin (17 chars, no I/O/Q), year, make, model, price, mileage, fuel_type, transmission.
Optional: trim, original_price, currency (USD|CAD|AUD|NZD), mileage_unit (mi|km), country (US|CA|AU|NZ), drivetrain, title_status, condition, exterior_color, interior_color, description, features (string[] ≤ 40), images (https URLs ≤ 30), carfax_url, store_id (dealers with several stores), location.
- external_id is the dealer's own stock number and goes in the URL path (in batch items it is a body field).
- currency / mileage_unit: send currency "CAD" / "AUD" / "NZD" for Canadian / Australian / New Zealand dollar prices and mileage_unit "km" for odometer readings in kilometres, with the numbers exactly as the user gave them. Omitting them keeps the stored value (new listings default to USD / mi); null resets to the default.
- title_status: only send it when the user states the title brand (clean title, rebuilt, salvage, lemon buyback) — never infer it from "no accidents" or a Carfax report. For Australian / New Zealand cars use ppsr_clear (the seller checked the official PPSR: no money owing) or written_off (recorded as an insurance write-off) instead — only when the user says so. Omitting it keeps the stored value; null clears it.
- condition: optional checklist object { service_records: bool, one_owner: bool, smoker: bool, keys_count: 0-5, tires_condition, known_issues: [slugs] }. Every item is optional — only include what the user actually states; never infer. known_issues [] means "no known issues" (only when the user says so). There is no free-text key (unknown keys → 400); put details in description. Omitting condition keeps the stored checklist; null clears it; an object replaces the whole checklist.
- location: dealer-tier accounts may omit it (the verified store address is used). Personal accounts MUST send it as "City, ST", a 5-digit US zip, a Canadian postal code (A1A 1A1) or a 4-digit Australian / New Zealand postcode.
- country: optional US / CA / AU / NZ. Required with a 4-digit postcode (Australia and New Zealand both use 4 digits) and must match the postcode format. On a new listing it sets the default currency / mileage_unit when you omit them (AU → AUD / km, NZ → NZD / km, CA → CAD / km, US → USD / mi). Responses carry a derived country field (inferred from the coordinates / location, not stored).
- description and features: send the user's own wording in any language — do NOT translate them yourself. They are stored verbatim and machine-translated into the other site languages. description_lang is deprecated and ignored (the language is detected automatically); omit it.
- The server decodes the VIN, mirrors the images, runs content moderation and translates asynchronously. The response "processing" array lists what is still running (vin, moderation, translation, images) — do not poll for it; GET /inventory later shows processing_state (images_pending, translation_pending).
- Re-sending a listing whose description, features, year, make, model and trim are unchanged triggers no new moderation, and translation only re-runs when the description changes, so periodic full syncs are cheap.
- Listings never expire; they stay live until you DELETE them, mark them sold, or reconcile them away.
Example
PUT https://www.clcauto.com/api/v1/inventory/STK12345
{
"vin": "1HGCV1F3XLA012345", "year": 2020, "make": "Honda", "model": "Accord", "trim": "Sport",
"price": 18900, "mileage": 41200, "fuel_type": "gasoline", "transmission": "cvt", "drivetrain": "fwd",
"exterior_color": "white", "description": "One owner, clean title, dealer maintained.",
"images": ["https://cdn.example.com/stk12345/1.jpg", "https://cdn.example.com/stk12345/2.jpg"]
}
→ 201 { "id": "…", "external_id": "STK12345", "url": "https://www.clcauto.com/zh/car/…", "status": "active", "created": true, "processing": ["vin", "moderation", "translation", "images"], "quota": { … } }
## Rules for you, the assistant
1. Units: never convert between currencies (USD/CAD/AUD/NZD) or miles/km yourself — send the figure as given with the matching currency / mileage_unit. Prices in other currencies (RMB etc.): ask the user for the figure in one of the supported currencies (USD, CAD, AUD, NZD); never guess a price.
2. Validate locally before calling: VIN is 17 characters, all required fields present, enum slugs from the lists above. Ask the user for anything missing instead of inventing values.
3. Importing a CSV or spreadsheet: map the columns to ListingInput, show the user 1–2 mapped rows and ask for confirmation, then send /inventory/batch in chunks of ≤ 100.
4. Full inventory sync: batch-upsert everything, then /inventory/reconcile with dry_run:true, show the would_remove list, and only run dry_run:false after the user explicitly confirms.
5. After every write, summarize for the user: created / updated / failed counts, remaining quota, and any validation_failed details verbatim (field + issue).
6. Only use the endpoints listed here. Machine-readable spec: https://www.clcauto.com/api/v1/openapi.json — this document: https://www.clcauto.com/api/v1/llms.txt
Authentication
Create an API key in the portal and send it as a bearer token in the Authorization header:
Authorization: Bearer clc_live_...- Read endpoints work without a key, but are limited to 20 requests/min per IP and contact details are masked.
- Requests with a key use the key's own rate limit (default 60/min). Write endpoints always require a key with the matching scope.
- Up to 5 active keys per account. The plaintext is shown only once at creation; revoke and recreate immediately if it leaks.
Quota tiers
| Tier | Condition | New listings / day | Updates / day | Active cap |
|---|---|---|---|---|
| Particulier | Default | 10 | 30 | Same as manual posting |
| Concessionnaire | Has a verified store | 2000 writes (combined) | — | 500 |
Quota is per account and shared by all its keys; it resets at 00:00 UTC. An "insert" is the first PUT of an external_id; an "update" is any PUT / DELETE / image upload / reconcile removal on an existing listing. Every write response carries X-Quota-Inserts-Remaining / X-Quota-Updates-Remaining so scripts can throttle themselves.
Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /cars | — | List listings (cursor-paginated, filterable) |
| GET | /cars/{id} | — | Listing detail |
| GET | /dealers | — | Verified dealer stores |
| GET | /dealers/{id} | — | Store detail with active listings |
| GET | /meta/makes | — | Make / model / count aggregates |
| GET | /me | any | Current key, account, stores and quota |
| GET | /inventory | write:inventory | My API-managed listings |
| GET | /inventory/{external_id} | write:inventory | Get one by stock number |
| PUT | /inventory/{external_id} | write:inventory | Idempotent upsert (core endpoint) |
| DELETE | /inventory/{external_id} | write:inventory | Mark sold (default) or removed |
| POST | /inventory/{external_id}/images | write:images | Replace image list |
| POST | /inventory/batch | write:inventory | Batch upsert |
| POST | /inventory/reconcile | write:inventory | Full reconcile; anything not listed is removed |
| GET | /openapi.json | — | OpenAPI 3.1 spec |
Batch endpoints take up to 100 items and charge quota per item; items past the limit come back as quota_exceeded while earlier ones stay written.
Listing fields
PUT /inventory/{external_id} and each batch item share the same fields:
| Field | Type | Required | Description |
|---|---|---|---|
| external_id | string | Required | Stock number, in the path; unique per account |
| vin | string(17) | Required | Duplicate VIN under the same account returns 409 |
| year | integer | Required | |
| make / model | string | Required | Make / model in English |
| trim | string | Optional | |
| price | integer | Required | Entier dans la devise de `currency` ; 0 = prix sur demande |
| original_price | integer | Optional | |
| currency | enum | Optional | USD (par défaut), CAD, AUD ou NZD ; omis = valeur conservée, null = retour à USD |
| mileage | integer | Required | Entier dans l'unité de `mileage_unit` |
| mileage_unit | enum | Optional | mi (par défaut) ou km ; omis = valeur conservée, null = retour à mi |
| fuel_type / transmission | enum | Required | See enum table below |
| drivetrain | enum | Optional | See enum table below |
| title_status | enum | Optional | Statut du titre, voir le tableau des énumérations ci-dessous ; omis = valeur actuelle conservée, null = effacé |
| condition | object | Optional | État du véhicule : service_records / one_owner / smoker (booléen), keys_count (0–5), tires_condition, known_issues (voir le tableau des valeurs ci-dessous). Pas de texte libre ; omettez-le pour conserver la valeur actuelle, null l'efface |
| exterior_color / interior_color | string | Optional | English |
| description | string | Optional | Any language; others are translated automatically |
| description_lang | string | Optional | Deprecated: accepted but ignored (language is detected automatically) |
| features | string[] | Optional | |
| images | string[] | Optional | https URLs, max 30, each ≤ 10 MB |
| carfax_url | string | Optional | |
| store_id | uuid | Optional | Pick a store when you have several; defaults to the first verified store |
| location | string | Required for individuals | City or ZIP; dealers default to the store address |
Writes return as soon as the row is saved; VIN decoding, AI moderation, translation and image mirroring run in the background and `processing` lists the steps still pending. Up to 30 images.
Enum values
Examples
curl -X PUT https://www.clcauto.com/api/v1/inventory/STK12345 \
-H "Authorization: Bearer clc_live_..." \
-H "Content-Type: application/json" \
-d '{
"vin": "1HGCV1F3XLA012345",
"year": 2020, "make": "Honda", "model": "Accord", "trim": "Sport",
"price": 18900, "mileage": 41200,
"fuel_type": "gasoline", "transmission": "cvt", "drivetrain": "fwd",
"exterior_color": "white", "interior_color": "black",
"description": "One owner, clean title.",
"features": ["Apple CarPlay", "Sunroof"],
"images": ["https://cdn.example.com/stk12345/1.jpg"]
}'curl "https://www.clcauto.com/api/v1/cars?make=Toyota&price_max=20000&sort=newest&limit=20&lang=en" \
-H "Authorization: Bearer clc_live_..."const BASE = "https://www.clcauto.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.CLC_API_KEY}`,
"Content-Type": "application/json",
};
// 1. Current stock exported from your DMS
// [{ external_id, vin, year, make, model, price, mileage, images, ... }]
const stock = await loadStockFromDms();
// 2. Upsert in batches of 100
for (let i = 0; i < stock.length; i += 100) {
const res = await fetch(`${BASE}/inventory/batch`, {
method: "POST",
headers,
body: JSON.stringify({ items: stock.slice(i, i + 100) }),
});
const { results } = await res.json();
for (const r of results) if (!r.ok) console.warn(r.external_id, r.error);
}
// 3. Reconcile: anything not in the list is marked removed
const rec = await fetch(`${BASE}/inventory/reconcile`, {
method: "POST",
headers,
body: JSON.stringify({ active_external_ids: stock.map((s) => s.external_id) }),
});
console.log(await rec.json()); // { dry_run: false, removed: [{ id, external_id }], failed: [], quota }Error format
Every error is { error: { code, message, details? } }; validation_failed lists each offending field in details.
{
"error": {
"code": "validation_failed",
"message": "price is required",
"details": [{ "field": "price", "issue": "required" }]
}
}Exceeding the per-minute limit returns 429 rate_limited (with Retry-After); using up the daily write quota returns 429 quota_exceeded with `quota` in the body:
HTTP/1.1 429 Too Many Requests
X-Quota-Tier: personal
X-Quota-Inserts-Remaining: 0
X-Quota-Updates-Remaining: 12
{
"error": {
"code": "quota_exceeded",
"message": "Daily insert quota reached",
"quota": { "tier": "personal", "inserts_used": 10, "inserts_limit": 10,
"updates_used": 18, "updates_limit": 30, "resets_at": "2026-09-03T00:00:00.000Z" }
}
}| code | HTTP | Description |
|---|---|---|
| bad_request / validation_failed | 400 | Invalid parameters or body |
| unauthorized / key_revoked / key_expired | 401 | Missing, invalid, revoked or expired key |
| insufficient_scope / ip_not_allowed / forbidden | 403 | Insufficient scope, IP not allowed, or account banned |
| not_found | 404 | Resource missing or not visible |
| conflict | 409 | Duplicate VIN under this account (includes existing_id; retry with ?adopt=true to attach) |
| listing_blocked | 409 | Cette annonce a été retirée par la modération ou un administrateur et ne peut pas être republiée. |
| rate_limited / quota_exceeded | 429 | Rate limit or quota exhausted |
| internal_error | 500 | Server error |
Generated server-side; import it straight into Postman, Scalar or a code generator.