# VS Engine API — full endpoint reference Central Stats & AI Content Motor powering Insideformation client products. Sources: Kambi odds + betting patterns, ValueStats insights, SportRadar match data. Data policy: **never invent data** — missing values become `null` + `blocked: true`, frontends hide blocked widgets. See /docs/concepts/data-policy.md. Version: 1.0.0 Servers: - https://api.insideformation.com/dataengine (Production) - http://localhost:3100/dataengine (Local development) ## lobby Client-facing lobby feed endpoints. One call returns everything needed to render a front page. ### GET /client/{id}/sportsbook/feed GET /dataengine/client/:id/sportsbook/feed ### GET /feed/{clientId} GET /dataengine/feed/:clientId ### GET /feed/{clientId}/poll GET /dataengine/feed/:clientId/poll ### POST /feed/{clientId}/refresh POST /dataengine/feed/:clientId/refresh ## match-detail Per-match widget feeds, odds catalogues, and live summaries. ### GET /feed/{clientId}/match/{eventId} GET /dataengine/feed/:clientId/match/:eventId ## league League-scoped widgets: leader boards, standings, liga-bäst rankings, standalone highlights. ### GET /fs/leaders/{leagueId} GET /dataengine/fs/leaders/:leagueId ### GET /goal-profiles GET /dataengine/goal-profiles ### GET /leaders/{leagueId} GET /dataengine/leaders/:leagueId ### GET /liga-bast/{leagueId} GET /dataengine/liga-bast/:leagueId ### GET /player-profiles GET /dataengine/player-profiles ### GET /standalone/{leagueId} GET /dataengine/standalone/:leagueId ### POST /standalone/{leagueId}/refresh POST /dataengine/standalone/:leagueId/refresh ## junibet-features Junibet-only feature endpoints that hybridize ValueStats confidence with Kambi betting patterns. ### GET /junibet/accumulators GET /dataengine/junibet/accumulators ### GET /junibet/catalog GET /dataengine/junibet/catalog ### GET /junibet/feed GET /dataengine/junibet/feed ### GET /junibet/live-rail Live Now rail — major-league in-play matches for the Junibet lobby Returns up to 10 currently-live football matches from Tier A (Premier League, La Liga, Serie A, Bundesliga, Ligue 1) and Tier B (Champions, Europa, Conference League). Each entry carries the live score, SR clock, live 1X2 milliodds, and a full provenance block with both Kambi and SportRadar origins. Every request re-aggregates from the raw Kambi (≤150s freshness budget) and raw SR live summary (≤30s freshness budget) caches, so response latency is O(matches) with no deep enrichment. Matches that fail any freshness or completeness check are silently dropped — the endpoint never ships a partial card with missing score or odds. Polled at 5 s cadence by the frontend. Returns HTTP 200 with an empty matches array when no Tier A/B matches are live — never 404. ### GET /junibet/market-spotlight GET /dataengine/junibet/market-spotlight ### GET /junibet/popular GET /dataengine/junibet/popular ### GET /junibet/ticker GET /dataengine/junibet/ticker ## live In-play match data: live summaries, timelines, lineups (SportRadar-backed). Polled every 15 seconds while a fixture is in the window [kickoff-10min, kickoff+180min]. Responses are raw SportRadar payloads; match_status=not_started until the whistle. ### GET /live/{eventId}/lineups Fetch starting lineups + formations for a match (cached once per fixture) Returns the SportRadar lineups payload — starting XI, formations (e.g. `"4-2-3-1"`), coaches, and substitutes for both teams. Unlike `/summary` and `/timeline` this is fetched **once** on the first poll where SR has published it (typically 15-60 minutes before kickoff) and cached for the duration of the match — the data does not change during play. Returns 404 when SR has not yet published lineups, which is normal until close to kickoff. Clients should poll periodically and accept 404 gracefully. ### GET /live/{eventId}/summary Fetch the latest SportRadar live summary for an in-play match Returns the most recent `sport_event_status` (match_status, period, clock, home/away score, period_scores) and `statistics.totals` (possession, shots, corners, fouls, cards) that the live poller has harvested from SportRadar for this fixture, plus optional `ball_locations` and `match_situation` fields when SR publishes them. The poller runs every 15 seconds against any enriched match with `sportEventUrn` set whose kickoff is in `[now-10min, now+180min]`. Responses are served straight from disk (ETag + 30-second `Cache-Control`), so expected freshness is 15s behind the live wire. Returns 404 when the poller has never seen this match — usually means either the enriched record has no SR URN (SR has not published the fixture in either team's summary cache), the match is outside the poller window, or the eventId does not exist. ### GET /live/{eventId}/timeline Fetch the latest SportRadar timeline (event-by-event log) for an in-play match Returns the ordered list of match events harvested from SportRadar: `match_started`, `period_start`, `goal_scored` (with scorers + assists), `yellow_card`, `red_card`, `substitution`, `video_assistant_referee` (VAR), `injury_time_shown`, `period_score`, `match_ended`, etc. Each entry carries an ISO `time`, a SR `type` code, and optional `commentary`, `match_clock`, `period`, and player references. Refreshed on the same 15-second live-poller cadence as `/summary`. Returns 404 with the same semantics as `/summary` — the poller has never seen this match. ## mappings Canonical SR↔Kambi identifier bridge. Every enriched football match has its SportRadar sport_event URN persisted at ingest time via deterministic URN+kickoff matching (never fuzzy names). These endpoints let you convert between Kambi numeric event ids and SR sr:sport_event:* URNs in either direction. ### GET /mappings/kambi-to-sr Resolve a Kambi event id to its SportRadar sport_event URN Takes a numeric Kambi event id and returns the canonical mapping record (SR sport_event URN + team URNs + kickoff + league) if the enriched match has its `sportEventUrn` field populated. Returns 404 when no SR URN has been resolved for this Kambi event. Root causes: the match is not yet enriched, SR has not published the fixture in either team's summary cache (common for in-play matches — SR's summaries endpoint drops fixtures while they are in-play and republishes after full-time), or the match belongs to a league we do not ingest from SR (non-soccer leagues always 404). **Never fabricates a mapping.** ### GET /mappings/sr-to-kambi Resolve a SportRadar sport_event URN to its Kambi event id Takes a SportRadar `sr:sport_event:` URN and returns the canonical mapping record (Kambi numeric event id + team URNs + kickoff + league) if an enriched match exists with that URN persisted. Returns 404 when the URN is not present on any enriched record. This happens when SR has not yet published the fixture in either team's summary cache, or when the enriched match pre-dates the sportEventUrn persistence rollout and has not been re-enriched. The engine **never fabricates a mapping** — a 404 is the honest answer per the no-invented-data policy. ## admin Operational endpoints: pipeline triggers, cache refreshes, health, cost, alerts. ### GET /alerts GET /dataengine/alerts ### POST /auto-articles/run POST /dataengine/auto-articles/run ### GET /auto-articles/status GET /dataengine/auto-articles/status ### GET /auto-videos/status GET /dataengine/auto-videos/status ### GET /cost GET /dataengine/cost ### GET /cost/log GET /dataengine/cost/log ### GET /health/leagues Per-league health snapshot (OK / empty / blocked) Reports which leagues are live, empty or blocked, with the age of the most recent match file per league. Open endpoint — no API key required. Added in the 2026-04-12 audit remediation (item N2). ### GET /pipeline/match/{eventId} Per-event pipeline attempt history Returns the last 50 article/video generation attempts for a single match event, each annotated with timestamp, recipe, status, optional error type+message, and cost or generated content id when available. Useful for diagnosing silent failures (Claude credit issues, HeyGen outages, ElevenLabs/Imagen errors) without reading container stderr. ### POST /pipeline/run POST /dataengine/pipeline/run ### GET /status GET /dataengine/status ## discovery Introspection endpoints for widgets, journalists, recipes, catalogs. ### GET / GET / ### POST /auto-videos/run POST /dataengine/auto-videos/run ### GET /brand/{clientId} GET /dataengine/brand/:clientId ### POST /brand/{clientId}/invalidate POST /dataengine/brand/:clientId/invalidate ### GET /client/{id}/sportsbook/accumulators GET /dataengine/client/:id/sportsbook/accumulators ### GET /client/{id}/sportsbook/catalog GET /dataengine/client/:id/sportsbook/catalog ### GET /client/{id}/sportsbook/live-rail GET /dataengine/client/:id/sportsbook/live-rail ### GET /client/{id}/sportsbook/market-spotlight GET /dataengine/client/:id/sportsbook/market-spotlight ### GET /client/{id}/sportsbook/popular GET /dataengine/client/:id/sportsbook/popular ### GET /client/{id}/sportsbook/ticker GET /dataengine/client/:id/sportsbook/ticker ### GET /cms/market-config GET /dataengine/cms/market-config ### POST /cms/market-config POST /dataengine/cms/market-config ### GET /dam/{clientId}/catalog GET /dataengine/dam/:clientId/catalog ### GET /dam/{clientId}/select GET /dataengine/dam/:clientId/select ### GET /fs/match/{eventId} GET /dataengine/fs/match/:eventId ### GET /fs/matches GET /dataengine/fs/matches ### GET /fs/player/{playerId} GET /dataengine/fs/player/:playerId ### GET /fs/standings/{leagueId} GET /dataengine/fs/standings/:leagueId ### GET /fs/team/{teamId} GET /dataengine/fs/team/:teamId ### GET /journalists GET /dataengine/journalists ### GET /odds/{eventId} GET /dataengine/odds/:eventId ### GET /odds/{eventId}/movement GET /dataengine/odds/:eventId/movement ### GET /team-stats-aggregates GET /dataengine/team-stats-aggregates ### GET /video/by-match/{eventId} GET /dataengine/video/by-match/:eventId ### POST /video/generate POST /dataengine/video/generate ### GET /video/{videoId} GET /dataengine/video/:videoId ### GET /widgets GET /dataengine/widgets ## content AI article generation and lifecycle management. ### POST /articles/generate POST /dataengine/articles/generate ### GET /recipes GET /dataengine/recipes ### POST /recipes/reload POST /dataengine/recipes/reload ## insights Pre-match data-mined insights — surprising-but-statistically-significant patterns supplementing ValueStats. Patterns surface things like "AIK has only won 1 of 20 matches against Malmö" or "team X with player Y starting wins 78% vs 23% without". Mined every 6h pre-match, cached per event. ### POST /insights/run-all Force re-mine insights for every upcoming pre-match fixture Iterates every EnrichedMatch on disk where `status === "scheduled"` and `kickoff` is in the future, and re-mines each one. Idempotent — safe to call repeatedly. Skips matches whose status, kickoff, or team ids fail validation; per-event mining errors are caught and reported (capped at the first 10) without aborting the run. This is the same operation the 6-hourly cron (`45 1,7,13,19 * * *` UTC) performs — call this endpoint manually only for editorial bulk-warm or after a data backfill. Compute is local-only (no AI calls), so this endpoint is NOT subject to the `AI_DAILY_BUDGET_EUR` cost guard. Typical run-time is ~30s for ~300 matches against the existing on-disk historical cache. Master-key only. ### GET /insights/{eventId} Read cached pre-match mined insights for one fixture Returns the top-25 statistically-validated patterns mined for the given Kambi event. Patterns are mined every 6 hours pre-match by the cron at minutes :45 of hours 1, 7, 13 and 19 UTC, and cached on disk under `computed/insights/{eventId}`. The cached payload is served with a 60s Cache-Control max-age + ETag — clients should honor `If-None-Match` to avoid redundant transfers between mining ticks. This dataset SUPPLEMENTS the editorial ValueStats insight feed — overlapping criteria are filtered server-side so consumers never see duplicates. Every pattern surfaced has survived: (1) sample size n >= 10, (2) signed |Δ| >= 20pp vs population baseline, and (3) Bonferroni-corrected one-sided binomial p < 0.05 across all tests run for this event. Set `?run=1` to force read-through mining if no cache exists yet — useful for editorial preview tooling. Returns 404 when the event has never been mined and `run` is unset. ### POST /insights/{eventId}/run Force re-mine insights for one fixture Idempotent re-mining of pre-match patterns for a single Kambi event. Reads the cached EnrichedMatch + historical SR summaries off disk, runs all four mining phases (scoring/dedup, h2h+form+trajectory, player splits, external signals), Bonferroni-corrects, dedups against ValueStats, and overwrites `computed/insights/{eventId}` with the fresh top-25. Returns the run summary — counts at each filter stage plus the top-3 patterns by surpriseScore. Use this for editorial preview after pushing new ValueStats data, or to manually warm a fixture that the 6h cron missed. Master-key only (POST endpoints require X-API-Key per the global auth hook). Returns 404 when no EnrichedMatch is on disk for the event, 500 if mining throws (error message in response body — never fabricated). ## catalog Self-service API catalog. One call returns every endpoint, every widget, every dataset, plus auth model and refresh cadences. Designed for downstream consumers (mobile apps, web frontends, other backends) that need to discover what the engine exposes without reading the source. ### GET /catalog Self-service API catalog (machine-readable JSON) Returns a single document describing every endpoint, every widget, every dataset, the auth model, rate limits, and a recent-changes log. Designed for downstream consumers that need to discover capabilities without reading the source. The endpoint list is harvested from the live Fastify route table at request time, so it can never drift from the running API. Memoized for 30 seconds. For a browser-friendly version see `GET /dataengine/docs/catalog.html`. For the full OpenAPI 3.1 spec see `/dataengine/docs/openapi.yaml` or the interactive Scalar UI at `/dataengine/docs/`. ### GET /docs/catalog.html Self-service API catalog (browser-friendly HTML) Same content as `GET /dataengine/catalog` but rendered as a self-contained HTML page with inline CSS — no external dependencies. Table-of-contents at top, sections for authentication, endpoints (grouped by tag), widgets, datasets, rate limits, and changelog. ## admin-packages Per-match approval queue for clients that opt into editorial moderation (Hemmaklubben). Generated articles + videos land in a Package in `pending_approval` status; editors approve / reject / edit / unpublish through these endpoints. Only packages that reach `live` (or `scheduled` whose `scheduledFor` is in the past) become visible in the lobby feed. ### GET /admin/packages List approval queue packages Returns a compact list view of packages, sorted by kickoff descending. Default filter is `status=pending_approval, clientId=hemmaklubben, limit=50`. ### GET /admin/packages/{id} Fetch full package with version history ### POST /admin/packages/{id}/approve Approve a pending package Body: `{ "goLive": "now" }` to publish immediately, or `{ "goLive": "" }` to schedule. Returns 409 if the package is not in `pending_approval`. ### PATCH /admin/packages/{id}/edit Apply an in-place text edit to the current version Field is a dot-path inside `currentVersion.elements`, e.g. "article.headline". Numeric path components index arrays (e.g. "article.body.0.text"). The edit is applied immediately to the package element snapshot AND mirrored to the canonical article/video file when the field maps cleanly. An entry is appended to `edits[]`. No new version is created and no regen is triggered. ### POST /admin/packages/{id}/reject Reject the current version with feedback and trigger regeneration Marks the current version as rejected, stores the feedback comment, and enqueues a regeneration via the relevant article/video pipeline. Subsequent generation lands as v(n+1) on the same package; status stays `pending_approval` throughout. v1 limitation: scope=`video.script` still triggers a full re-render of audio + video. ### POST /admin/packages/{id}/unpublish Unpublish a live package Moves status from `live` to `unpublished`. Subsequent feed rebuilds drop it from the lobby. ### GET /admin/packages/{id}/version/{versionId} Fetch a specific historical version of a package ## aggregates Per-team and per-player rich aggregates derived from cached SR match summaries. Powers Junibet AI insight prompts so a bettor evaluating a market gets concrete season-level patterns ("AIK averages 6.2 corners home / 5.3 away, 7.1 vs top-half opposition"; "Player X has 3 goals in last 5 with 1.4 shots-on-target per match"). 6h on-disk TTL per-id with `?refresh=1` override; standings ingest piggybacks a background recompute. ### GET /player-aggregates/{playerId} Per-player rich aggregates for AI insight prompts Returns season-scoped per-player aggregates: attacking totals + per-90 metrics, efficiency ratios, discipline + suspension-watch flag, and form (goals/assists/shots in last 5 matches + scoring streak). Computed from every closed SR match summary that lists the player in `statistics.totals.competitors[].players[]`. CACHING: 6h TTL on disk under `computed/player-aggregates/{playerId}.json`. The route serves with 60s Cache-Control + ETag; `?refresh=1` forces re-compute. NULL HANDLING: Per-90 metrics depend on `minutes_played`, which SR does NOT ship in the per-player team-summary block for most leagues. When minutes are missing every per-90 field is `null`. `key_passes`, `tackles`, `interceptions` are also always `null` because SR does not surface them in this dataset. ### GET /team-aggregates/{teamId} Per-team rich aggregates for AI insight prompts Returns a comprehensive season-scoped statistical profile for one team — corner, card, foul, free-kick, goal, match-state and form-context aggregates derived from every closed SR match summary in the current season. Powers Junibet AI insight prompts ("AIK averages 6.2 corners home / 5.3 away, 7.1 vs top-half opposition"). CACHING: 6h TTL on disk under `computed/team-aggregates/{teamId}.json`. The route serves the cached payload with a 60s Cache-Control + ETag; pass `?refresh=1` to force re-compute. Standings ingest piggybacks a background recompute so the cache stays warm. NULL HANDLING: Fields that depend on timeline data (goal-by-interval, set-piece / open-play / penalty splits, late winners, comeback wins, penalties for/against) are `null` whenever the cached SR summaries lack a `timeline` array — currently the common case. The frontend / prompt layer must hide null fields rather than rendering 0.