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

# Eagle Eye

> Search, save, and receive alerts for dealer buying conditions across Korean auction and dealer inventory.

Eagle Eye is LMN's saved-condition and alerting surface. It lets a partner preview a buying condition, save it as a watch, review the current match list, and receive webhook batches when new opportunities or changes appear.

For product context, see the partner-facing overview: [Eagle Eye overview](/eagle-eye).

## API surfaces

| Surface                | Endpoint                                                                     | What it does                                                                                                                                                                                                                      |
| ---------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Search preview         | `POST /v1/eagle-eye/search`                                                  | Evaluate a condition once and return current matches.                                                                                                                                                                             |
| Saved watches          | `POST /v1/eagle-eye/watches`                                                 | Save a condition and seed its current match list.                                                                                                                                                                                 |
| Watch management       | `GET /v1/eagle-eye/watches`, `GET /v1/eagle-eye/watches/{watch_id}`          | List and inspect watches. To retire a watch, pause it (`POST …/pause`).                                                                                                                                                           |
| Watch controls         | `POST /v1/eagle-eye/watches/{watch_id}/mute`, `/unmute`, `/pause`, `/resume` | Control notifications and collection.                                                                                                                                                                                             |
| Match inbox            | `GET /v1/eagle-eye/watches/{watch_id}/matches`                               | Read current matches for a watch.                                                                                                                                                                                                 |
| Match triage           | `POST /v1/eagle-eye/watches/{watch_id}/matches/hide`, `/matches/unhide`      | Hide or restore reviewed vehicles in bulk.                                                                                                                                                                                        |
| Static list membership | `POST /v1/eagle-eye/watches/{watch_id}/matches`, `/matches/remove`           | Add or remove specific cars on a static list (`automatic_list_update: false`). See [Static lists](#static-lists).                                                                                                                 |
| Event recovery         | `GET /v1/eagle-eye/watches/{watch_id}/events`                                | Read the webhook history for a watch. `delivery_status` is `delivered` / `pending` / `failed` — a **narrower** set than the order event log uses. See [the vocabulary comparison](/webhooks/overview#self-service-event-history). |

`PUT` and `DELETE /v1/eagle-eye/watches/{watch_id}` were removed from the partner surface — see the [changelog](/changelog) for details. There is no partner-facing way to rename a watch or edit its filters; create a new watch with the desired configuration and pause the old one.

## Schema reuse

Eagle Eye reuses the existing Open API vehicle model. Search results and webhook `additions` / `price_changes` embed the same `VehicleSummary` shape returned by `GET /v1/vehicles`; Eagle Eye metadata such as `watch_id`, `match_reason`, and `signal_detail` wraps that vehicle object instead of replacing it.

Match rows include a `vehicle` snapshot with that same `VehicleSummary` shape when LMN has captured one for the match. For older or not-yet-refreshed rows, `vehicle` can be `null`; use `vehicle_id` with `GET /v1/vehicles/{id}` as the fallback.

The `vehicle_id` in a match is the same ID used by `GET /v1/vehicles/{id}` and the existing order API.

## Webhook

Eagle Eye emits one webhook type:

```text theme={null}
eagle_eye.match
```

See [webhook event types](/webhooks/event-types#eagle_eye-match) for the full payload shape, and [webhook signing & retries](/webhooks/signing-and-retries) for delivery behavior.

The event is batched and can include:

* `additions` — new vehicles that matched the watch,
* `price_changes` — watched vehicles whose price dropped enough to notify,
* `removals` — vehicles that disappeared or no longer qualify.

Signal matches include `signal_detail` so the partner can explain the alert:

| Signal           | Detail                                                       |
| ---------------- | ------------------------------------------------------------ |
| `repeat_listing` | `listing_count`, `cumulative_drop_pct`, and `window_days`.   |
| `undervalued`    | `reference_price_krw`, `listing_price_krw`, and `delta_pct`. |
| `price_drop`     | `previous_price_krw`, `current_price_krw`, and `drop_pct`.   |

`price_drop` is drop-only. If a vehicle price increases while still qualifying, the current match updates silently and loses the stale `signals.price_drop` reason. If the increase makes it fail the watch's filters, such as rising above `price_max_krw`, LMN sends a `removal` instead.

Webhook delivery uses the same HMAC signing and retry behavior as order webhooks.

### Testing your receiver — `POST /v1/eagle-eye/watches/{watch_id}/test-webhook`

**Sandbox only.** Sandbox has no automatic Eagle Eye evaluators, so this endpoint
is the way to make sandbox emit an `eagle_eye.match` webhook on demand — it is the
primary integration-testing mechanism, not a convenience.

```bash theme={null}
curl -X POST "https://sandbox-api.lmnauto.com/v1/eagle-eye/watches/{watch_id}/test-webhook" \
  -H "x-api-key: $SANDBOX_API_KEY" \
  -H "content-type: application/json" \
  -d '{"vehicle_id": "glovis_12345"}'
```

The vehicle must currently be on the watch. The event rides the **real delivery
pipeline** — same outbox, same cron, same `X-LMN-Signature`/`X-LMN-Timestamp`/`X-LMN-Event-Id`
headers, same retries — so it exercises exactly what production delivery exercises.
The payload is a normal `eagle_eye.match` with one `additions` entry (the stored
vehicle summary) plus **`data.is_test: true`**. Always check `is_test` before
letting downstream automation act on an addition — it is present on every test
event and never on organic ones.

Responses:

| Status | Meaning                                                                                                                                                                                                                                                                                                                                                                                    |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `200`  | `{ "event_id": "evt_..." }` — confirm delivery via `GET /v1/eagle-eye/watches/{watch_id}/events` (`delivery_status`: `delivered` / `pending` / `failed` — note `failed` here covers both "your endpoint rejected it" and "no URL registered"; see [the vocabulary comparison](/webhooks/overview#self-service-event-history)).                                                             |
| `403`  | Production key or non-sandbox host — the endpoint is sandbox-only.                                                                                                                                                                                                                                                                                                                         |
| `404`  | Watch not found, or the vehicle is not on this watch.                                                                                                                                                                                                                                                                                                                                      |
| `409`  | No webhook endpoint registered for sandbox (register one first), or the vehicle row has no stored summary to build a test payload from.                                                                                                                                                                                                                                                    |
| `429`  | `rate_limited` — 5 test fires per minute per watch, rolling window. **Transient**: retry after \~60s. No `Retry-After` header and no `error.details`; that missing `details` is what distinguishes it from a [trial budget exhaustion](/errors#trial-budget-exhausted), which shares the same code but never clears. See [Telling the two 429s apart](/errors#telling-the-two-429s-apart). |
| `503`  | `webhook_config_unavailable` — your endpoint **is** registered; delivery is temporarily unavailable on our side. Retry later.                                                                                                                                                                                                                                                              |

Test fires are allowed on muted and paused watches (an explicit test is user
intent; mute/pause gates only automatic evaluation). The same action is available
per-vehicle in the partner console's sandbox view.

## Acting on a match

Eagle Eye does not create orders directly. A match carries the same `vehicle_id` used by the existing order API.

When the dealer wants to act, send that `vehicle_id` to `POST /v1/orders`.

***

## Static lists

Most watches are **automatic**: LMN adds and removes cars for you based on `filters` (and optionally `signals`). A **static list** flips that model — you control membership car-by-car, and LMN only *monitors* the cars you've added; it never adds or removes any on its own.

Static lists exist for one core use case: a dealer favorites a specific car, LMN watches it, and you get a webhook the moment its price is set or changes — including a car that had no price yet at all ("first-price-set"), a transition automatic watches deliberately never report (see [`eagle_eye.match`](/webhooks/event-types#eagle_eye-match) for why).

<Note>
  **Static lists accept both auction and dealer (encar) vehicles.** Membership routing is per-id: auction ids (`glovis_…`, `sk_…`, …) resolve against live auction inventory; `encar_…` ids resolve against live dealer inventory. One list can mix both. Per-source behavior — refresh trigger, removal reason, error surfaces — is summarized in [Tracking dealer-source vehicles](#tracking-dealer-source-vehicles).
</Note>

### Creating a static list

Pass `automatic_list_update: false` when creating the watch:

```json theme={null}
// POST /v1/eagle-eye/watches
{
  "name": "Shekel favorites",
  "filters": {
    "source": ["glovis", "sk", "aj", "lotte", "kcar"]
  },
  "automatic_list_update": false
}
```

<ParamField body="automatic_list_update" type="boolean" default="true">
  `true` (default, or omitted) — LMN adds and removes cars automatically based on `filters` / `signals` / `flexibility`, exactly as documented elsewhere on this page.

  `false` — creates a **static list**. Only `filters.source` is honored, and every source token is accepted — auction (`glovis`, `sk`, `aj`, `lotte`, `kcar`) and `dealer` alike. `source` is **declarative** on a static list: membership is decided per-id at add time, not gated by it. Every other `filters` key, plus `signals` and `flexibility`, returns `400 validation_error` naming the offending field — inert config is rejected rather than silently stored. `filters.make` / `filters.model` are **not required** on a static list. The watch is **born empty**: nothing is seeded, `active_match_count` starts at `0`.
</ParamField>

A static list still counts against the 100-active-watch cap and supports the same `/mute`, `/unmute`, `/pause`, `/resume` controls and `GET …/matches` as an automatic watch. `POST …/matches/hide` / `/matches/unhide` also work the same way — hiding is a read-side mute (a hidden car keeps refreshing its price silently but stops appearing in the default match list and stops notifying); it is not the same as removing a car (below). Only the membership mechanism — how cars enter and leave the list — differs.

### Adding cars — `POST /v1/eagle-eye/watches/{watch_id}/matches`

```json theme={null}
// request — auction and dealer ids can ride the same batch
{
  "vehicle_ids": ["glovis_20260703_1234_5678", "encar_41509086", "sk_20260703_88_1001"]
}
```

```json theme={null}
// response 200
{
  "added": [
    { "vehicle_id": "glovis_20260703_1234_5678", "price_krw": null },
    { "vehicle_id": "encar_41509086", "price_krw": 14500000 },
    { "vehicle_id": "sk_20260703_88_1001", "price_krw": 32500000 }
  ],
  "already_in_list": [],
  "not_found": [],
  "unavailable": []
}
```

Each id is resolved live at add time: auction ids against live auction inventory, `encar_…` ids against live dealer inventory. A live auction listing that has no price yet resolves normally — `price_krw: null` in `added` — because tracking an un-priced car until it gets a price is exactly what a static list is for (dealer listings always carry a price). An id that isn't currently live (typo, delisted, sold — either source) lands in `not_found`; a dealer (`encar_…`) id currently under contract on Encar lands in `not_found` too — it isn't a dead id, so add it again once the contract clears. An unresolvable id is never an error for the batch.

**`unavailable`** is the third bucket, for dealer ids only: LMN could not *check* the car this moment — a transient upstream failure, or a listing state LMN doesn't recognize yet. Unlike `not_found`, it is not a verdict about the car — retry the same id later. Auction ids never land here.

* `400 eagle_eye_watch_not_static` — the watch is an automatic watch (`automatic_list_update: true`). Cars can only be added to static lists; use `/matches/hide` to stop showing a car on an automatic watch instead.
* `400 eagle_eye_match_batch_too_large` — `vehicle_ids` exceeds the per-call cap of **30**. Paginate `GET …/matches` and call again in batches.
* `400 validation_error` — adding the resolved cars would exceed the **500-member** per-list cap (`unavailable` ids don't count). See membership rules below.
* `404 eagle_eye_watch_not_found` — unknown or cross-tenant `watch_id`.

No webhook is sent for an add — you performed the action yourself.

### Removing cars — `POST /v1/eagle-eye/watches/{watch_id}/matches/remove`

```json theme={null}
// request
{
  "vehicle_ids": ["glovis_20260703_1234_5678", "sk_99999999"]
}
```

```json theme={null}
// response 200
{
  "removed": ["glovis_20260703_1234_5678"],
  "not_in_list": ["sk_99999999"]
}
```

Removal **hard-deletes** the match row — the car stops being monitored entirely and `active_match_count` decrements. This is different from hiding: `POST …/matches/hide` mutes a car (it keeps refreshing its price silently and can be restored with `/matches/unhide`), while removal is permanent — to watch the same car again you'd add it back and it starts over with a fresh `first_matched_at`.

Same static-only gate (`400 eagle_eye_watch_not_static`) and per-call cap (`400 eagle_eye_match_batch_too_large`, 30) as adding. No webhook is sent for a remove.

### Membership rules

<Note>
  **The 500-member list cap counts hidden members too.** A static list's size is checked against total membership, not just visible/active rows — hidden entries still occupy a slot because they keep refreshing silently in the background and can be unhidden at any time. To reclaim space, unhide-then-decide or remove cars you no longer want monitored at all; adding more members never bumps a hidden one out automatically.
</Note>

<Note>
  **Re-adding a car that's already in the list — hidden or not — answers `already_in_list` and does not change its hidden state.** Adding is not an un-hide affordance. `POST …/matches/unhide` is the only way to restore a hidden car to the default match-list view.
</Note>

### The favorites recipe

The motivating use case for static lists is un-priced auction listings:

1. A dealer favorites a car in your app that's currently published on a KR auction source without a price.
2. You add it to a static list: `POST /v1/eagle-eye/watches/{watch_id}/matches` with the car's `vehicle_id`. It's added with `price_krw: null`.
3. When the auction source later publishes a price for that car, LMN's next ingest pass detects the change and sends `eagle_eye.match` with the car in `price_changes` — `previous.price_krw: null`, the new price populated, `match_reason: ["manual"]`. See [static-list emission](/webhooks/event-types#static-lists) for the full rule, including why price *rises* also notify here (unlike automatic watches).
4. Show the dealer the price and let them act via `POST /v1/orders` as usual.

The same recipe covers **dealer favorites**: a dealer favorites an encar car in your app, you add its `encar_…` id to the list, and LMN notifies on every subsequent price change and on delisting. The only difference is that dealer listings already carry a price at add time, so the flow starts at "watch for changes" instead of "wait for the first price".

### Integration guidance

* **One shared static list per environment** is enough for most integrations. Watches are partner-scoped, not dealer-scoped — eagle-eye tracks the cars, not who favorited them — so car→dealer mapping stays on your side. Per-dealer watch scoping is not available in v1.
* Static lists count against the same **100-active-watch cap** as automatic watches (per partner, per environment). A handful of static lists (e.g. one per environment) leaves ample room for automatic watches.
* To retire a static list, **pause it** (`POST …/pause`) rather than removing every car — `PUT` / `DELETE /v1/eagle-eye/watches/{watch_id}` no longer exist for partners.

### Tracking dealer-source vehicles

Dealer (encar) vehicles are supported on **both** tracking surfaces:

* **Static lists** — add specific dealer cars by id (`encar_…`) exactly like auction cars. LMN re-checks each dealer member against live dealer inventory **hourly**: any price change (drops *and* rises, per the static emission rule) arrives in `price_changes`, a car that leaves dealer inventory arrives in `removals` with `reason: "delisted"`, and a car that goes under contract on Encar arrives in `removals` with `reason: "under_contract"` — reversible: if the contract falls through, LMN's next hourly check restores it as a new `additions` entry, not a new event type. If a member can't be checked on a given pass (transient upstream failure), it is skipped and re-checked next hour — a hiccup never delists a tracked car.
* **Automatic watches** — include `"dealer"` in `filters.source` (`make` is required — `400 eagle_eye_filters_required` without it) and LMN polls dealer inventory for the filters hourly: new matches, price drops after a baseline, removals. A dealer car that goes under contract is removed here too, but reported as `reason: "delisted"` — automatic watches don't distinguish a contract from a genuine sale (see [Removal reasons](/webhooks/event-types#removal-reasons)). It can still return as a plain new match once it's purchasable again.

Source-specific behavior at a glance:

| Behavior                                    | Auction (`glovis` / `sk` / `aj` / `lotte` / `kcar`) | Dealer (encar)                                        |
| ------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- |
| Automatic watches (`filters.source`)        | Supported                                           | Supported — `make` required                           |
| Static-list membership (`POST …/matches`)   | Supported                                           | Supported — ids resolve against live dealer inventory |
| Static member refresh trigger               | Per auction scrape batch                            | Hourly                                                |
| Static removal reason                       | `auction_complete`                                  | `delisted` or `under_contract` (reversible)           |
| `unavailable` add-response bucket           | Never                                               | On transient dealer-lookup failure                    |
| First-price-set notification (static lists) | Supported — the core un-priced-listing use case     | Not applicable — dealer listings always carry a price |
| Price-change notifications                  | Supported                                           | Supported                                             |
| `repeat_listing` / `undervalued` signals    | Supported                                           | Not supported — auction-first in v1                   |

Mixed lists and mixed watches (`source: ["dealer", "glovis", …]`) are both supported. On a mixed static list the two member groups refresh on their own triggers (auction: per scrape batch; dealer: hourly) but share the same watch, match list, and webhook stream — no coordination needed on your side. For filter semantics that differ by source, see [Damage filter semantics](#damage-filter-semantics).

If you prefer criteria-based tracking of a dealer segment rather than hand-picking cars, a narrowly scoped automatic watch remains a good pattern:

```json theme={null}
// POST /v1/eagle-eye/watches — narrow dealer watch around one buying brief
{
  "name": "GLE-Class for Musa",
  "filters": {
    "source": ["dealer"],
    "make": "Mercedes-Benz",
    "model": "GLE-Class",
    "year_min": 2021,
    "mileage_max_km": 45000,
    "price_max_krw": 62000000
  }
}
```

***

## Filter reference

### New filter fields (search and watch bodies)

The following fields are accepted in the `filters` block of `POST /v1/eagle-eye/search` and `POST /v1/eagle-eye/watches`:

<ParamField body="fuel" type="string">
  Single lowercase fuel token. Accepted values: `gasoline`, `diesel`, `lpg`, `hybrid`, `gasoline_hybrid`, `diesel_hybrid`, `electric`, `plug_in_hybrid`, `hydrogen`.

  Supply exactly one token — an array or an unrecognized token returns `400 validation_error`. For multiple fuels, use `fuel_in` instead.
</ParamField>

<ParamField body="fuel_in" type="string[]">
  Multiple fuel tokens, OR-ed. Same accepted values as `fuel`; must be non-empty when present. Fuel is applied as a post-filter over fetched listings on every source, so OR-ing multiple fuels has no upstream cost.
</ParamField>

<ParamField body="no_accident" type="boolean">
  `true` filters to clean cars only. `false` or absent applies no filter.

  **Per-source semantics differ** — see the [damage filter semantics](#damage-filter-semantics) section below before using this field across mixed sources. For frame-sensitive buyers, `no_frame_damage` is the source-equivalent alternative.
</ParamField>

<ParamField body="no_frame_damage" type="boolean">
  `true` excludes cars with structural frame damage; exterior panel repair is permitted. `false` or absent applies no filter.

  This filter is **source-equivalent** — it maps to the same structural-integrity standard on both dealer and auction sources. Recommended for buyers who care about frame integrity but accept repaired exterior panels.
</ParamField>

<ParamField body="estimated_landed_max_usd" type="number">
  Maximum estimated landed cost in USD (positive number). Computed per-vehicle using your partner's FX rate and freight configuration.

  Two implications:

  * **Null exclusion:** vehicles whose landed cost cannot be computed are excluded when this filter is active.
  * **FX drift:** a pure exchange-rate move (no listing price change) can admit or remove vehicles from an active watch. Set your bound with a buffer for normal FX volatility, or use the `flexibility.fields.estimated_landed_max_usd` tolerance (see [flexibility](#buyer-fit-tolerance-flexibility)).
</ParamField>

### Damage filter semantics

`no_accident` and `no_frame_damage` map to different underlying predicates depending on inventory source:

| Filter                  | Dealer (encar)                                                            | Auction (Glovis / SK / AJ / Lotte / KCar)                                         |
| ----------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `no_accident: true`     | Strictly 무사고 — **zero** frame damage AND **zero** exterior repair records | Grade A only — no structural panel exchange; exterior panel work is **permitted** |
| `no_frame_damage: true` | 무사고 or 단순수리 — zero frame exchange; simple exterior repair accepted        | Grades A + B — no structural exchange in either grade                             |

**D7 asymmetry:** `no_accident` is not semantically equivalent across sources. On dealer it means zero exterior repairs; on auction it means no structural exchange (exterior work is permitted because grade A is the strongest clean-accident classification auction sources express). **`no_frame_damage` is source-equivalent** and is the recommended filter for buyers who need a consistent structural-integrity guarantee across both dealer and auction inventory.

<Note>
  Approximately 3% of dealer stock lacks a valid vehicle inspection record (성능기록부). When either damage filter is active, those cars are **excluded** — unknown condition is treated conservatively as not qualifying. This exclusion is not surfaced as a separate indicator in the response.
</Note>

If both a damage boolean (`no_accident` or `no_frame_damage`) and `accident_grade_in` are present in the same request, the engine intersects the two. A contradictory combination — for example `no_accident: true` with `accident_grade_in: ["B"]` — yields zero matches rather than a `400` error.

***

## Buyer-fit tolerance (flexibility)

`flexibility` is an optional top-level block in the search and watch request body. It widens numeric filter bounds and expands categorical filter alternatives so a watch catches cars that fall just outside its strict criteria. When absent, all filters are applied strictly.

Flex matches are tagged `match_type: "flex"` in every response surface (search rows, match list items, and webhook entries). Strict matches carry `match_type: "exact"`.

### Request shape

```json theme={null}
{
  "filters": {
    "source": ["dealer"],
    "make": "Hyundai",
    "model": "Santa Fe",
    "fuel": "gasoline",
    "year_min": 2020,
    "mileage_max_km": 225308,
    "estimated_landed_max_usd": 13700,
    "no_frame_damage": true
  },
  "flexibility": {
    "default": "none",
    "fields": {
      "estimated_landed_max_usd": { "level": "medium" },
      "mileage_max_km":           { "level": "small" },
      "year_min":                 { "level": "none" },
      "fuel":                     { "level": "none" },
      "no_frame_damage":          { "level": "none" },
      "model": {
        "level": "small",
        "allowed_values": ["Santa Fe Hybrid"]
      }
    }
  }
}
```

<ParamField body="flexibility.default" type="FlexLevel">
  Applies to all **numeric and year** filter fields that are present in the request and not overridden by `flexibility.fields`. Does **not** apply to categorical fields (`model`, `trim`, `fuel`). Accepted values: `none` | `small` | `medium` | `large`. Default `none`.
</ParamField>

<ParamField body="flexibility.fields" type="object">
  Per-field overrides. Each key must reference a filter field that is present in the same request — a key referencing an absent filter returns `400 eagle_eye_invalid_flexibility`.
</ParamField>

<ParamField body="flexibility.fields[field].level" type="FlexLevel" required>
  Tolerance level for this field. `none` | `small` | `medium` | `large`.
</ParamField>

<ParamField body="flexibility.fields[field].allowed_values" type="string[]">
  For categorical fields (`model`, `trim`, `fuel`) only. Each value creates one additional query variant. Required when `level != 'none'` on a categorical field. Maximum 4 values per field.
</ParamField>

### Numeric and year tolerance

These fields accept a `level` tolerance: `year_min`, `year_max`, `mileage_max_km`, `price_max_krw`, `estimated_landed_max_usd`, `auction_count_min`, `auction_count_max`.

| Level    | Percent tolerance (max bounds) | Year slack |
| -------- | ------------------------------ | ---------- |
| `none`   | 0%                             | ±0 years   |
| `small`  | +2% (floor)                    | ±0 years   |
| `medium` | +5% (floor)                    | ±1 year    |
| `large`  | +10% (floor)                   | ±2 years   |

Year fields always use the slack column. Non-year max bounds apply `floor(value × (1 + tolerance))`; min bounds apply `ceil(value × (1 − tolerance))`.

**Santa Fe example** — strict `estimated_landed_max_usd: 13700` with `level: "medium"`:

* Effective bound: `floor(13700 × 1.05) = 14385`
* A vehicle priced at \$13,782 is admitted and tagged `match_type: "flex"`
* A vehicle priced at \$13,200 is admitted and tagged `match_type: "exact"`

### Categorical tolerance

Categorical fields (`model`, `trim`, `fuel`) do not have a numeric percentage tolerance. To widen them, supply `allowed_values` — each value creates one additional search variant that runs alongside the strict base query.

`level` must be `none` (explicit no-op) or paired with non-empty `allowed_values`. Supplying `level: "small"` (or any non-`none` level) without `allowed_values` on a categorical field returns `400 eagle_eye_invalid_flexibility`.

### Non-flexable fields

`no_accident`, `no_frame_damage`, `source`, and `make` only accept `level: "none"`. This serves as an explicit per-field override of `flexibility.default` — useful to pin a boolean outside the default without setting a tolerance. Passing any other level for these fields returns `400 eagle_eye_invalid_flexibility`.

### Caps

| Limit                                                                               | Value |
| ----------------------------------------------------------------------------------- | ----- |
| `allowed_values` per categorical field                                              | 4     |
| Total variant product (`∏ (len(allowed_values) + 1)` across all categorical fields) | 8     |

Exceeding either cap returns `400 eagle_eye_invalid_flexibility` with `details.field` naming the offending field.

***

## Response metadata — match\_type and flex\_detail

Every match-bearing surface now carries `match_type`, plus `flex_detail` describing any applied tolerance — surfaces differ on whether an exact match omits `flex_detail` or includes it as `null`, so see each surface's example below for its exact-match shape. These fields are orthogonal to `match_reason` — `match_reason` tells you **why** a vehicle is in the watch (filters / signals); `match_type` / `flex_detail` tell you **how closely** it matched.

### Search row

<ResponseField name="match_type" type="&#x22;exact&#x22; | &#x22;flex&#x22;" required>
  `"exact"` when all filters matched strictly. `"flex"` when at least one filter matched only within its tolerance.
</ResponseField>

<ResponseField name="flex_detail" type="object">
  Present only when `match_type` is `"flex"`. One key per field that required the tolerance. Each value is either a numeric entry or a categorical entry:

  **Numeric entry:** `{ requested: number, level: string, effective: number, actual: number }`

  **Categorical entry:** `{ requested: string, allowed_values: string[], actual: string }`
</ResponseField>

**Numeric flex example** — landed cost just above the strict limit:

```json theme={null}
{
  "id": "encar_12345",
  "source": "dealer",
  "pricing": {
    "breakdown": { "estimated_landed": 13782 }
  },
  "match_type": "flex",
  "flex_detail": {
    "estimated_landed_max_usd": {
      "requested": 13700,
      "level": "medium",
      "effective": 14385,
      "actual": 13782
    }
  }
}
```

**Categorical flex example** — alternative model admitted:

```json theme={null}
{
  "id": "encar_67890",
  "source": "dealer",
  "match_type": "flex",
  "flex_detail": {
    "model": {
      "requested": "Santa Fe",
      "allowed_values": ["Santa Fe Hybrid"],
      "actual": "Santa Fe Hybrid 1.6T"
    }
  }
}
```

An exact match returns `"match_type": "exact"` with no `flex_detail` key.

### Match list item

`GET /v1/eagle-eye/watches/{watch_id}/matches` now includes `match_type` and `flex_detail` on every row. Unlike search rows, match list items **always** include the `flex_detail` key; it is `null` for exact matches.

```json theme={null}
{
  "vehicle": {
    "id": "encar_12345",
    "source": "dealer",
    "make": "Hyundai",
    "model": "Santa Fe",
    "year": 2020,
    "mileage_km": 64120,
    "pricing": {
      "listing_price": 13200,
      "sold_price": null,
      "discount_config": {
        "bid_threshold": 0,
        "rate_below": 0,
        "rate_at_or_above": 0,
        "fixed_discount_amount": 300,
        "vehicle_discount_pct": 0
      },
      "breakdown": {
        "auction_fee": 0,
        "dealer_fee": 300,
        "lmn_commission": 1300,
        "ocean_freight": 1500,
        "estimated_landed": 13782
      }
    }
  },
  "vehicle_id": "encar_12345",
  "source": "dealer",
  "price_krw": 46000000,
  "match_reason": ["filters"],
  "signal_detail": null,
  "match_type": "flex",
  "flex_detail": {
    "estimated_landed_max_usd": {
      "requested": 13700,
      "level": "medium",
      "effective": 14385,
      "actual": 13782
    }
  },
  "first_matched_at": "2026-06-11T09:00:00+09:00",
  "last_seen_at": "2026-06-11T09:00:00+09:00",
  "hidden": false
}
```

### Webhook eagle\_eye.match

`additions` and `price_changes` entries in the `eagle_eye.match` webhook now carry `match_type` and `flex_detail`:

```json theme={null}
{
  "type": "eagle_eye.match",
  "data": {
    "additions": [
      {
        "vehicle": { "id": "encar_12345", "source": "dealer" },
        "match_reason": ["filters"],
        "match_type": "flex",
        "flex_detail": {
          "estimated_landed_max_usd": {
            "requested": 13700,
            "level": "medium",
            "effective": 14385,
            "actual": 13782
          }
        }
      }
    ],
    "price_changes": [
      {
        "vehicle": { "id": "encar_12345", "source": "dealer" },
        "previous": { "price_krw": 47000000, "observed_at": "2026-06-10T09:00:00+09:00" },
        "match_reason": ["filters", "signals.price_drop"],
        "match_type": "flex",
        "flex_detail": {
          "estimated_landed_max_usd": {
            "requested": 13700,
            "level": "medium",
            "effective": 14385,
            "actual": 13782
          }
        }
      }
    ],
    "removals": []
  }
}
```

Exact-match webhook entries omit the `flex_detail` key (same omission behavior as search rows). `removals` entries are not changed — they carry only `vehicle_id` and `reason`.

### Watch response

The watch resource (`GET`/`POST /v1/eagle-eye/watches`) now echoes back the stored `flexibility` configuration:

```json theme={null}
{
  "id": "wat_01HZEXAMPLE",
  "status": "active",
  "filters": { "source": ["dealer"], "make": "Hyundai", "model": "Santa Fe" },
  "flexibility": {
    "default": "none",
    "fields": {
      "estimated_landed_max_usd": { "level": "medium" }
    }
  }
}
```

`flexibility` is `null` when not configured. There is no partner-facing update path for `flexibility` (or any other filter/signal config) after creation — `PUT /v1/eagle-eye/watches/{watch_id}` has been removed (see the [changelog](/changelog)). Create a new watch with the desired configuration and pause the old one.

***

## Eagle Eye error codes

| Code                                  | HTTP | When                                                                                                                                                                                                                                                                                                     |
| ------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eagle_eye_filters_required`          | 400  | `filters` block absent; neither `make` nor `model` present; or `make` missing when `source` includes `dealer`.                                                                                                                                                                                           |
| `eagle_eye_invalid_filter_for_dealer` | 400  | Auction-only filter present when `source` includes `dealer` (e.g., `accident_grade_in`, `auction_count_min`).                                                                                                                                                                                            |
| `eagle_eye_invalid_flexibility`       | 400  | `flexibility` block is structurally invalid: a field key references an absent filter, `level != 'none'` on a non-flexable field, `allowed_values` on a non-categorical field, categorical non-`none` level without `allowed_values`, or variant cap exceeded. `details.field` names the offending field. |
| `eagle_eye_invalid_signals`           | 400  | Signal bound or shape violation (e.g., `min_listings` out of range).                                                                                                                                                                                                                                     |
| `eagle_eye_watch_not_static`          | 400  | `POST …/matches` or `POST …/matches/remove` called on an automatic watch (`automatic_list_update: true`). These endpoints only operate on static lists.                                                                                                                                                  |
| `eagle_eye_match_batch_too_large`     | 400  | `vehicle_ids` on `POST …/matches` or `POST …/matches/remove` exceeds the per-call cap of 30. Paginate and call again.                                                                                                                                                                                    |
| `eagle_eye_watch_limit`               | 429  | Watch cap reached — 100 active watches per environment. Delete or pause watches, then retry `POST /v1/eagle-eye/watches`.                                                                                                                                                                                |
