API reference.
REST and Server-Sent Events over HTTPS. Base URL is https://api.matchkit.dev, every call carries an X-API-KEY header, and almost everything comes back in the same envelope.
Only what exists today is documented here. Everything else is under Not built yet. If you're relying on LP mode, read how the fairness gate behaves under Queues & matchmaking.
Building in Unity? The Unity SDK wraps this surface (install, setup and a Mirror handoff) so you don't write the REST and SSE plumbing by hand.
Where MatchKit fits
MatchKit runs your queue, forms the match, and keeps the ratings. When a match is ready it hands the roster back as plain JSON and steps out of the way. Whatever your game already uses to connect players plugs into that seam unchanged. That's the platform services (Steam, Epic, a dedicated fleet), the netcode framework you build on (Mirror, FishNet or Photon in Unity; Godot's high-level multiplayer or Netfox in Godot), peer-to-peer, or your own sockets. MatchKit never touches the wire, so adopting it is additive, not a migration.
flowchart TB
subgraph YOU1["Your game"]
C["Client or game server<br/><i>any engine · plain REST + SSE</i>"]
end
subgraph MK["MatchKit · queue · match · rating"]
direction TB
P["POST /player<br/><i>register once</i>"]
Q["POST /queue/join<br/><i>enter a mode</i>"]
M["match.found<br/><i>teams pushed over SSE</i>"]
E["POST /match/end<br/><i>result → ratings recalculate</i>"]
P --> Q --> M
end
subgraph YOU2["Your session layer · any transport"]
direction LR
T1["Steamworks"]
T2["Epic Online Services"]
T3["PlayFab / dedicated"]
T4["Unity netcode<br/><i>Mirror · FishNet · Photon</i>"]
T5["Godot multiplayer<br/><i>high-level API · Netfox</i>"]
T6["Peer-to-peer"]
T7["Your own sockets"]
end
C --> P
M -- "match.found payload<br/>teamA / teamB · playerIds · matchId<br/>(no MatchKit runtime in the loop)" --> YOU2
YOU2 -- "match ends → you report the winner" --> E
E -. "ratings written · loop closes" .-> Q
classDef mk fill:#0f1a18,stroke:#00c9a7,stroke-width:1px,color:#e8e9eb;
classDef you fill:#1c170d,stroke:#e8a430,stroke-width:1px,color:#e8e9eb;
class P,Q,M,E mk;
class C,T1,T2,T3,T4,T5,T6,T7 you;
style MK fill:#0d0f12,stroke:#00c9a7,color:#00c9a7;
style YOU1 fill:#0d0f12,stroke:#e8a430,color:#e8a430;
style YOU2 fill:#0d0f12,stroke:#e8a430,color:#e8a430;Quickstart
Three calls: create a player, put them in a queue, report the result when the match ends. It's plain REST plus a Server-Sent Events stream, so you don't need an SDK; the Unity one just saves you the boilerplate. Send your key as X-API-KEY on every request and unwrap .data from the response.
The pre-alpha is invite-only. Tell me what you're building and I'll send you a key. The calls below are what you'll run once you have one.
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
// Server credential. Don't ship it in a game client; anything in the
// build is extractable. Put it behind your own server.
const string Api = "https://api.matchkit.dev";
const string ApiKey = "mk_live_xxx";
[System.Serializable]
class CreatePlayerBody {
public string username;
public string region;
public string matchmakingMode; // LP (default) | MMR | ELO
public string ratingMode; // TS | MMR
}
IEnumerator CreatePlayer() {
var body = JsonUtility.ToJson(new CreatePlayerBody {
username = "p-4821",
region = "eu-west",
matchmakingMode = "LP",
ratingMode = "TS",
});
using var req = new UnityWebRequest($"{Api}/player", "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("X-API-KEY", ApiKey);
yield return req.SendWebRequest();
Debug.Log(req.downloadHandler.text); // ApiResponse<Player>
}Authentication
Two schemes, and which one you use depends on who's calling. Your game servers send X-API-KEY: mk_test_ in development, mk_live_ in production. Your dashboard and account calls use a JWT from POST /auth/login (access token 24h, refresh 7d). Most product endpoints take either. New accounts verify their email before the first login works.
An API key is a server credential. Anything you ship inside a game build can be pulled out of it, so route calls through a server you control rather than putting a live key in the client.
- POST/auth/loginemail + password → JWT, refresh token, role
- POST/auth/refresh-tokenmint a fresh access token (refresh token in the X-REFRESH-TOKEN header)
- POST/auth/registercreate a developer account
// Game servers send the API key header on every request.
req.SetRequestHeader("X-API-KEY", ApiKey);
// Account / dashboard calls use a bearer JWT from POST /auth/login instead.
req.SetRequestHeader("Authorization", $"Bearer {jwt}");Responses & errors
Almost everything comes back in the same envelope, so unwrap .data and get on with it. When something fails, status flips to "ERROR" and error carries { code, detail, validationErrors[] }.
"Almost" is doing work in that sentence. Some /queue/* endpoints return a bare string: POST /queue/join answers with "Joined queue", not JSON. Parse it as JSON and you'll get an exception on a request that actually succeeded.
{
"status": "SUCCESS",
"message": "Player fetched successfully",
"data": { "id": 101, "username": "p-4821", "stats": { "...": "..." } },
"timestamp": "2026-07-06T10:15:30.123",
"error": null
}Accounts & API keys
Your account lives at /auth/me. Keys are prefixed mk_live_ or mk_test_ and hashed at rest, so we can't read them back to you. The free tier allows 30,000 write calls (POST/PUT/DELETE) per month and at most 1,000 in a UTC day; reads, auth and key management don't count against either. Whichever cap is closer to binding is the one the X-RateLimit-* headers report.
The secret is shown exactly once, when you create the key. There's no endpoint that will give it to you again. Lose it and you rotate it.
- GET/auth/meyour account, resolved from the JWT
- PATCH/auth/meupdate username / email / password
- DELETE/auth/meself-service account deletion
- GET/developer/api-keyslist keys (masked, never plaintext)
- POST/developer/api-keyscreate a key, returns the secret once
- DELETE/developer/api-keys/{id}revoke a key
Players API
A player belongs to your account and carries one stats block covering every rating algorithm: wins/losses, matchmakingScore (MMR), skillMean and uncertainty (TrueSkill μ/σ), and K/D/A counters. Whichever rating mode is active reads and writes the subset it cares about and leaves the rest alone. Full CRUD over REST, plus a gRPC surface for server-to-server.
The player record is a username you supply, a region, ratings and K/D/A. There's no email field, no device id, and no IP address column; we don't want them, so we didn't add them.
- POST/playercreate a player
- PUT/playerupdate a player
- GET/playerlist all your players
- GET/player/{id}one player, with stats
- GET/player/{id}/historyper-match K/D/A + win/loss records
- DELETE/player/{id}delete (blocked while in a live team)
using var req = UnityWebRequest.Get($"{Api}/player/101");
req.SetRequestHeader("X-API-KEY", ApiKey);
yield return req.SendWebRequest();
// ApiResponse<Player>: unwrap .data on the client.
Debug.Log(req.downloadHandler.text);Queues & matchmaking
Join, leave, inspect, tune and drop queues over REST. A scheduled matcher pulls candidates and asks the queue's MatchmakingMode to form teams: LP hands the assignment to an OR-Tools solver, MMR and ELO pair greedily down a sorted list. Formation and rating are separate axes: MatchmakingMode decides who ends up on which team, RatingMode decides what happens to their numbers afterwards. Live queue state sits in Redis (WATCH/MULTI compare-and-swap) or Kafka (compacted topics replayed into a local view, eventually consistent across nodes). queue.storage.type picks one, and Redis is the right answer unless you specifically want the event log. The matcher is ShedLock-guarded, so exactly one node ever resolves a given queue.
LP is the mode worth using: it's why MatchKit exists, and it's the default. In LP mode the matcher won't ship a split whose imbalance exceeds the queue's match-quality tolerance: it forms nothing and leaves the players queued until a fairer match appears or the wait cap force-ships the closest split. Both the tolerance and the wait cap are per-queue, so you set how strict fair is; or raise the tolerance, or switch to MMR/ELO, to turn the gate off. GK2 is currently accepted by the RatingMode enum and does nothing at all, and it's being removed rather than finished. Use TS.
- POST/queue/joinadd a player to a queue
- POST/queue/leaveremove a player (playerId query param)
- GET/queue/allevery live queue + entries
- GET/queue/playerwhich queue a player sits in
- POST/queue/configtune relaxation params live (queueID query param)
- DELETE/queuedrop a queue (queueID query param)
[System.Serializable]
class JoinBody { public long playerId; public string gameMode; public int teamSize; }
IEnumerator Join() {
var body = JsonUtility.ToJson(new JoinBody {
playerId = 101, gameMode = "ranked", teamSize = 5,
});
using var req = new UnityWebRequest($"{Api}/queue/join", "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("X-API-KEY", ApiKey);
yield return req.SendWebRequest(); // 200 · "Joined queue"
}Matches
A full match is persisted the moment it forms and pushed to subscribed clients. Report the result with POST /match/end, the winning team plus per-player stats, and the rating system for that match recalculates everyone's numbers and writes the history rows. You can read live rosters, or search history by time range, team, game mode or rating mode.
Ratings only move when you report the result. A match nobody ends stays open forever, and the players in it keep the ratings they walked in with.
- POST/match/endreport the result → triggers rating updates
- GET/match/getActiveMatchDetailslive rosters, one entry per team (matchId query param)
- GET/match/searchsearchable history (gameMode, ratingMode, hasEnded, …)
[System.Serializable]
class Stat { public long playerId; public int kills, deaths, assists; }
[System.Serializable]
class EndBody { public long matchId; public long winningTeamId; public Stat[] playerStats; }
IEnumerator EndMatch() {
var body = JsonUtility.ToJson(new EndBody {
matchId = 1042,
winningTeamId = 1,
playerStats = new[] {
new Stat { playerId = 101, kills = 12, deaths = 4, assists = 7 },
},
});
using var req = new UnityWebRequest($"{Api}/match/end", "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("X-API-KEY", ApiKey);
yield return req.SendWebRequest();
}Realtime events (SSE)
Match found, updated and completed events arrive on a stream, so you don't poll for them. It's SSE rather than WebSocket because these events only travel one way, server to client, and SSE is plain HTTP with reconnect built in, which means no second protocol to terminate, proxy or scale. The cost is that you can't send anything back up the stream; client-to-server stays REST. Subscribe one player to their own queue-to-match lifecycle, or subscribe an ops client to everything.
In a browser, EventSource can't set headers, so it can't send X-API-KEY. Connect through a same-origin proxy that injects the key server-side. From a game server there's no problem: set the header like any other request.
- GET/sse/client/{playerId}one player's queue → match lifecycle
- GET/sse/admin/{clientId}the full firehose of matchmaking activity
// Read the event stream with HttpClient; each event arrives as a data: line.
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-KEY", ApiKey);
using var stream = await client.GetStreamAsync($"{Api}/sse/client/101");
using var reader = new StreamReader(stream);
while (!reader.EndOfStream) {
var line = await reader.ReadLineAsync();
if (line != null && line.StartsWith("data:"))
Debug.Log(line.Substring(5)); // match found / updated / completed
}Health & monitoring
GET /health is public and unauthenticated, so point your load balancer at it. The actuator endpoints give you detailed health and a Prometheus scrape target, which matter mostly if you're self-hosting.
Don't expose /actuator/* to the internet. It belongs on your internal metrics network.
- GET/healthpublic liveness → { status: "UP" }
- GET/actuator/healthdetailed health (ops network)
- GET/actuator/prometheusscrape target for metrics
What's missing
None of this works today. It's here because finding out from the docs is better than finding out from a 404. Status lives on the roadmap.
- PlannedClient SDKs
Unreal, Godot, and a JS/TS client. There's a Unity SDK already; the rest aren't written and there's no date to give you. REST and SSE work from any stack in the meantime.
- PlannedBilling & plans API
/plans and /billing/*. Nobody pays anything during pre-alpha, and the 1,000-writes/day free cap is the only quota the service enforces today.
- PlannedWebhooks
match.found / match.completed / player.timeout POSTed to your endpoint, with retries and a delivery log, for when you'd rather not hold an SSE connection open. Use SSE until then.
- PlannedUsage metrics & request logs
A real usage endpoint behind the dashboard Overview. Those panels currently run on sampled data, which is worth knowing before you trust a number on them.
- PlannedPagination on list endpoints
Cursor and total controls on /player, /match/search and /auth/devs, so a large account doesn't get a slow response and a big payload.
- PlannedParties, regions, leaderboards, brackets
Queueing as a group with party-vs-party balancing, region-aware queues, leaderboards, and tournament brackets. All wanted, none started.
The pre-alpha is invite-only. Tell me what you're building and I'll send you a key. One message, no follow-ups.
Request an invite