> ## Documentation Index
> Fetch the complete documentation index at: https://lmn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Combined inventory storefront

> Build one inventory page over LMN auction listings and dealer (fixed-price) listings: two requests, source-aware filters, one cursor per source, honest sorting, and failure states that never look like empty results.

This guide is for a storefront that shows **auction** cars and **dealer** cars on one page. It adds recipes and rendering rules on top of the endpoint contract; every behaviour it relies on is defined in [Vehicles](/endpoints/vehicles) and [Orders](/endpoints/orders), and those pages win if anything here is ever out of date.

<Note>
  Building with Lovable, Cursor or Claude? Connect the [documentation connector](/ai-builders) first and paste the **combined storefront** prompt from that page — it encodes every rule below.
</Note>

<a id="source-vs-purchase-method" />

## Source vs. purchase method

Partners regularly conflate two different axes. Keep them separate in your data model and your UI:

| Axis | Values | Where it comes from |
| - | - | - |
| **Source** — where the car is listed | `dealer` (Encar retail), `glovis`, `sk`, `aj`, `lotte`, `kcar` (auction houses) | `source` filter on the request; `source` field on every row |
| **Purchase method** — how it is bought | *Bid* (max-bid order) or *Buy now* (fixed price, no bid) | `source` + `auction_result` on the row |

| Row | `source` | `auction_result` | Purchase method | Order payload ([Orders](/endpoints/orders#post-v1-orders)) |
| - | - | - | - | - |
| Dealer car | `dealer` | `null` | **Buy now** — fixed asking price | `max_bid_amount_usd: null` |
| Auction car, upcoming round | any house | `upcoming` | **Bid** — LMN bids up to your ceiling | `max_bid_amount_usd` required, ≥ `pricing.listing_price` |
| Auction car, unsold last round | any house | `no_bid` | **Buy now** — eligible for a negotiated purchase | `max_bid_amount_usd: null` |

Two rules follow:

* A **"Buy now" toggle filters on purchase method, not on source.** Dealer cars and `no_bid` auction cars are both Buy now. Do not make dealer cars appear only when Buy now is on — they belong in "All sources" either way.
* **"All sources" means two requests**, not one request with `source` omitted. Omitting `source` returns auction listings only (see below).

## Before you start

* Base URL and key: [Quickstart](/quickstart). All examples use sandbox (`https://sandbox-api.lmnauto.com`, `lmn_stg_*` keys); production is `https://api.lmnauto.com` with an `lmn_prd_*` key and an [IP allowlist](/authentication#ip-allowlist).
* Calls are **server-side only** — the key lives in your backend (`LMN_API_KEY`), the browser talks to your backend. See [Build with AI](/ai-builders) for the rule and why.
* Every example below was executed against the live API before publishing. Values such as counts and IDs are illustrative; shapes and status codes are exact.

## Step 1 — Load the catalog, never guess names

`make`, `model` and `sub_model` must be spelled as LMN's catalog spells them. Pull the catalog once a day and build your pickers from it. Two pulls: **without `source`** the response describes auction houses *and* dealer together (the make/model pickers for a combined page); **with `source=dealer`** it adds the dealer-only `sub_models`:

```bash theme={null}
# makes + models for both sources → your pickers
curl -H "x-api-key: $LMN_API_KEY" \
  "https://sandbox-api.lmnauto.com/v1/vehicles/facets"
# dealer catalog incl. sub_models → the sub-model picker (dealer request only)
curl -H "x-api-key: $LMN_API_KEY" \
  "https://sandbox-api.lmnauto.com/v1/vehicles/facets?source=dealer"
```

```json theme={null}
{
  "makes": ["BMW", "Genesis", "Hyundai", "Kia", "Mercedes-Benz", "…"],
  "models": { "BMW": ["1 Series", "2 Series", "3 Series", "5 Series", "X5", "…"] },
  "sub_models": { "BMW": { "1 Series": ["1 Series (E82)", "1 Series (F20)", "1 Series (F40)"] } },
  "described_sources": ["dealer"],
  "vocabulary_version": "…"
}
```

* `makes` / `models` are the values `make` and `model` accept across the sources in `described_sources` — build pickers from the no-`source` pull so auction-only makes and models are not missing. `sub_models` (from the `source=dealer` pull) are the exact values `sub_model` accepts — currently for `source=dealer` only (see [filter partition](#which-filters-go-to-which-request)).
* **Facets are vocabulary, not stock.** A listed value may have no car today, and dealer stock is never observed in `availability`. Use facets to pick *accepted* values; only a list request tells you whether cars exist. Details: [`GET /v1/vehicles/facets`](/endpoints/vehicles#get-v1-vehicles-facets).
* On the **dealer** request a guessed `make`, `model` or `sub_model` returns `400 validation_error` with `details.field` and `details.value`. Auction houses accept any pair — a typo there just returns an empty page — which is exactly why pickers must come from facets rather than free text.

<a id="step-2-three-request-shapes" />

## Step 2 — Three request shapes

### Auction only

```bash theme={null}
curl -H "x-api-key: $LMN_API_KEY" \
  "https://sandbox-api.lmnauto.com/v1/vehicles?source=glovis,sk,aj,lotte,kcar&make=BMW&limit=24"
```

Works with or without `make`. Omitting `source` entirely is equivalent for the rows you get back (auction only), but naming the houses keeps the request self-documenting and your whitelist explicit.

### Dealer only

```bash theme={null}
curl -H "x-api-key: $LMN_API_KEY" \
  "https://sandbox-api.lmnauto.com/v1/vehicles?source=dealer&make=BMW&limit=24"
```

* `make` is **required** (`400 validation_error`, `details.field: "make"` without it). CSV is allowed: `make=Hyundai,Kia` fans out to one upstream search per make.
* The dealer upstream caps a page at **48 rows** regardless of `limit`; above that the response carries `X-LMN-Dealer-Page-Max: 48`.
* Every dealer response includes `source_status` for `encar` and `danawa` — read it before trusting the rows (see [Failures vs. empty results](#failures-vs-empty-results)).

### Combined page

Issue the two requests above **in parallel** and merge on your side. A single mixed request (`source=glovis,dealer`) is supported for the **first page only** — the moment you pass `cursor` with a mixed `source` you get `400 mixed_source_pagination_unsupported` — so a page that paginates must use separate requests from the start. Contract: [Mixed source — first page only](/endpoints/vehicles#mixed-source-first-page-only).

A backend function (Node 18+, any serverless runtime) that does this — each source fails, pages and ends **independently**:

```javascript theme={null}
const BASE = process.env.LMN_API_BASE_URL ?? 'https://sandbox-api.lmnauto.com';
const AUCTION_HOUSES = 'glovis,sk,aj,lotte,kcar';

// Filters each source accepts from the storefront. Anything not listed is dropped
// for that source — never forward one source's query to the other.
const COMMON = ['make', 'model', 'body_type', 'year_min', 'year_max', 'mileage_min_km', 'mileage_max_km',
  'price_min_usd', 'price_max_usd', 'fuel', 'color', 'transmission', 'options_include', 'trim', 'sort'];
// keyword and body_style are not rejected by dealer, just ignored — route them to auction only.
const AUCTION_ONLY = ['auction_status', 'auction_date_from', 'auction_date_to', 'accident_grade', 'exterior_grade',
  'auction_count_min', 'auction_count_max', 'orderable_only', 'keyword', 'body_style'];
const DEALER_ONLY = ['sub_model']; // auction rejects sub_model until taxonomy rollout

// One request to one source. NEVER throws: transport failures and non-JSON bodies
// become an error result, so one source cannot take the other down with it.
// An error result hands back the cursor it was SENT (undefined for page 1), never
// null — so feeding it into the next call retries the same page instead of marking
// the source exhausted.
async function lmn(path, params) {
  const cursorIn = params.cursor ?? undefined;
  const url = new URL(path, BASE);
  for (const [k, v] of Object.entries(params)) if (v != null && v !== '') url.searchParams.set(k, String(v));
  let res;
  try {
    res = await fetch(url, { headers: { 'x-api-key': process.env.LMN_API_KEY } });
  } catch (err) {
    return { state: 'error', status: 0, code: 'network_error', message: String(err?.message ?? err), requestId: null, rows: [], nextCursor: cursorIn };
  }
  const requestId = res.headers.get('x-request-id');
  let body = null;
  try { body = await res.json(); } catch { /* non-JSON body — handled below */ }
  if (!res.ok) {
    // The documented error envelope: { error: { code, message, request_id, details } }
    return { state: 'error', status: res.status, code: body?.error?.code ?? 'http_error', message: body?.error?.message ?? res.statusText,
      requestId: body?.error?.request_id ?? requestId, details: body?.error?.details ?? null, rows: [], nextCursor: cursorIn };
  }
  if (!body || !Array.isArray(body.data)) {
    return { state: 'error', status: res.status, code: 'malformed_response', message: 'Response body is not the documented list envelope', requestId, rows: [], nextCursor: cursorIn };
  }
  return {
    state: 'ok', status: res.status, rows: body.data,
    nextCursor: body.next_cursor ?? null,                       // null = this source is exhausted
    sourceStatus: body.source_status ?? null,                   // dealer responses only
    dealerPageMax: res.headers.get('x-lmn-dealer-page-max'),    // present when limit > 48 on dealer
    requestId,
  };
}

function pick(filters, allowed) {
  return Object.fromEntries(Object.entries(filters).filter(([k]) => allowed.includes(k)));
}

// Per-source continuation state. Three values matter:
//   undefined → not fetched yet (page 1);  a string → continue from here;  null → exhausted, do not request again.
// Reset BOTH to undefined whenever any filter or sort changes.
const EXHAUSTED = { state: 'exhausted', rows: [], nextCursor: null };
// Skipped = not requested; its cursor stays at "page 1" (undefined), not exhausted.
const SKIPPED = (reason) => ({ state: 'skipped', reason, rows: [], nextCursor: undefined });
// Held = deliberately not requested on this call (single-source retry); hands the
// stored cursor back untouched so the caller's state for that source does not move.
const HELD = (cursor) => ({ state: 'held', rows: [], nextCursor: cursor });

// options.only: 'auction' | 'dealer' — retry ONE source and hold the other.
export async function searchCombined(filters, cursors = {}, limit = 24, options = {}) {
  const { only } = options;
  const auctionReq = only === 'dealer'
    ? Promise.resolve(HELD(cursors.auction))
    : cursors.auction === null
      ? Promise.resolve(EXHAUSTED)
      : lmn('/v1/vehicles', { source: AUCTION_HOUSES, ...pick(filters, [...COMMON, ...AUCTION_ONLY]), limit, cursor: cursors.auction });
  // Dealer needs a make. Until the user picks one, skip the request and tell the UI why.
  const dealerReq = only === 'auction'
    ? Promise.resolve(HELD(cursors.dealer))
    : !filters.make
      ? Promise.resolve(SKIPPED('make_required'))
      : cursors.dealer === null
        ? Promise.resolve(EXHAUSTED)
        : lmn('/v1/vehicles', { source: 'dealer', ...pick(filters, [...COMMON, ...DEALER_ONLY]), limit, cursor: cursors.dealer });

  // lmn() never rejects, so Promise.all cannot lose the surviving source.
  const [auction, dealer] = await Promise.all([auctionReq, dealerReq]);
  return { auction, dealer };
}
```

Each side of the result carries its own `state` (`ok` / `error` / `exhausted` / `skipped` / `held`), `rows`, `nextCursor` and, for errors, `status`, `code`, `message` and `requestId` — exactly what the failure state must show. Feed each side's `nextCursor` straight back as the next call's `cursors.auction` / `cursors.dealer`: a string continues, `undefined` fetches page 1, and `null` marks that source exhausted and stops its requests while the other keeps paging. **An `error` result returns the cursor it was sent**, so the same page is retried — a failed page is never mistaken for an exhausted source. **To retry one source only** (the dealer side after a `503`, say), pass `{ only: 'dealer' }`: the dealer request is re-issued with its returned cursor, no auction request is made, and the auction side comes back as `state: 'held'` with the cursor you passed in unchanged — keep the auction rows you already rendered and only replace the dealer section.

The frontend receives `{ auction, dealer }`, each with its own `state`, `rows` and `nextCursor`, and renders them as described in [Rendering](#rendering-cards).

## Which filters go to which request

Build each request from a whitelist. Forwarding the auction query to the dealer request (or vice versa) is the single most common cause of missing dealer listings.

| Filter | Auction request | Dealer request | Notes |
| - | - | - | - |
| `make`, `model` | optional | **`make` required**; `model` optional | Values from facets. `model` needs `make` on both. |
| `year_*`, `mileage_*`, `price_*`, `fuel`, `color` | yes | yes | `color` is a single value on dealer. |
| `body_type` | yes | yes | e.g. `suv`, `truck`. [Body-type filtering](/endpoints/vehicles#body-type-filtering) |
| `transmission` | yes | yes (post-filter) | Dealer facets return `transmissions: []`; the filter still applies. |
| `options_include` | yes | yes (native, AND) | `panoramic_sunroof` and `dashcam` return `400` on dealer. [Catalog](/endpoints/vehicles#options_include-for-dealer-full-catalog) |
| `trim` | yes | yes | Any source. Exactly one `make` and one `model`; ≤30 values; names from [`GET /v1/vehicles/trims`](/endpoints/vehicles#get-v1-vehicles-trims). [Trim filter](/endpoints/vehicles#trim-filter-dealer) |
| `sub_model` | **no** — `400` on `sub_model` until auction taxonomy rollout | yes | Values from `facets.sub_models`. Omitted `source` counts as auction and also returns `400`. [Model vs. sub-model](/endpoints/vehicles#model-vs-sub-model) |
| `auction_status`, `auction_date_from` / `auction_date_to`, `accident_grade`, `exterior_grade`, `auction_count_min` / `auction_count_max` | yes | **no** — `400 validation_error` naming the field | [Dealer source constraints](/endpoints/vehicles#dealer-source-constraints) |
| `orderable_only` | yes | **no** | Returns only `upcoming` auction cars with an open order window — it removes dealer rows by definition. |
| `keyword`, `body_style` | yes | silently ignored | Dealer upstream has no equivalent — send them on the auction request only. |
| `sort` | yes | yes | See [Sorting across sources](#sorting-across-sources). |

When you filter on an auction-only field with `source` **omitted**, the API silently excludes dealer rows and (when `make` is present and the dealer upstream is reachable) adds `X-LMN-Filtered-Out: dealer=N`. That header is a best-effort *hidden-count estimate* for an auction-only request — the dealer upstream is queried to compute it, but no dealer rows are returned. Contract: [Null-strict semantics](/endpoints/vehicles#null-strict-semantics).

## Pagination

* **One cursor per source.** Store `next_cursor` from the auction response and from the dealer response separately; send each back only to the request that produced it. `next_cursor: null` means that source is exhausted — record that and **stop requesting it** while the other source keeps paging (re-requesting page 1 of an exhausted source wastes trial budget and re-yields rows you already deduped). Keep three states per source: not fetched yet, continuing from a cursor, exhausted.
* **Never pass `cursor` with a mixed `source`.** `400 mixed_source_pagination_unsupported`.
* **Reset both cursors on any filter or sort change.** Cursors encode a position in a specific result set. Reusing one after the filters change does not reliably fail — it can return page *N* of the *new* result set, silently skipping its first page. Treat "user changed anything" as "start both sources from page 1".
* **Dealer pages are at most 48 rows.** If you ask for more, the dealer half is capped and `X-LMN-Dealer-Page-Max: 48` tells you so; keep following the dealer cursor.
* **Dedupe by `id`.** Auction IDs (`glovis_…`, `sk_…`, …) and dealer IDs (`encar_…`) never collide, but a re-render that fires the same request twice will. Key your list by `id`.
* **Label every card with its source.** `row.source` is on every row; show it.

## Sorting across sources

`sort` sorts *each request's* result set. Two separately fetched pages cannot be globally sorted unless you have loaded all pages of both, so:

* Either **interleave by source** (auction block, dealer block, or alternating) and say so in the UI, or
* Sort **within the loaded window** and label it ("sorted by price *on this page*").

Do not label a merged list "sorted by price" across sources unless you have actually loaded everything. Note that the default `sort=auction_date_asc` has no meaning for dealer rows (no auction date); on the dealer request prefer `price_asc`, `price_desc`, `year_desc` or `mileage_asc`. Sort values: [Sort](/endpoints/vehicles#sort).

## Rendering cards

| Field | Show as | Notes |
| - | - | - |
| `pricing.listing_price` | **Listing price** | Whole USD. Auction: estimated final price (or start price when no comparables); dealer: asking price. [`pricing`](/schemas#pricing) |
| `pricing.breakdown.estimated_landed` | **Estimated landed cost** | Whole USD, present on list rows too. Label it as an estimate; do not present it as the price. |
| Currency | `USD` | The API returns whole US dollars only ([Conventions](/conventions#money)). Convert to a local currency yourself and label the conversion as yours. |
| `year` | Year | Model year as listed by the source. Do not present it as registration date — the API has no separate registration-year field on list rows. |
| `source` | Source badge | `dealer` vs. the auction house. |
| `auction_result` | Purchase method | `null` → Buy now (dealer); `upcoming` → Bid; `no_bid` → Buy now. See [Source vs. purchase method](#source-vs-purchase-method). |
| `trim`, `sub_model` | Secondary line | Either may be `null`; render "Unknown", never a blank or `0`. |
| Freshness | Caveat on dealer cards | Dealer rows come from a live third-party proxy; a car can sell between list and detail. Detail returns `404 vehicle_not_found` for a sold or under-contract dealer car — the under-contract case is reversible. [Dealer detail caveats](/endpoints/vehicles#dealer-detail-caveats) |

Any missing value renders as **"Unknown"**. A `0` price or a blank year is a bug on the storefront, not data.

<a id="failures-vs-empty-results" />

## Failures vs. empty results

A storefront that shows "No vehicles found" for a `401`, a `400`, or a thrown fetch hides the exact information that fixes it. Separate the three states in code and in the UI:

| State | Detect | Show |
| - | - | - |
| **Error** | non-2xx, or the fetch threw | The HTTP status, `error.code`, `error.message` and `request_id` — and a retry for `503` |
| **Empty** | `200` with `data: []` | "No cars match these filters" plus a way to broaden |
| **Stale dealer** | `200` with `source_status.encar.status: "stale"` | The dealer rows (cached, ≤30 min old) with an "as of `as_of`" caveat; re-check availability before relying on a car |
| **Degraded dealer** | `200` with `source_status.encar.status` of `partial` or `unavailable`, plus `X-LMN-Partial-Dealer-Unavailable: 1` and `X-LMN-Dealer-Unavailable-Sources` — on a mixed request *or* a dealer-only request where only one dealer source failed | Whatever rows came back, a "dealer listings temporarily unavailable" notice, and a retry of the dealer request only |

Read `source_status` on every dealer response: `fresh` is live; `stale` is a cached page up to 30 minutes old (show `as_of`); `partial` means some of a multi-make query answered; `unavailable` means none did. A dealer-only request returns `503 dealer_upstream_unavailable` only when every dealer source is down. Contract: [Dealer upstream unavailability](/endpoints/vehicles#dealer-upstream-unavailability). Per-symptom fixes: [Missing dealer listings](/troubleshooting-dealer-listings).

## Integration acceptance checklist

"Vehicles render" is not a completion criterion. Work through each line with a reproducible filter (BMW is a good one: both sources usually have stock) and keep the actual responses as evidence.

* [ ] **Auction only:** `source=glovis,sk,aj,lotte,kcar&make=BMW&limit=24` returns `200` with ≥1 row and `next_cursor`.
* [ ] **Dealer only:** `source=dealer&make=BMW&limit=24` returns `200`, `source_status.encar.status` is `fresh` or `stale`, and ≥1 row. (`partial`/`unavailable` is a retry-later, not a failed integration.)
* [ ] **Combined page issues two requests** — confirm in your backend logs or network tab that both were sent, in parallel.
* [ ] **Page 2 per source:** the auction cursor fetches auction page 2; the dealer cursor fetches dealer page 2; neither cursor is ever sent with a mixed `source`.
* [ ] **Filter change resets both cursors** — change `model` and confirm both requests go out without `cursor`.
* [ ] **Missing-make UX:** with no make selected, the dealer request is not sent and the page shows the "select a make to include dealer listings" affordance — not an empty grid, not "No vehicles found".
* [ ] **Failure state:** temporarily use a wrong key. The page must show the `401 invalid_api_key` error with a `request_id`, not "No vehicles found". Restore the key.
* [ ] **Labels:** every card shows its source, the listing price labelled as such, the estimated landed cost labelled as an estimate, and `USD`.
* [ ] **Unknowns:** a row with `trim: null` renders "Unknown".
* [ ] **No key in the frontend:** search the built client bundle for `lmn_stg_` and `lmn_prd_` — zero hits.

**Zero dealer rows is not a failed check** when `source_status.encar` is healthy — a narrow filter can legitimately match nothing. Broaden (drop `model`, widen year or price, or try a high-volume make such as Hyundai or Kia) until you get a positive dealer result once; then re-narrow.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.