// KickoffApi.kt — KickoffAPI's official lightweight Kotlin client (JDK 11+, java.net.http). // // val api = KickoffApi("YOUR_API_KEY") // free key: https://kickoffapi.com/portal/ // val live = api.countries() // val epl = api.standings(mapOf("league" to "lg_8K3mZ0pQ", "season" to 2025)) // // One file, the JDK's own HttpClient, no Gradle/Maven dependency to add. // // No JSON library either: the JDK ships none, and pulling one in would defeat // the point of a dependency-free client. Each method returns the raw response // body as a String — decode it with whatever your project already uses // (org.json on Android, kotlinx.serialization, Jackson). There is a worked // example in README.md. // // Method names are the Python client's snake_case names, so the same call reads // the same in every official SDK, rather than Kotlin camelCase. // // Errors: every non-2xx response throws KickoffApiException, which carries the // HTTP status, the message the API sent back, and the seconds from Retry-After // when the response was a 429. // // Spec: /openapi-v2.yaml import java.net.URI import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration /** Base URL every method is relative to. */ const val DEFAULT_BASE_URL = "https://api.kickoffapi.com/api/v2" /** Seconds before a request is abandoned. */ const val DEFAULT_TIMEOUT_SECONDS = 30L /** Every non-2xx response. */ class KickoffApiException( val status: Int, override val message: String, /** Seconds from the Retry-After header. Null unless the response was a 429. */ val retryAfterSeconds: Int? ) : Exception(message) /** One shot into the xG model, in the 120x80 pitch frame. */ data class Shot( val x: Double, val y: Double, val bodyPart: String? = null, val situation: String? = null ) { fun toJson(): String = buildString { append("""{"x":""").append(x).append(""","y":""").append(y) jsonField("bodyPart", bodyPart) jsonField("situation", situation) append("}") } private fun jsonField(name: String, value: String?) { if (value != null) append(",\"").append(name).append("\":\"").append(jsonEscape(value)).append("\"") } } private fun jsonEscape(value: String): String = value .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") /** Pulls the message out of an {"error": "..."} body, or falls back to the raw body. */ private fun errorMessageOf(body: String): String { val match = Regex("\"error\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(body) ?: return if (body.isBlank()) "request failed" else body return match.groupValues[1] .replace("\\\"", "\"") .replace("\\\\", "\\") .replace("\\n", "\n") .replace("\\t", "\t") } class KickoffApi @JvmOverloads constructor( private val apiKey: String, private val baseUrl: String = DEFAULT_BASE_URL, private val timeoutSeconds: Long = DEFAULT_TIMEOUT_SECONDS, private val httpClient: HttpClient = defaultHttpClient() ) { init { require(apiKey.isNotEmpty()) { "KickoffApi: api_key is required" } } private val timeout: Duration = Duration.ofSeconds(timeoutSeconds) // --- reference ----------------------------------------------------------- /** GET /countries — [p]: limit, cursor */ fun countries(p: Map = emptyMap()): String = get("/countries", p) /** GET /leagues — [p]: country, limit, cursor */ fun leagues(p: Map = emptyMap()): String = get("/leagues", p) /** GET /teams — [p]: country, limit, cursor */ fun teams(p: Map = emptyMap()): String = get("/teams", p) /** GET /bookmakers */ fun bookmakers(): String = get("/bookmakers") /** GET /bet-types */ fun bet_types(): String = get("/bet-types") /** GET /venues — [p]: search, limit, cursor */ fun venues(p: Map = emptyMap()): String = get("/venues", p) /** GET /leagues/{id}/source — which pipeline currently serves a league */ fun league_source(id: String): String = get("/leagues/$id/source") /** GET /idmap/leagues — [p]: id (legacy id or comma-separated list), limit */ fun league_id_map(p: Map = emptyMap()): String = get("/idmap/leagues", p) /** GET /resolve — [p]: entity (league|team|fixture|player), legacyId */ fun resolve(p: Map = emptyMap()): String = get("/resolve", p) // --- fixtures ------------------------------------------------------------ /** GET /fixtures — [p]: league, season, date, from, to, team, limit, cursor */ fun fixtures(p: Map = emptyMap()): String = get("/fixtures", p) /** GET /fixtures/{id} — detail, with any stored preview/recap embedded */ fun fixture(id: String): String = get("/fixtures/$id") /** GET /fixtures/{id}/events — [p]: refresh */ fun fixture_events(id: String, p: Map = emptyMap()): String = get("/fixtures/$id/events", p) /** GET /fixtures/{id}/lineups */ fun fixture_lineups(id: String): String = get("/fixtures/$id/lineups") /** GET /fixtures/{id}/statistics */ fun fixture_statistics(id: String): String = get("/fixtures/$id/statistics") /** GET /fixtures/{id}/players */ fun fixture_players(id: String): String = get("/fixtures/$id/players") /** GET /fixtures/{id}/projection */ fun fixture_projection(id: String): String = get("/fixtures/$id/projection") /** GET /fixtures/{id}/model-odds — [p]: margin */ fun fixture_model_odds(id: String, p: Map = emptyMap()): String = get("/fixtures/$id/model-odds", p) /** GET /fixtures/{id}/preview — [p]: model, refresh, debug */ fun fixture_preview(id: String, p: Map = emptyMap()): String = get("/fixtures/$id/preview", p) /** GET /fixtures/{id}/content — stored preview and recap. A DB read. */ fun fixture_content(id: String): String = get("/fixtures/$id/content") /** GET /headtohead — [p]: team1, team2, h2h */ fun head_to_head(p: Map = emptyMap()): String = get("/headtohead", p) // --- tables, squads and leaderboards ------------------------------------- /** GET /standings — [p]: league, season (both required) */ fun standings(p: Map = emptyMap()): String = get("/standings", p) /** GET /players — [p]: limit, cursor */ fun players(p: Map = emptyMap()): String = get("/players", p) /** GET /teams/{id}/squad — [p]: season */ fun team_squad(id: String, p: Map = emptyMap()): String = get("/teams/$id/squad", p) /** GET /teams/{id}/form — [p]: season (required), n */ fun team_form(id: String, p: Map = emptyMap()): String = get("/teams/$id/form", p) /** GET /topscorers — [p]: league, season */ fun top_scorers(p: Map = emptyMap()): String = get("/topscorers", p) /** GET /topassists — [p]: league, season */ fun top_assists(p: Map = emptyMap()): String = get("/topassists", p) /** GET /insights/season-sim — [p]: league, season, sims, cutoff */ fun season_simulation(p: Map = emptyMap()): String = get("/insights/season-sim", p) /** GET /insights/strength — [p]: league, season */ fun team_strength(p: Map = emptyMap()): String = get("/insights/strength", p) // --- odds, transfers, injuries, honours ---------------------------------- /** GET /odds — [p]: fixture (required), bookmaker, bet */ fun odds(p: Map = emptyMap()): String = get("/odds", p) /** GET /odds/live — [p]: limit, cursor */ fun live_odds(p: Map = emptyMap()): String = get("/odds/live", p) /** GET /transfers — [p]: player, team, limit, cursor */ fun transfers(p: Map = emptyMap()): String = get("/transfers", p) /** GET /injuries — [p]: fixture, team, player, limit, cursor */ fun injuries(p: Map = emptyMap()): String = get("/injuries", p) /** GET /coaches — [p]: team, limit, cursor */ fun coaches(p: Map = emptyMap()): String = get("/coaches", p) /** GET /trophies — [p]: player, limit, cursor */ fun trophies(p: Map = emptyMap()): String = get("/trophies", p) // --- in-house models ----------------------------------------------------- /** GET /xg — [p]: x, y (required), bodyPart, situation */ fun xg(p: Map = emptyMap()): String = get("/xg", p) /** POST /xg */ fun xg_batch(shots: List): String { val body = buildString { append("{\"shots\":[") shots.forEachIndexed { index, shot -> if (index > 0) append(',') append(shot.toJson()) } append("]}") } return post("/xg", body) } /** GET /xg/model */ fun xg_model(): String = get("/xg/model") // --- account ------------------------------------------------------------- /** GET /account/status */ fun account_status(): String = get("/account/status") /** GET /account/overage */ fun account_overage(): String = get("/account/overage") /** POST /account/export — the GDPR export of the authenticated account */ fun export_my_data(): String = post("/account/export", "{}") /** POST /account/delete-request — emails the token. Destructive once confirmed. */ fun request_account_deletion(): String = post("/account/delete-request", "{}") /** GET /account/delete-confirm — [p]: token from the deletion email. Destructive. */ fun confirm_account_deletion(p: Map = emptyMap()): String = get("/account/delete-confirm", p) // --- events, markets, insights ------------------------------------------- /** GET /events — [p]: fixture (required) */ fun events(p: Map = emptyMap()): String = get("/events", p) /** GET /fixtures/{id}/markets — derived markets for a fixture */ fun fixture_markets(id: String): String = get("/fixtures/$id/markets") /** GET /insights/accuracy — [p]: league, season */ fun model_accuracy(p: Map = emptyMap()): String = get("/insights/accuracy", p) /** GET /insights/fixture — [p]: fixture (required) */ fun fixture_insight(p: Map = emptyMap()): String = get("/insights/fixture", p) // --- league form guide, over/under ----------------------------------------- /** GET /leagues/{id}/form-guide — [p]: season (required) */ fun league_form_guide(id: String, p: Map = emptyMap()): String = get("/leagues/$id/form-guide", p) /** GET /leagues/{id}/over-under — [p]: season (required) */ fun league_over_under(id: String, p: Map = emptyMap()): String = get("/leagues/$id/over-under", p) // --- player career & predictions ------------------------------------------ /** GET /players/{id}/career — player career and club history */ fun player_career(id: String): String = get("/players/$id/career") /** GET /players/{id}/predictions — [p]: league, season, opponent */ fun player_predictions(id: String, p: Map = emptyMap()): String = get("/players/$id/predictions", p) // --- sidelined, team rankings, results, streaks ---------------------------- /** GET /sidelined — [p]: player, team, fixture, limit, cursor */ fun sidelined(p: Map = emptyMap()): String = get("/sidelined", p) /** GET /teams/rankings — [p]: country, limit */ fun team_rankings(p: Map = emptyMap()): String = get("/teams/rankings", p) /** GET /teams/{id}/results — [p]: n */ fun team_results(id: String, p: Map = emptyMap()): String = get("/teams/$id/results", p) /** GET /teams/{id}/streaks — win, draw and scoring streaks */ fun team_streaks(id: String): String = get("/teams/$id/streaks") // --- timezone, leaderboards, value bets ------------------------------------ /** GET /timezone — IANA time zone identifiers */ fun timezone(): String = get("/timezone") /** GET /topredcards — [p]: league, season, limit, cursor */ fun top_red_cards(p: Map = emptyMap()): String = get("/topredcards", p) /** GET /topyellowcards — [p]: league, season, limit, cursor */ fun top_yellow_cards(p: Map = emptyMap()): String = get("/topyellowcards", p) /** GET /value-bets — [p]: league, season */ fun value_bets(p: Map = emptyMap()): String = get("/value-bets", p) // --- kept for v1 parity (all live on /api/v2 as well) -------------------- /** GET /fixtures?live=all */ fun live_scores(): String = get("/fixtures", mapOf("live" to "all")) /** v2's Elo prediction, not v1's /predictions — /api/v2/predictions is not a route. */ fun predictions(p: Map = emptyMap()): String = get("/predictions/elo", p) /** GET /teams/{id}/statistics — [p]: team (or id), league, season */ fun team_statistics(id: String, p: Map = emptyMap()): String = get("/teams/$id/statistics", p) // --- transport ----------------------------------------------------------- /** GET a v2 path. Escape hatch for anything without a named method yet. */ @JvmOverloads fun get(path: String, params: Map = emptyMap()): String = send("GET", path, params, null) /** POST a JSON body to a v2 path. */ @JvmOverloads fun post(path: String, jsonBody: String, params: Map = emptyMap()): String = send("POST", path, params, jsonBody) /** Releases this client's connection pool and executor. */ fun shutdown() { httpClient.executor().ifPresent { it.shutdown() } } private fun send(method: String, path: String, params: Map, jsonBody: String?): String { val builder = HttpRequest.newBuilder() .uri(URI.create(baseUrl + path + queryString(params))) .timeout(timeout) .header("x-api-key", apiKey) .header("Accept", "application/json") if (jsonBody != null) builder.header("Content-Type", "application/json") val request = when (method) { "GET" -> builder.GET().build() "POST" -> builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody ?: "{}")).build() else -> throw IllegalArgumentException("KickoffApi: unsupported method $method") } val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) val body = response.body() ?: "" val status = response.statusCode() val retryAfter = response.headers().firstValue("Retry-After").orElse(null)?.trim()?.toIntOrNull() if (status !in 200..299) throw KickoffApiException(status, errorMessageOf(body), retryAfter) return body } private fun queryString(params: Map): String { val pairs = params.entries .filter { it.value != null } .map { (name, value) -> URLEncoder.encode(name, "UTF-8") + "=" + URLEncoder.encode(value.toString(), "UTF-8") } return if (pairs.isEmpty()) "" else "?" + pairs.joinToString("&") } companion object { private fun defaultHttpClient(): HttpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build() } }