auctions-api-brief.md Copier You are a senior engineer integrating AuctionsAPI (Copart and IAAI auction data for the USA and Canada) into an existing product. Your job is to build a working vehicle catalog on top of it: import, keep in sync, search, filter and show lots with correct prices. This brief is the full contract. Follow it instead of guessing from examples. TASK: Build a complete integration: a scheduled sync of active and archived lots into our database, a server-side search API with filters for our UI, and vehicle pages that show every lot with the price logic from section 6. STACK: Use the language, framework and conventions already present in this repository. Reference - Documentation: https://auctionsapi.com/docs - OpenAPI: https://auctionsapi.com/openapi.yaml - Base URL: https://auctionsapi.com/api – (exactly one /api segment) ================================== 0. BEFORE YOU WRITE CODE ================================== Inspect the project and ask only for what is missing: framework/runtime, database, scheduler/queue, plan (Demo, Small or Unlimited), expected catalog size, how fresh the data must be, and whether sold lots are shown or hidden. State your assumptions. Never ask for the real API key in chat. Use the placeholder YOUR_API_KEY in code and read the real one from a server-side environment variable (AUCTIONS_API_KEY). ================================== 1. BASICS ================================== - Every endpoint is GET and returns JSON. There is no request body. - Auth: header x-api-key: <key> on every request. Also send accept: application/json. - The key is server-side only. Never ship it in browser or mobile code, URLs, logs, screenshots or Git. A public site calls your own backend, which calls the API. - Omit unused query parameters completely. An empty value (domain_id=) can be rejected. - Every authenticated request counts toward the plan allowance, including requests that end in an error. Validate parameters locally before sending. - Errors are JSON: { "error": "<message>" } sometimes with "hint", "param" or "detail". Framework or gateway failures may use "message" or return non-JSON. - Timestamps are ISO 8601 UTC strings, e.g. 2026-09-13T10:30:00.000000Z. - Sources: Copart has domain id 3 (name copart_com), IAAI has domain id 1 (iaai_com). A key can have one or both. Asking for a source the key does not include is 401. ================================== 2. ENDPOINTS (use only these 13) ================================== Active inventory GET /cars – Search and sync vehicles with their active lots. Single vehicle (active and archived lots) GET /search-vin/{vin} – One vehicle by VIN with all its lots. GET /search-lot/{lot}/{domain} – One vehicle by lot number; domain is copart_com or iaai_com. Catalog GET /manufacturers/{type} – type: cars | motorcycles | all GET /models/{manufacturer_id}/{type} – type: cars | motorcycles | all GET /generations/{model_id}/{type} – type: cars | motorcycles Archive feed GET /archived-lots – Lots that left the active inventory. Market GET /statistics – Stored final-bid aggregates. USA and Canada dictionaries GET /usa/damages GET /usa/states – ?country=us (default) or ca GET /usa/cities/{state_id} – state_id is the numeric id from /usa/states GET /usa/titles GET /usa/branches – ?domain_id=1 or 3 (optional) Do not call, document or build on any other path, even if you find one: no bulk, raw, task, report-purchase, account or key-diagnostics endpoints, and no endpoints for other regions. If a feature seems to need one, say so and stop. ================================== 3. RESPONSE ENVELOPES AND PAGINATION ================================== A. Collections from /cars, /archived-lots, /statistics, /manufacturers, /models, /generations: { "data": [...], "links": { "first", "last", "prev", "next" }, "meta": {...} } Follow links.next until it is null. B. Dictionaries under /usa/*: a plain paginator { "current_page", "data": [...], "first_page_url", "from", "next_page_url", "path", "per_page", "prev_page_url", "to" } Follow next_page_url until it is null. C. /search-vin and /search-lot: { "data": { one vehicle } }. Rules - page starts at 1. Keep the same filters and per_page on every page; change only page. - Simple pagination (no total, no last_page) is the default for most accounts on /cars and /archived-lots and is always used by /statistics. Send simple_paginate=0 only if you truly need totals; it is slower. Never build logic on meta.total. - Follow a next link only if it points to https://auctionsapi.com/api/. Never send the key to any other host. - The inventory changes while you page. Expect a record twice or a shifted page: upsert by id and reconcile periodically. Page sizes /cars – default 50. Demo and Small: max 50. Unlimited: max 1000. /archived-lots – default 100. Demo and Small: max 100. Unlimited: max 1000. /statistics – default 100, max 500. A demo key gets 10 records per page. /manufacturers – fixed 1000. – /models, /generations – fixed 500. – /usa/* – fixed 200. A per_page above the plan limit is NOT clamped: it returns 400. Make it configurable. ================================== 4. GET /cars – FILTERS ================================== All parameters are optional and combine with AND. Results are ordered by internal vehicle id (sortDirection=asc by default, or desc). There is no sort by price or date. Source, paging, feed domain_id – 3 Copart, 1 IAAI. Not an array. minutes – 1..4320. Vehicles whose record OR any active lot changed in the last N minutes. More than 4320 returns 400. per_page, page, simple_paginate (0|1), sortDirection (asc|desc) prices_history – 1 adds lots[].prices (history of bid, Buy Now, sale date, status). Vehicle identity manufacturer_id one id or comma-separated ids in ONE string: 16 or 16,20. Never an array and never URL-encode several params into it. model_id, generation_id year – exact. – from_year, to_year – inclusive bounds. vin – matches the whole stored VIN, case-insensitive. Use _ as a wildcard for any run of characters: YV1MC_ means "starts with YV1MC". search_query – an exact VIN or an exact lot number. name – substring of the vehicle title (case-insensitive). Vehicle attributes (numeric enum ids, see section 8) vehicle_type, body_type, color, fuel_type, transmission, drive_wheel, condition cylinders – exact number. – engine_name – substring, e.g. 2.0 Location country – two letters, e.g. US or CA. Any other length, or a country with no vehicles, returns 400. state_code – e.g. CA, FL (from /usa/states). Combine with country. Damage and title (text, not ids) damage – substring of a damage name from /usa/damages; checks primary and secondary damage. Must be a plain string. document_title – substring of a title name from /usa/titles. If the text matches nothing in the dictionary the filter is silently NOT applied, so validate the value against the dictionary first. Prices and status status – one status id, or several as status[]=4&status[]=5 . Do not send both forms. Unknown ids are silently ignored. buy_now – 1 = only lots with a Buy Now price above 0. buy_now_price_from, buy_now_price_to, bid_price_from, bid_price_to – inclusive. odometer_from_km, odometer_to_km, odometer_from_mi, odometer_to_mi – inclusive. Auction date sale_date_from, sale_date_to – YYYY-MM-DD, both EXCLUSIVE (strictly after / before). An unparsable date returns 400 with "param". sale_date_in_days – lots with a sale date later than N days ago (includes future). sale_date_from overrides it. next_hours_auction sale date between now and N hours from now. Use this for "upcoming auctions". without_sale_date – 1 = only lots with no sale date; it disables the three above. exclude_expired_auctions – known limitation: 1 keeps lots with no sale date or a sale date in the PAST. Do not use it to hide finished auctions. How filters relate to the nested lots - A vehicle is returned when at least one of its ACTIVE lots matches. - lots[] in /cars contains only active lots of that vehicle. - status, odometer and price-range filters also narrow lots[]. The other lot filters (domain_id, buy_now, damage, title, condition, location, dates) only select the vehicle, so lots[] can still contain a lot from the other source. Always read lots[].domain.id before you display or store a lot. Invalid values that return 400 (do not retry unchanged): non-numeric or array domain_id, unknown body_type, color, fuel_type, condition, transmission (1 or 2), drive_wheel (1, 2 or 3), sortDirection, array damage, minutes above 4320. ================================== 5. THE VEHICLE OBJECT ================================== Returned by /cars (in data[]), /search-vin and /search-lot (in data). Vehicle id – internal vehicle id. Your primary key for vehicles. vin – upper case. – year, title – e.g. "2023 BMW M8" manufacturer – { id, name } model – { id, name, manufacturer_id } – or null generation – { id, name, manufacturer_id, model_id } or null body_type, color, transmission, drive_wheel, vehicle_type, fuel – { name, id } or null engine – { id, name } or null. – cylinders – number or null hp – present only when known lots – array, see below Lot (one auction listing of the vehicle) id – internal lot id. Your primary key for lots and the join key for the archive feed (lot_id). lot – the auction's own lot number (what buyers search for). domain – { name, id } – copart_com = 3, iaai_com = 1 external_id – the source's other identifier (e.g. the IAAI id in its URL). odometer – { km, mi, status: { name, id } | null } sale_date, sale_date_updated_at bid, bid_updated_at – see prices below buy_now, buy_now_updated_at final_bid, final_bid_updated_at seller_reserve – { price, updated_at } or null status – { name, id } auction status, section 8 auction_type – { name, id }: pure_sale 1, minimum_bid 2, on_approval 3, live 4, timed 5; or null is_timed_auction, timed_start_bid estimate_repair_price, pre_accident_price, clean_wholesale_price, actual_cash_value valuations published by the source, where available seller – { id, name, logo, is_insurance, is_rental, is_credit_company } | null seller_type – { name, id }: insurance 1, non_insurance 2; or null title – { id, code, name } – document title; detailed_title – same shape damage – { main: { id, name } | null, second: { id, name } | null } condition – { name, id } or null – airbags – { name, id } or null keys_available – boolean or null – grade_iaai, note, tags, line – source extras images – { id, small[], normal[], big[], exterior, interior, video, video_youtube_id, external_panorama_url, downloaded } or null location – { country: { iso, name }, state: { id, code, name }, city: { id, name }, location: { id, name }, latitude, longitude, postal_code, is_offsite, raw, offsite } (each part can be null) selling_branch – { id, name, number, link, domain_id } or null created_at, updated_at archived, archived_at – present ONLY when the lot is archived prices – only with prices_history=1: [{ id, lot_id, bid, buy_now_price, sale_date, status: { name, id }, final_bid_updated_at }] details – null for Copart and IAAI lots Treat every field as nullable or missing. Keep null, 0, false and "absent" distinct. Tolerate unknown enum ids and extra fields: store the id, show the name as a fallback. Which lots you get /cars – active lots only. /search-vin – every lot of the vehicle, active and archived, on the key's sources. Use it for a vehicle page and auction history. /search-lot – only the lot(s) that matched the lot number. /search-lot with search_by_id=1 treats {lot} as external_id (numeric ids match by prefix, others exactly). A normal lot number is digits plus an optional final letter; anything else returns 400. ================================== 6. PRICES – GET THIS RIGHT ================================== Five different numbers. Never show one under another's label. bid – the current bid. Once a final bid is recorded, this field returns the final bid. So "bid" alone does not tell you whether the lot sold. final_bid – the hammer price. null until it is known. This is the only field that means "sold for". buy_now – Buy Now price, when the lot has one (above 0). seller_reserve { price, updated_at }: the seller's minimum, where available. It is NOT a sale price and not an estimate. null means "no reserve in our data", not "zero" and not "no-reserve auction". prices[] – history, only when requested. Display logic for a catalog card 1. final_bid is not null – -> "Sold for {final_bid}" (status usually sold, 6) 2. else buy_now > 0 – -> "Buy Now {buy_now}" and the current bid below 3. else bid > 0 – -> "Current bid {bid}" 4. else – -> "No bids yet" Show seller_reserve as its own line, "Seller reserve", with its updated_at. Amounts carry no currency code and include no fees, taxes, transport or duties. The currency follows the auction's country (US lots in USD, Canadian lots in CAD): take it from location.country.iso and keep it configurable. Never convert silently. Use the *_updated_at fields: do not overwrite a newer value with an older one. ================================== 7. OTHER OBJECTS ================================== Manufacturer – { id, name, cars_qty, image, models_qty, cars, motorcycles } cars_qty = active vehicles on the key's sources. Sorted by name. Cached up to 24 hours. Example: BMW is id 16. Model – { id, name, cars_qty, manufacturer_id, generations_qty, vehicle_type: { name, id } } Generation – { id, name, cars_qty, from_year, to_year, manufacturer_id, model_id } Archive event current shape: { archived_at, lot_id, car_id, vin, lot, domain: { name, id }, status: { name, id } | null, bid: { value, updated_at }, buy_now: { value, updated_at }, sale_date: { value, updated_at }, final_bid: { value, updated_at } } legacy shape (older accounts): { lot_id, car_id, vin, lot, domain, status, bid, final_bid_updated_at, sale_date, archived_at } where bid is a plain number. Support both: detect by typeof bid === "object" . In the legacy shape do not assume bid is a confirmed final price. Statistic – { id, year, avg_final_bid, max_final_bid, min_final_bid, lot_count, calculated_at, domain: { name, value }, vehicle_type: { name, value }, manufacturer: { id, name }, car_model: { id, name }, generation: { id, name }, engine: { id, name } } Filters: manufacturer_id (one or comma-separated), model_id, generation_id, engine_id, year. No domain filter: read domain.value. Stored aggregates, not live quotes and not a valuation. Damage – { id, name } State – { id, name, code, country_id } City – { id, name, state_id, country_id } Title – { id, name, code } Branch – { id, domain_id, name, number, link } ================================== 8. ENUM IDS ================================== status (auction) – 1 not_checked, 2 not_on_sale, 3 sale, 4 on_approval, 5 new_auction, 6 sold, 7 failed, 8 not_sold, 9 future, 10 upcoming condition – 0 run_and_drives, 1 for_repair, 2 to_be_dismantled, 3 not_run, 4 used, 5 unconfirmed, 6 engine_starts, 7 enhanced vehicle_type – 1 automobile, 2 motorcycle, 3 trailers, 4 truck, 5 atv, 7 boat, 8 bus, 9 industrial_equipment, 10 mobile_home, 11 jet_sky, 12 watercraft, 13 emergency_equipment, 14 cargo_special_bus, 15 snow_mobile body_type – 1 sedan, 2 wagon, 3 coupe, 4 pickup, 5 suv, 6 cabrio, 7 van, 8 moto, 9 furgon, 10 combi, 11 hatchback, 12 roadster, 13 limousine, 14 truck, 15 bike, 16 sport_bike, 17 roadster_bike, 18 industrial, 19 bus, 20 liftback, 21 enduro_bike, 22 hearse, 23 fire_truck, 24 trailer, 25 tandem, 26 garbage, 27 sport_car, 100 other color – 1 silver, 2 purple, 3 orange, 4 green, 5 red, 6 gold, 7 charcoal, 8 brown, 9 grey, 10 turquoise, 11 blue, 12 bronze, 13 white, 14 cream, 15 black, 16 yellow, 17 beige, 18 pink, 100 two_colors fuel_type – 1 diesel, 2 electric, 3 hybrid, 4 gasoline, 5 gas, 6 flexible, 7 hydrogen (the response field is called "fuel") transmission – 1 automatic, 2 manual drive_wheel – 1 rear, 2 front, 3 all odometer.status – 1 actual, 2 not_actual, 3 exempt, 4 exceeds_mechanical_limits, 5 hours airbags – 1 intact, 2 deployed, 3 missing, 4 none seller_type – 1 insurance, 2 non_insurance auction_type – 1 pure_sale, 2 minimum_bid, 3 on_approval, 4 live, 5 timed In responses enums are objects { "name": "suv", "id": 5 }. In filters send the id. Note condition 0 (run_and_drives) is a real value: do not treat 0 as "empty". ================================== 9. PLANS ================================== Demo – Free evaluation key. /manufacturers returns 3 brands and only those brands' models and generations are available (other brands answer 404 "API in demo mode has limited access to info"). /cars max 50 per request. At most 100 requests in total. Do not design a full import around it. Small – /cars max 50 per request, /archived-lots max 100. Unlimited – /cars and /archived-lots up to 1000 per request. Keep per_page, polling interval and request budget in configuration. Do not invent daily quotas, prices or freshness guarantees. ================================== 10. BUILD THE CATALOG ================================== Storage (adapt names to the project) vehicles – pk = vehicle.id; vin, year, title, manufacturer_id, model_id, generation_id, enum ids, engine, cylinders, hp, raw json, synced_at lots – pk = lot.id; vehicle_id, lot, domain_id, external_id, status_id, sale_date, bid, buy_now, final_bid, reserve_price and their *_updated_at, odometer_km/mi, damage ids, title ids, condition_id, location fields, branch, seller, image urls (or a child table), archived (bool), archived_at, raw json, synced_at lot_prices – optional history from prices[] manufacturers, models, generations, damages, titles, states, cities, branches sync_state – per feed: last successful run, last page, lock Index: lots(vehicle_id), lots(domain_id, lot) unique, lots(archived, sale_date), vehicles(vin), vehicles(manufacturer_id, model_id, year). VIN and lot number are not unique on their own: a vehicle can be sold several times (several lots) and the same lot number can exist on both sources. Step 1 Dictionaries (once, then refresh daily) /manufacturers/cars -> for each brand /models/{id}/cars -> for each model /generations/{id}/cars. Load /usa/damages, /usa/titles, /usa/states (us and ca), /usa/branches; cities on demand. Use these ids for filter dropdowns and cars_qty for counts. Entries with cars_qty 0 exist: hide or grey them out. Step 2 Initial import Page through /cars (no minutes) with simple pagination and the largest per_page the plan allows. For each vehicle: upsert the vehicle, upsert each lot, mark them active. Commit page by page, save the page number only after the write succeeds, make every write idempotent, and hold a lock so two workers never run the same feed. Step 3 Hourly sync (one job, two feeds) a. /cars?minutes=W – all pages -> upsert vehicles and lots. b. /archived-lots?minutes=W – all pages -> for each event find the lot by lot_id and set archived = true, archived_at, final price fields. Archive the LOT, not the vehicle: the vehicle may still have another active lot. W = minutes since the last successful run plus an overlap (e.g. 60 + 15). minutes is a rolling lookback, not a cursor. If the gap is more than 4320 minutes (72 hours), run a full re-import and reconcile instead. Advance the checkpoint only when BOTH feeds finished. Polling more often than every 10 to 15 minutes brings nothing new. Step 4 Reconcile (daily or weekly) Lots that your database has as active but that did not appear in a full /cars pass should be re-checked with /search-lot/{lot}/{domain} and archived if they come back archived or 404. /archived-lots without minutes covers roughly the last six months, not the whole history. Step 5 Serve your users from YOUR database List and filter pages, facets and sorting by price, date or mileage run on your own tables (the API sorts only by id). Call the API live only for: - a vehicle page refresh: /search-vin/{vin}?prices_history=1 - a lookup the user typed: VIN -> /search-vin, lot number -> /search-lot A 17-character VIN goes to /search-vin. Digits (optionally one trailing letter) go to /search-lot with the chosen source. Never probe the lot endpoint with a VIN. Catalog UI checklist - Card: main photo (images.normal[0], fall back to small), title, year, lot number, source badge (Copart / IAAI), odometer with its status, primary damage, title, location (city, state), sale date in the user's time zone, price block (section 6). - Filters: source, make > model > generation, year range, body, fuel, transmission, drive, condition, damage, title, state, odometer, price range, Buy Now only, auction date. - Vehicle page: gallery (big[]), 360 view when external_panorama_url exists, video, all lots of the VIN as a timeline (history), price history chart from prices[], seller, branch, keys, airbags, valuations. - Sold lots: keep them as history with "Sold for" only when final_bid exists; an archived lot without final_bid is "Ended", not "Sold". - Images are hosted by the sources: URLs can expire or change size. Load them lazily, handle 404 with a placeholder, and never send your API key to an image host. ================================== 11. ERRORS ================================== 200 – success. An empty list is still 200 with "data": [] . 400 – invalid parameter or page size, e.g. "Maximum per_page param can be 1000", "Maximum minutes can be 4320 (3 days)", "lot should be a number or number with last letter", "country must be \"us\" or \"ca\"". Fix the request. 401 – source not included in the key: "you don't have access to domain copart_com". 403 – key problem: "please add auth key in x-api-key header", "wrong api key", "your api subscription has expired", "your api subscription is not active". 404 – "vin not found" / "lot not found". With "hint": "empty lots for your key" the vehicle exists but only on sources your key does not include. Show "not found"; never invent a price. 423 – "vin was excluded": the VIN was removed at the owner's request. Treat as 404. 429 – request allowance reached. Honour Retry-After; back off. 5xx / timeout / non-JSON – temporary. Retry with backoff, at most a few times. Never retry an unchanged 400, 401, 403, 404 or 423. Stop the job on 403 and alert. ================================== 12. CLIENT AND OPERATIONS ================================== - One HTTP client: base URL and key from configuration, connect and total timeouts (30 to 60 s), bounded retries with exponential backoff and jitter for 429/5xx only. - Parse defensively: check that data is an array/object before use; skip and log a malformed record instead of failing the whole page. - Log endpoint, status, duration, page and run id. Never log headers, the key or full response bodies. - Metrics: last successful sync per feed, pages processed, records upserted, lots archived, failures, retry count, sync lag. - Tests with sanitized fixtures: multi-page traversal with both envelopes, both archive shapes, null and missing fields, unknown enum ids, a vehicle with lots on both sources, 400/401/403/404/423/429, retry limits, an interrupted run that resumes. ================================== 13. DELIVERABLES ================================== 1. Assumptions and a short plan for this stack, plan and catalog size. 2. Migrations or schema, the API client, and the mapping from API objects to tables. 3. Dictionary loader, initial import, hourly sync with lot-level archiving, reconcile. 4. Query layer and UI (or API) for list, filters, vehicle page and VIN/lot lookup with the price logic from section 6. 5. Tests, environment variables, scheduler setup and run instructions. 6. What you could not verify. Do not claim production readiness before real responses and limits were checked against a live key. Follow the project's existing conventions and dependencies. Make small, reviewable changes. Do not run a production import or deploy without explicit approval. ================================== 14. HOW THE API WORKS INSIDE ================================== Knowing where each value comes from avoids wrong labels and wasted requests. These notes add detail to sections 5 and 6. - Collection. Every Copart and IAAI lot is collected from the public auction sites. Vehicle data is refreshed about every 2 hours, Buy Now prices about every 15 minutes, final prices as soon as the auction reports them. Allow about 10 minutes of processing before a change is visible in the API. - One vehicle, many lots. A vehicle (VIN) is stored once. Each time it is offered at an auction it gets a lot. A lot that does not sell can run again: same lot, a new sale_date, and a new row in prices[]. - Active inventory and archive. /cars reads only active lots. When a lot leaves the auction (sold, ended, withdrawn) it moves to the archive with archived = true and archived_at, and /archived-lots reports it. Archived does not mean sold: read the status and final_bid. /search-vin and /search-lot read active and archived lots. - Final prices are checked before they are published. final_bid is set only when the lot status is on_approval (4), sold (6) or not_sold (8), and only if the price was seen within the current sale (from about 12 hours before the sale date). A price left over from an earlier run of the same lot never appears as today's result. A Buy Now purchase closes the lot with final_bid equal to the Buy Now price. final_bid_updated_at is the time that final price was seen. - bid returns final_bid once it is known; bid_updated_at still dates the last live bid. - Buy Now is tracked separately (buy_now_updated_at). Seller reserve is stored with the time it was last seen (seller_reserve.updated_at); a reserve of 0 comes back as null. - prices[] has one row per auction attempt of the lot, not one row per bid change, and it is not a complete record of every price change. In a row, bid is the final bid of that attempt when one is known. The order of rows is not guaranteed: sort by sale_date yourself. - Specifications missing at the source (body type, vehicle type, make, model, year, drive, fuel, cylinders) are filled from the US government vPIC service by VIN, only when vPIC has a value. Nothing is guessed. hp is present only when it is known. - Photos: the auction's own URLs (small, normal, big) and our stored copies (downloaded). 360 frames, interior panoramas and videos come from the auction when it has them (section 17). - /statistics returns precomputed aggregates of final bids; calculated_at says when they were computed. /manufacturers counts (cars_qty) are cached up to 24 hours. - minutes on /cars matches a vehicle when the vehicle record or any of its active lots changed in that window. The nested lots[] then contain all its active lots, not only the changed ones. - sale_date_from and sale_date_to compare calendar days (UTC), not times. - search_query works best on its own: combined with other filters it can return vehicles outside them. - /search-lot returns only the matched lot. If one lot number matches lots of different vehicles, only the first vehicle comes back: call /search-vin with its VIN for the full history. - Numbers can arrive as numeric strings (for example seller_reserve.price in /cars). Parse them, and treat anything that is not a finite number above 0 as "no value". ================================== 15. PRICE WORDING BY STATUS ================================== What a final bid means depends on the lot status: status 6 sold – -> "Sold for {final_bid}" status 4 on_approval – -> "Final bid {final_bid}, waiting for the seller's approval" status 8 not_sold – -> "Highest bid {final_bid}, not sold" (the reserve was not met) Only status 6 is a sale. Never write "sold" for 4 or 8. Open lot with no bid, no Buy Now and a future sale date, or status upcoming (10) or future (9): "Auction soon" / "Auction price: coming soon". Seller reserve: show it only while the lot is open (no final_bid, not archived), as its own line "Seller reserve". Hide it once a final price is shown, because next to a final price it reads like the sale price. Tooltip text: "The minimum price the seller will accept. If bidding ends below it, the car is not sold." Do not show its date. A finished lot whose sale date was today or yesterday can carry a "Recently finished" badge; older finished lots say "Ended". An archived lot whose sale date is more than 2 days old gets a banner: "This lot is archived. The auction ended on {date}." ================================== 16. THE VEHICLE PAGE ================================== Load: GET /search-vin/{vin}?prices_history=1 (every lot of the VIN, active and archived). When the user arrives by lot number, call /search-lot/{lot}/{domain} ?prices_history=1 first, then /search-vin with the VIN it returns, and merge lots by domain.id + lot. Which lot the page is about (the "primary lot") 1. the lot the user asked for (lot number + source), if present; 2. otherwise prefer a lot that is not finished, then the latest sale_date, then status: sale (3) > upcoming (10) / future (9) / new_auction (5) > sold (6) > not_on_sale (2) > others, then the lot with more photos, 360 or video. Use the same rule to choose the lot a catalog card shows. Header Title (vehicle.title, or year + make + model), then one spec line: engine name (e.g. 2.0 L), cylinders, hp when present, drive (rear RWD, front FWD, all AWD/4x4), transmission (automatic AT, manual MT). VIN and lot number with copy buttons, the source badge (Copart blue, IAAI red) and a link to the original listing: Copart https://www.copart.com/lot/{lot} IAAI – https://www.iaai.com/vehicledetail/{external_id} (when external_id is empty: {lot}~US) Open these in a new tab with rel="noopener nofollow". Highlights (one row each; hide a row with no value) Condition – run_and_drives (0) green; engine_starts (6), enhanced (7), used (4) neutral; not_run (3), for_repair (1), to_be_dismantled (2), unconfirmed (5) warning. 0 is a real value. Damage – main + second. Mark severe words (burn, fire, flood, water, frame, rollover, all over, biohazard) as danger; "none" as fine. Title – detailed_title.name, else title.name, with the title code. Keys – true "Present", false "Not present", null hidden. Airbags – intact / deployed / missing / none. Odometer – "{mi} mi ≈ {km} km" (km first for metric users) and the odometer status when it is not actual: not actual, exempt, exceeds mechanical limits, hours. Seller – "Insurance" when seller.is_insurance, else seller.name, else seller_type (insurance / non-insurance), else "Unknown". Show seller.logo when present. is_rental and is_credit_company can add a small "Rental" or "Finance" tag. Grade – grade_iaai when present (IAAI lots). History – the number of earlier lots of this VIN, or "First time at auction". Auction block Sale date in the user's time zone (long date, 24-hour time) and a live countdown while it is in the future ("2d 4h 15min 30sec", ticking every second; "Auction started" once it passed; none for a finished lot). "Timed auction" when is_timed_auction is true (timed_start_bid is the opening bid of a timed sale), the sale type from auction_type, the lane (line), tags as small labels, note as plain text, the branch (selling_branch.name, linked to selling_branch.link) and the location: first non-empty of location.location.name (the yard), "city, STATE" (state code), location.raw. Show "Offsite" when location.is_offsite is true, and a map pin from latitude/longitude when both exist. Valuations as their own rows when above 0: actual_cash_value "Actual cash value", pre_accident_price "Retail value", clean_wholesale_price "Clean wholesale value", estimate_repair_price "Repair estimate". They are the source's estimates, never a price anyone can buy at. Price panel (primary lot) Finished (final_bid set, or archived): only the final line from section 15, plus "Recently finished" when it applies. No current bid and no reserve. Open: "Current bid" (show 0 when there is none yet), "Buy Now" only above 0, "Seller reserve" when known, and the countdown. Upcoming with no bid and no Buy Now: a status badge and "Auction price: coming soon". Sales history (every lot of the VIN, oldest first) One row per lot, plus one row per entry of its prices[] (earlier runs of the same lot). Columns: date (sale_date, else final_bid_updated_at, else archived_at, else created_at), source badge, a photo, lot number (link to that lot's page), price (final_bid when known, else bid, else "-"; 0 only for an open lot), odometer, status (sold green; not_sold, failed, not_on_sale red; others grey) and seller. Drop a lot summary row that repeats a prices[] row with the same price and date. Hide the current lot when it is the only row. Sort by date; rows without a valid date go last. Show "History: N records" in the highlights. Market statistics (optional block) GET /statistics?manufacturer_id=&model_id=&generation_id= (cache 15 minutes). Drop rows with lot_count 0 and no average. Per year and per source (domain.value), plot the average weighted by lots: sum(avg_final_bid * max(lot_count, 1)) / sum(max(lot_count, 1)) over the last 8 years. Tiles: weighted average, the lowest min_final_bid, the highest max_final_bid, total lots, and "updated" = the latest calculated_at. Label it as past auction results, never as a valuation. Similar vehicles The same make and model from your database (or /cars with manufacturer_id and model_id), excluding this VIN, preferring lots with a future sale date. SEO One canonical address per lot, for example /cars/{domain_id}-{lot}/{title-slug}-{VIN}, with a 301 from other spellings. A bare lot number that matches exactly one lot can redirect to it; otherwise show search results. JSON-LD: Vehicle (brand, model, year, VIN, mileage, color, fuel, transmission) with an Offer: price = final_bid, else buy_now, else bid; availability SoldOut for a finished lot, InStock while it is open. ================================== 17. MEDIA: PHOTOS, VIDEO AND 360 ================================== images holds (any part can be null or empty): small[], normal[], big[] – the auction's own photo URLs in three sizes (same photos). downloaded[] – our stored copies of the photos. Stable: prefer them. exterior[] – frames of a 360-degree exterior spin, in order. interior – one URL of an interior 360 panorama (a string). external_panorama_url – the auction's own 360 page, when it has one. video – a direct video URL, when the auction has one. video_youtube_id – the YouTube id of a walk-around video, when there is one. Photos Gallery: downloaded[] first; if a source size has more photos than downloaded, append the missing ones from it. Without downloaded, use the largest of big, normal, small. Catalog card: up to 5 photos from normal (fall back to small), lazy loaded, the first ones preloaded when the browser is idle. Accept only http(s) URLs. On a broken image show a "no photo" placeholder. Full-screen viewer with thumbnails; swipe on phones. Video If video_youtube_id matches ^[A-Za-z0-9_-]{8,}$, play https://www.youtube.com/watch?v=ID (or its embed URL). Else if video is an http(s) URL ending in mp4, m4v, webm, ogg, ogv or mov, play it in an HTML5 <video>. Otherwise show no video. 360 Exterior: when exterior[] has more than one frame, build your own spinner: preload every frame, autoplay about 120 ms per frame, drag to rotate (about 8 px per frame), mouse wheel steps one frame, previous / play / next buttons and an "n / N" counter. Interior: show the interior URL in an iframe (16:9, 4:3 on phones). Otherwise use external_panorama_url in an iframe when it is an http(s) page (not an image file) that looks like a 360 view (contains 360, panorama or threesixty). Show the "360" button only when one of these exists. Never send your API key to an image or video host. ================================== 18. CATALOG DETAILS ================================== - Card: up to 5 photos (section 17), title, year, lot number, source badge, odometer with its status, primary damage, title, seller label (section 16), location (city, state), sale date in the user's time zone and a timing badge: a countdown when the sale is in the future, "Live" when the status says so and there is no date, "Ended" when finished, "Coming soon" when nothing is known. Price block from sections 6 and 15. The card shows the primary lot (section 16). - Filters: source, make > model > generation (with cars_qty), year range, body, fuel, transmission, drive, condition, colour, cylinders, damage, title, state, odometer, price range, Buy Now only, seller type, status, auction date (next 24 h -> next_hours_auction=24; from today, tomorrow or next week -> sale_date_from). Send a range only when the user changed it from its default. - Keep filters in the URL, debounce typing (about 350 ms) and give each results page a stable address. Mark unusual filter combinations noindex. - Search box: a 17-character VIN -> /search-vin; digits with an optional final letter -> /search-lot on each source the key has; merge and dedupe by source + lot. One exact match opens the vehicle page directly. - Two sources in one live list: query each domain_id, merge, dedupe by vehicle id, then cut your page. Better: serve lists from your own database (section 10). - Labels: map enum ids to your own translated labels (section 8) and fall back to the name from the response for ids you do not know yet. - Cache dictionaries (manufacturers 24 h, models and generations 1 h, statistics 15 min). Space live calls (one at a time, a few hundred ms apart) to stay inside the plan.