# API reference — Used Car Deal Finder AI
Reference tables for integrators, moved out of the Store page. Back to the [listing](../../README.md).

## Flags (`targetFlags` values)

<details><summary>Flags — put the name into <code>targetFlags</code> (<code>wellBelowMarket</code> works too)</summary>

| Flag (`targetFlags` value) | Meaning |
|---|---|
| `well_below_market` | priced 15 % or more under the median of comparable cars in the run |
| `below_market` | priced 8–15 % under the median of comparable cars in the run |
| `price_rating_agrees` | mobile.de's own price rating also says a good price |
| `price_rating_disagrees` | priced under market here, but mobile.de rates the price fair, increased or high |
| `no_baseline` | too few comparable cars in the run for a market median |
| `low_mileage` | under 10,000 km per year of age |
| `single_owner` | one previous owner |
| `clean_history` | accident-free, not damaged, roadworthy — as stated in the listing |
| `private_seller` | sold by a private seller (no dealer warranty) |
| `price_dropped` | the price fell 5 % or more since you last received this car |
| `accident_reported` | the listing says the car had an accident |
| `currently_damaged` | the listing says the car is currently damaged or a damage case |
| `not_roadworthy` | the listing says the car is not roadworthy |
| `accident_hint` | the seller text hints at accident or body damage (Unfallschaden, Vorschaden …) |
| `export_only` | the seller text says export or dealer sale only |
| `commercial_use` | the seller text says rental, taxi, driving-school or commercial use |
| `repair_needed` | the seller text says defect, repair needed, tinkerer's car or not drivable |
| `no_hu` | no valid HU / TÜV inspection (field or seller text) |
| `engine_or_gearbox_damage` | the seller text names engine, gearbox or clutch damage |
| `vague_history` | the listing is silent on accidents and shows no service history |
| `missing_url` | no link to the original listing |
| `short_description` | seller text shorter than 40 characters |

</details>

## Preset inputs (JSON)

<details><summary>Preset inputs (JSON)</summary>

BMW diesel estates:

```json
{"sourceMode": "actor", "searchUrls": ["https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true&s=Car&vc=Car&ms=3500&fuels=DIESEL"],
 "fuel": ["diesel"], "maxPrice": 25000, "minYear": 2017, "maxMileageKm": 160000, "minUnderMarketPct": 10, "maxListings": 300, "maxQualified": 25}
```

Private-seller bargains:

```json
{"sourceMode": "actor", "searchUrls": ["https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true&s=Car&vc=Car&adLimitation=ONLY_FSBO_ADS&maxPrice=10000"],
 "sellerType": "private", "maxPrice": 10000, "minYear": 2012, "minUnderMarketPct": 12, "maxListings": 300, "maxQualified": 25}
```

Electric cars from dealers:

```json
{"sourceMode": "actor", "searchUrls": ["https://suchen.mobile.de/fahrzeuge/search.html?isSearchRequest=true&s=Car&vc=Car&fuels=ELECTRICITY&maxPrice=30000"],
 "fuel": ["electric"], "sellerType": "dealer", "maxPrice": 30000, "minYear": 2020, "maxMileageKm": 80000, "maxListings": 300, "maxQualified": 25}
```

</details>

## Every field of a row

<details><summary>Every field of a row</summary>

| Field | Description |
|---|---|
| `itemId`, `name`, `url`, `listingId` | Identity — `url` is the car's own mobile.de page; stable across runs, safe as a primary key |
| `make`, `model`, `variant`, `year`, `firstRegistration`, `mileageKm`, `fuel`, `powerKw`, `powerHp`, `ccm`, `bodyType`, `color`, `emissionClass`, `previousOwners`, `huStatus` | The car as the listing states it; `null` when the seller gave none — never guessed |
| `priceEur`, `priceNetEur`, `priceNegotiable`, `mobileDePriceRating` | Gross price in EUR, VAT-deductible net price, negotiable flag, mobile.de's own price rating |
| `accidentFree`, `currentlyDamaged`, `roadworthy`, `isNew`, `isDamageCase` | Condition flags as stated: true / false, `null` = the listing says nothing |
| `sellerType`, `sellerName`, `sellerCity`, `sellerPhone`, `sellerRating`, `sellerEmail`, `sellerWebsite`, `contactRoute` | The seller; private sellers' details are dropped by default; `contactRoute` is e-mail, phone, else the listing page |
| `city`, `postalCode`, `state`, `lat`, `lon`, `descriptionText`, `features`, `imageUrls` | Location, the seller text (HTML stripped) and the first 5 photos |
| `comparableCount`, `marketMedianEur`, `underMarketPct`, `marginEstimateEur`, `priceDropFromLastSeen` | The run's market baseline; `null` below the minimum of comparable cars; the drop in EUR on a re-delivery |
| `dealReason`, `riskFlags`, `riskEvidence`, `riskSeverity`, `messageLanguage` | One line why this is (not) a deal; risk flags; the quoted sentences and severity (AI rows); the message language |
| `flags`, `targetFlagsMatched` | Rule-based findings and which of your target flags matched |
| `ruleScore`, `fitScore`, `score`, `label` | Rule score, the AI's final score, the ranking score, hot (70+) / warm (45–69) / cold |
| **Canonical fields** | `url` and `score` are the fields to map |
| **Which score to use** | Sort by `score`; `ruleScore` is the rule part and `fitScore` the AI's (rule score ± at most 15) — threshold and trend across runs on `ruleScore`. The Shortlist shows `score` only; the "Score breakdown" view has the parts |
| `assessedBy` | Which scale `score` is on: `ai`, `sample` (the demo's labelled verdicts) or `rules` — within hot / warm / cold the AI-assessed rows come first |
| `scoreReasons`, `summary`, `recommendedNextStep` | AI assessment (empty when AI is off) |
| `generatedText` | The message to the seller — the AI's when it is on, a template on hot / warm cars when it is off |
| `aiModel`, `chargedEvent`, `billedAs`, `dedupeKey`, `source`, `scrapedAt` | Provenance and billing (`billedAs` = the price tier, also on free rows; `chargedEvent` is `demo` on sample rows and `already-charged` on a car paid for under another search of the same memory store) |

</details>

## Which call runs what

<details><summary>Which call runs what — demo, live search or your own rows</summary>

| Input shape | `OUTPUT.mode` | Can it charge? |
|---|---|---|
| `{}` or the untouched form (the sample cars) | `demo` | no — sample rows are `chargedEvent: demo` |
| the sample cars + your own link or make typed next to them | `live` | yes — per qualified car deal; the scan on your account, capped by `maxDiscoveryChargeUsd` |
| the same, with `autoSwitchToLive: false` | `demo` | no — the mode you send is the mode that runs; the status names the field it did not search |
| `sourceMode: "actor"` + `searchUrls` or `make` | `live` | yes — as above |
| `sourceMode: "list"` + your own `itemsList` | `list` | yes — per qualified car deal, no source run |
| `sourceMode: "dataset"` + `datasetId` | `dataset` | yes — per qualified car deal, no source run |

</details>

## New hot car → Sheet row + seller message

1. Make / Zapier: trigger **Apify – Actor run finished** (the saved task of the 30-minute schedule).
2. Action **Get dataset items** (`Shortlist` view, `label` = `hot`).
3. Action **Google Sheets – update or add row**, matched on `dedupeKey` — start from the [tracker sheet](tracker.csv) (`dedupeKey` first).
4. Optional action: send `generatedText` (the seller message) from your own account; the Outreach view holds the route (`contactRoute`).

The Actor itself already delivers only new cars and price drops, so the Sheet never gets a duplicate row.

## Rows from any car site (list mode)

`sourceMode: "list"` with `itemsList` runs on any Apify plan and scores rows from AutoScout24, Kleinanzeigen, Marktplaats or your own sheet: export the saved cars to a spreadsheet (or a scraper's dataset), map the columns to the [ready template](list-template.json) (`title`, `priceEur`, `make`, `model`, `year`, `mileageKm`, `fuel`) and paste the JSON array into `itemsList`.

## Run outcomes

<details><summary>Run outcomes — what your integration sees (status, dataset, <code>OUTPUT</code> field, charged?)</summary>

| Outcome | Run status | Dataset | `OUTPUT` | Charged? | What to do |
|---|---|---|---|---|---|
| Free demo (the pre-filled sample) | SUCCEEDED | sample cars | `qualified`, `demo` | no | paste your mobile.de link next to it |
| Success | SUCCEEDED | qualified cars, best first | `outcome: ok`, `funnel`, `chargedEvents`, `aiCoverage` | per qualified car deal | — |
| Nothing matched (the demo too) | SUCCEEDED | empty | `outcome: empty`, `qualified: 0` | not by us — a live search's scan runs on your plan | loosen the minimum score, the gap or the filters |
| Demo text still in place | SUCCEEDED | empty | `inputNeeded` | no | replace the field the status names |
| Invalid input | FAILED | empty | — | no | fix the field the status message names |
| Your plan cannot run the source Store Actor | SUCCEEDED — the same for the Console, the API and a schedule; check `OUTPUT.ok` | empty | `ok: false`, `outcome: source_refused`, `sourceError`, `sourceRefused: "plan"` | no | on any plan paste [list-mode rows](#list-mode-rows), or use a plan that runs Store Actors |
| Source failed (nothing loaded: crash, timeout, the scraper renamed its output fields) | FAILED | empty | `ok: false`, `outcome: source_failed`, `sourceError` | no | follow the reason in the status |
| Source partial (the scraper timed out or was aborted after scraping) | SUCCEEDED | cars from the rows scraped | `sourceStatus` (`partial: true`) | per qualified car deal | lower Max listings or pass a longer `timeoutSecs` |
| AI unavailable | SUCCEEDED | rule-scored cars (the demo: labelled sample AI verdicts) | `aiError`, `freeAiReserve`, `aiCoverage` | free while your free cars last, then basic price | use your own key |

</details>

## List-mode rows

<details><summary>List-mode rows — what a pasted listing needs</summary>

`sourceMode: "list"` runs on any Apify plan: paste listings you already have into `itemsList` — start from the [ready template](list-template.json) (no scraper run, so nothing is billed on your account). Rows are the scraper's own rows, or objects with these fields; a plain-text ad pasted as a string entry is read too (the AI parses it, else a strict mapping; a text with no title and price is reported and never charged).

| Field | Needed? | What it enables |
|---|---|---|
| `title` (or `name`) | required | the row's title |
| `priceEur` (or `price`) | required | the price gap — a car without a price is never charged |
| `make`, `model`, `year`, `mileageKm`, `fuel` | for the baseline | comparable cars: same make and model, ±2 years, similar mileage, same fuel |
| `url`, `id` | optional | the link and the dedupe key (built from the id; else the link) |
| `descriptionText` | optional | the seller-text risk flags |
| `accidentFree`, `currentlyDamaged`, `roadworthy`, `huStatus` | optional | condition flags — absent means unknown, never "clean" |
| `priceRating` | optional | mobile.de's own rating: the agreement flag, and a stand-in for a missing baseline |
| `sellerType`, `sellerName`, `sellerPhone`, `previousOwners`, `powerKw`, `ccm`, `bodyType`, `color`, `features`, `imageUrls`, `city` | optional | the matching output fields |

</details>

## Run time per mode

<details><summary>Run time per mode and the <code>timeoutSecs</code> to pass</summary>

| Mode | Time | `timeoutSecs` to pass |
|---|---|---|
| Free demo (12 sample cars) | 0.1 s measured | 120 |
| Your own listings, rules only | ≈ 1 s per 100 (estimate) | 300 |
| Live search, rules only | ≈ 1–3 min per 200 listings (estimate) | 1800 |
| AI via Apify's model access | ≈ 5 s per car, 8 at a time (estimate) | 1800 |
| AI with a free own key (`byok`) | a few cars per run — the AI stops after 2 minutes of rate-limit waiting, the rest rule-scored | 600 |
| AI with a paid own key | not yet measured | 3600 |

With your own key the AI runs 2 calls at a time and one at a time after a rate limit; a rate-limited run stops the AI after 2 minutes of waiting and scores the rest by rules, so that is the upper bound. Worst case with a slow own key: the AI phase stops before the run's timeout and every row is still pushed with its rule score. Own-key throughput per provider is not measured yet. Pass `timeoutSecs` 3600 to `.call()`; every run reports its phases in `OUTPUT.timing`.

</details>

## More questions

**Which Apify plan do I need?** Any plan runs the free demo. The live search and Apify's built-in AI need a plan that can run Store Actors; on any other plan paste your own listings in `list` mode and use your own key — a free Gemini / Groq key assesses a few cars per run, a paid key every car.

**Do rule-scored cars use my free ones?** Yes, while the AI is unavailable — except the last 5, the AI reserve, kept so your first AI-assessed cars are free too. **What happens if nothing matches?** The run succeeds with 0 rows and charges nothing here.

**Will I be charged for the same car twice?** No, with the memory on (default): every delivered car is remembered per search on your account; another source or `dedupeStoreName` keeps its own. A car already charged under another search or filter set of the same store is delivered to the new one as `already-charged`, never charged twice. A car is charged again only when its price fell 5 % or more since you got it, or after `dedupeTtlDays` days (0 = never).

**How do I send the messages safely?** By hand, from your own mobile.de account or the number the seller shows, a few a day; no payment links or off-platform contact IDs in the first message. The AI's message leaves links out and never promises a price. Private sellers' details are dropped by default — use the listing's contact form.

**Can I use my own model?** Yes: "My own API key" plus your key, or any OpenRouter slug in "AI model" with Apify model access. **What if the AI fails?** The car keeps its rule score, keyword flags and template message — a model error never fails the run.

**Is this legal?** It reads the public data mobile.de shows to everyone, through the scraper you run on your own account; you are responsible for using it within mobile.de's terms and the law. **Can I schedule it?** Yes — see [Run it weekly](#-run-it-weekly).
