APEXReference

Price estimation

POST /estimate returns a lane's market price as p25/median/p75 with a confidence score, resolved through a four-level cascade. Confidence is computed from data volume and freshness, scaled by the cascade level.

POST /api/v1/estimate returns the market price for a single lane as a percentile distribution with a confidence score. What the numbers mean conceptually is covered in APEX › Overview; this page is the exact contract.

Request

curl -X POST https://apex.tovr.eu/api/v1/estimate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{
    "loading_country": "PL",
    "loading_postal_code": "50-148",
    "unloading_country": "DE",
    "unloading_postal_code": "60311",
    "distance_km": 700
  }'
FieldTypeRequiredDescription
loading_countrystringyesISO 3166-1 alpha-2 code (e.g. DE, PL).
loading_postal_codestringnoOrigin postal code — without it the cascade starts at country level.
unloading_countrystringyesISO 3166-1 alpha-2 code.
unloading_postal_codestringnoDestination postal code.
distance_kmnumberyesRoute distance in kilometres; must be > 0.

Postal codes are optional but valuable: without both, a request can only ever resolve at country_pair level. With them, the cascade can find exact corridors, reverse corridors, and postal-prefix regions.

Response

{
  "estimated_price_eur": 1168.3,
  "estimated_price_per_km": 1.669,
  "p25_price_per_km": 1.5935,
  "median_price_per_km": 1.669,
  "p75_price_per_km": 1.8707,
  "confidence_score": 0.87,
  "quality_status": "HIGH",
  "observation_count": 3496,
  "data_freshness_hours": 237,
  "match_level": "exact"
}
FieldTypeDescription
estimated_price_eurnumbermedian_price_per_km × distance_km.
estimated_price_per_kmnumberMedian EUR per kilometre at the matched level.
p25_/median_/p75_price_per_kmnumberThe price distribution at the matched level.
confidence_scorenumber0.0–1.0; volume + freshness × cascade multiplier (below).
quality_statusstringHIGH, MEDIUM, LOW, or INSUFFICIENT_DATA.
observation_countnumberDistinct observations behind the statistics (unweighted).
data_freshness_hoursnumberHours since the newest observation used.
match_levelstringexact, reverse, postal_prefix, country_pair, or none.

The cascade

The estimator resolves each request through four levels, returning the first that has enough data. Confidence is discounted as the match broadens:

Levelmatch_levelMin obsMultiplierHow it matches
1exact3×1.0Both postal codes resolve to a known corridor.
2reverse3×0.85The corridor exists in the opposite direction (B→A proxies A→B).
3postal_prefix5×0.7Aggregates corridors sharing the same 2-digit postal prefixes.
4country_pair10×0.6Average across the whole country pair.

Every level's statistics use only trusted data: price_per_km > 0, not flagged as an outlier, and observed prices only (enriched/estimated prices never feed an estimate). Executed-freight observations are weighted 3× in the averages and percentiles; observation_count reports distinct observations, unweighted. (Why executed prices weigh more: Data sources.)

How confidence_score is computed

The estimate's confidence comes from data volume and freshness — not from the corridor aggregate-quality score used elsewhere. For the matched level:

volume    = min(observation_count / 100, 1.0)          // saturates at 100 observations
freshness = max(1 − data_freshness_hours / 720, 0.1)   // penalises data older than 30 days
base      = round((volume × 0.6 + freshness × 0.4) × 100) / 100
confidence_score = base × cascade_multiplier            // ×1.0 exact … ×0.6 country_pair

The quality_status is derived from confidence_score:

StatusCondition
HIGHconfidence_score ≥ 0.5
MEDIUM0.3 ≤ confidence_score < 0.5
LOWconfidence_score < 0.3
INSUFFICIENT_DATAno cascade level met its minimum (see below)

This is not the corridor aggregate-quality formula (sample size / source diversity / price variance). That score is computed during corridor aggregation and used for analytics — it never reaches an estimate response. The two are compared in Confidence & quality.

INSUFFICIENT_DATA

When no level reaches its minimum observation count, the API still returns 200 OK with all price fields 0, match_level: "none", and quality_status: "INSUFFICIENT_DATA". This is a data-coverage statement, not an error — some lanes genuinely have no coverage yet.

On this page