Skip to content

Matchmaking API

Everything a game backend can call, and everything it receives back. This page is written by hand. The protobuf definitions are the contract source, and generated reference material will follow as the public surface expands.

SurfaceEndpointSideStatus
gRPC matchmaker.core.v1.MatchmakerServicematch.ivk.devWriteAvailable
HTTP/JSON, the same operationsmatch.ivk.devWriteComing soon
Outcome deliveryYour endpoint or socketReadSee Outcome Delivery

The API is split on purpose: submission is a request/response call, results are asynchronous. CreateTicket returns as soon as the ticket is durably in the pool, not when a match is found. Matches arrive later over the outcome sink. There is no long-polling “wait for my match” call. A match is a multi-party event, so pushing it is the only shape that works.


Every operation below is available two ways, and they are the same API:

  • gRPC, service matchmaker.core.v1.MatchmakerService.
  • HTTP/JSON, using the canonical protobuf JSON mapping.

Field names, validation, and error semantics are identical; only the envelope differs. Pick gRPC if your backend already speaks it and you want a generated client and lower per-call overhead. Pick HTTP/JSON for Unreal or Unity dedicated-server code, PHP or Ruby backends, serverless functions, or anywhere a gRPC toolchain is more trouble than it is worth. Mixing them is fine: nothing distinguishes a ticket created over gRPC from one created over HTTP.

OperationgRPCHTTP
Create a ticketCreateTicketPOST /v1/queues/{queue_id}/tickets
Cancel a ticketCancelTicketDELETE /v1/queues/{queue_id}/tickets/{ticket_id}
Reactivate ticketsReactivateTicketsPOST /v1/queues/{queue_id}/tickets:reactivate
Create a backfill requestCreateBackfillRequestPOST /v1/queues/{queue_id}/backfills
Cancel backfill requestsCancelBackfillRequestsPOST /v1/queues/{queue_id}/backfills:cancel
Clear a queue poolClearQueuePoolPOST /v1/queues/{queue_id}:clear

The endpoint is match.ivk.dev, over TLS. Every call carries a bearer token issued from the portal:

Authorization: Bearer <token>

Over gRPC that is call metadata; over HTTP/JSON it is a request header. See Authentication.

gRPC server reflection is enabled, so you can explore the surface without the protos in hand:

Terminal window
grpcurl -H 'Authorization: Bearer <token>' match.ivk.dev:443 list
grpcurl -H 'Authorization: Bearer <token>' match.ivk.dev:443 \
describe matchmaker.core.v1.MatchmakerService

Standard gRPC health checking (grpc.health.v1.Health) is also served.

Three fields are protobuf bytes: engine_input, metadata, and engine_output. In the canonical protobuf JSON mapping those are base64-encoded strings, so an HTTP client still has to produce a serialized EngineInput and base64 it.


Each operation is shown with its protobuf definition, which is also the JSON body shape.

OperationPurpose
Create a ticketPut a player or party into a queue.
Cancel a ticketRemove one ticket from matchmaking.
Reactivate ticketsReturn resolved tickets to the pool.
Create a backfill requestAsk for players to fill an existing match.
Cancel backfill requestsWithdraw backfill requests.
Clear a queue poolEmpty a queue’s pool. Test tooling.
gRPCCreateTicket
HTTPPOST /v1/queues/{queue_id}/tickets
rpc CreateTicket(CreateTicketRequest) returns (CreateTicketResponse);
message CreateTicketRequest { Ticket ticket = 1; }
message CreateTicketResponse { string ticket_id = 1; }

Stores a ticket and makes it immediately eligible for matching. Returns as soon as the ticket is durable (typically single-digit milliseconds), not when a match is found.

The caller supplies the ticket ID, and it has to be a UUID. The game backend generates the ID, so retrying after a timeout is idempotent without any extra work, and the backend never has to reconcile an ID it does not recognise.

Over gRPC:

Terminal window
grpcurl -H 'Authorization: Bearer <token>' -d '{
"ticket": {
"id": "3f2b8c1e-9a4d-4f7b-8e11-2c5d6a7b8c90",
"queue_id": "acme.quickplay",
"ruleset_ids": ["5v5"],
"engine_input": "<base64 nemesis EngineInput>",
"metadata": "<base64 opaque bytes>"
}
}' match.ivk.dev:443 matchmaker.core.v1.MatchmakerService/CreateTicket

The same call over HTTP:

Terminal window
curl -X POST https://match.ivk.dev/v1/queues/acme.quickplay/tickets \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"ticket": {
"id": "3f2b8c1e-9a4d-4f7b-8e11-2c5d6a7b8c90",
"queue_id": "acme.quickplay",
"ruleset_ids": ["5v5"],
"engine_input": "<base64 nemesis EngineInput>",
"metadata": "<base64 opaque bytes>"
}
}'
200 { "ticketId": "3f2b8c1e-9a4d-4f7b-8e11-2c5d6a7b8c90" }

Note the JSON field name: the canonical protobuf JSON mapping is lowerCamelCase, so ticket_id on the wire is ticketId. The original snake_case names are also accepted on input.

gRPCCancelTicket
HTTPDELETE /v1/queues/{queue_id}/tickets/{ticket_id}
rpc CancelTicket(CancelTicketRequest) returns (CancelTicketResponse);
message CancelTicketRequest {
string queue_id = 1;
string ticket_id = 2;
}

Removes the ticket from matchmaking. Call this when a player backs out of the queue.

There is an unavoidable race here. The ticket may already be in a match that has been committed but whose outcome has not yet been delivered. Cancellation then fails with FAILED_PRECONDITION, and the game backend should expect the match to arrive anyway. Never treat a failed cancel as “the player is out of queue”.

gRPCReactivateTickets
HTTPPOST /v1/queues/{queue_id}/tickets:reactivate
rpc ReactivateTickets(ReactivateTicketsRequest) returns (ReactivateTicketsResponse);
message ReactivateTicketsRequest {
string queue_id = 1;
repeated string ticket_ids = 2;
}
message ReactivateTicketsResponse {
repeated string failed_ticket_ids = 1;
}

Returns already-resolved tickets to the active pool. This is the compensating action for a match the backend cannot honour, most often a match whose server provisioning failed. Reactivate the tickets and the players are back in queue without losing their place.

failed_ticket_ids lists tickets that could not be reactivated because they are no longer in the pool at all, usually because they expired past terminal_retention_ttl_secs. Those players need a fresh CreateTicket.

That makes terminal_retention_ttl_secs a real integration parameter: it is the window in which a failed provisioning attempt can still be undone. Set it comfortably above the orchestrator’s worst-case provisioning timeout.

gRPCCreateBackfillRequest
HTTPPOST /v1/queues/{queue_id}/backfills
rpc CreateBackfillRequest(CreateBackfillRequestRequest) returns (CreateBackfillRequestResponse);
message CreateBackfillRequestRequest { BackfillRequest backfill_request = 1; }
message CreateBackfillRequestResponse { string backfill_id = 1; }

Asks the matchmaker to find players for a match that is already running. Auto-activates immediately. If a backfill request already exists for the same match_id, it is cancelled and replaced, so a running server can safely re-post its current open-slot count on a timer rather than tracking request state.

id may be left empty and the server will generate one. match_id must be a UUID. ruleset_id is required when the queue has more than one ruleset. A slots_requested of 0 is treated as 1.

gRPCCancelBackfillRequests
HTTPPOST /v1/queues/{queue_id}/backfills:cancel
rpc CancelBackfillRequests(CancelBackfillRequestsRequest) returns (CancelBackfillRequestsResponse);
message CancelBackfillRequestsRequest {
string queue_id = 1;
repeated string backfill_ids = 2;
}

Hard removal. Call it when the match fills up or ends.

gRPCClearQueuePool
HTTPPOST /v1/queues/{queue_id}:clear
rpc ClearQueuePool(ClearQueuePoolRequest) returns (ClearQueuePoolResponse);
message ClearQueuePoolRequest { string queue_id = 1; }
message ClearQueuePoolResponse {
uint64 tickets_cleared = 1;
uint64 backfill_requests_cleared = 2;
}

Destructively empties a queue. Intended for E2E test setup and teardown. It ejects every waiting player and emits ticket.removed and backfill.removed outcomes with reason CLEAR_COMMAND.


message Ticket {
string id = 1; // required, UUID, caller-supplied
string queue_id = 2; // required
google.protobuf.Timestamp created_at = 3; // server-set; any caller value is overwritten
optional bytes engine_input = 4; // engine-schema payload
repeated string ruleset_ids = 5; // empty = all rulesets in the queue
optional bytes metadata = 6; // opaque, round-tripped verbatim
}

The two payload fields carry most of the integration, and they do different jobs:

engine_inputmetadata
Who reads itThe match engineNobody; IVK Match never inspects it
SchemaFixed by the engine (see below)Entirely yours
ValidatedYes, parsed once at ingestion; a bad payload is rejectedNo
Returned to youNo (the engine’s own output comes back instead)Yes, verbatim, on every outcome
Default size cap32 KiB (max_engine_input_bytes)4 KiB (max_metadata_bytes)

Put matchmaking-relevant facts in engine_input: players, skill ratings, measured latencies, platform and playlist attributes. Put your own correlation state in metadata: session ID, party ID, region preference, region of the requesting shard, anything you want handed back when the match arrives. Use metadata well and you will not need a side lookup table keyed by ticket ID.

ruleset_ids selects which rulesets the ticket competes in. Empty means all of them. Unknown or duplicated IDs are rejected at submission.

message BackfillRequest {
string id = 1; // optional; server generates if empty
string queue_id = 2; // required
google.protobuf.Timestamp created_at = 3; // server-set; any caller value is overwritten
string match_id = 4; // required, UUID of the running match
string ruleset_id = 5; // required in multi-ruleset queues
uint32 slots_requested = 6; // open slots to fill
optional bytes engine_input = 7;
optional bytes metadata = 8;
}
message Match {
string id = 1; // UUID; use it as your session key
repeated Ticket tickets = 2; // every ticket, each with its metadata
optional bytes engine_output = 3; // engine-defined; teams, host selection
google.protobuf.Timestamp created_at = 4;
string ruleset_id = 5; // which ruleset produced this match
}

tickets is a flat list. Team composition lives inside engine_output, since how players are split into teams is the engine’s business. Decode engine_output with the schema of the engine your ruleset names.

message Backfill {
string id = 1;
BackfillRequest request = 2; // your original request, echoed in full
repeated Ticket assigned_tickets = 3; // tickets assigned to fill it
google.protobuf.Timestamp created_at = 4;
}

engine_input and engine_output are protobuf messages whose schema is set by the match engine. Queues run the nemesis engine; its schemas live in package matchmaker.engines.nemesis.v1.

Ticket.engine_input
message EngineInput {
repeated Player players = 1; // one entry per player; >1 = a party
map<string, Attribute> attributes = 2; // keyed by attr_filter name
}
message Player {
string player_id = 1;
double mmr = 2; // skill rating; unitless, see below
map<string, uint32> latencies = 3; // host key -> measured RTT in ms
}
message Attribute {
repeated string values = 1; // what this ticket *is*
repeated string accepts = 2; // what this ticket will *accept*
}

Four things to get right.

Parties are one ticket. A party of three is a single ticket with three Player entries. The engine never splits a ticket across teams.

mmr is unitless, and the queue config has to agree with your scale. The engine does not normalise it. It compares raw values and divides by the reference parameters in the queue config. The parameter defaults are tuned for a normalised 0.0-1.0 scale, so if you send a raw ladder rating in the thousands, the skill term will dominate everything else until you rescale mmr_normalization_ref and party_synergy_bonus to match. See Nemesis § The skill rating scale.

latencies keys are your host identifiers. The map is opaque to IVK Match. Whatever string you use (eu-central, fra1, a specific machine ID) is echoed back as the match’s chosen host, which is the join point with the orchestrator. Use keys that map straight onto something the platform’s provisioning API accepts and the game backend gets a lookup instead of a translation layer. A host with no measurement for a player is assumed to be default_latency_ms.

values and accepts are what implement crossplay. For an attribute in overlap mode (the default, e.g. playlist) two tickets are compatible if their values share at least one entry. For containment mode (e.g. platform) every value on each side has to appear in the other side’s accepts. That is how you express “a PC player who allows console opponents can be matched with a console player who allows PC opponents”.

A two-player party on a normalised skill rating scale:

{
"players": [
{ "player_id": "p1", "mmr": 0.62,
"latencies": { "eu-central": 22, "eu-west": 41, "us-east": 96 } },
{ "player_id": "p2", "mmr": 0.55,
"latencies": { "eu-central": 28, "eu-west": 35, "us-east": 104 } }
],
"attributes": {
"playlist": { "values": ["TDM", "Upload"], "accepts": [] },
"platform": { "values": ["pc"], "accepts": ["pc", "xbox", "playstation"] }
}
}
Match.engine_output
message EngineOutput {
repeated Team teams = 1;
repeated Attribute attributes = 2; // per-attribute intersection across the match
HostSelection host = 3; // unset if there were no host candidates
// NOT the same message as the input Attribute: no `values` field.
message Attribute {
string name = 1;
repeated string accepts = 2; // the match-wide intersection for this attribute
}
}
message Team {
string id = 1;
repeated string player_ids = 2;
}
message HostSelection {
string preferred = 1; // the host to provision on
repeated string acceptable = 2; // every host acceptable to all tickets
}

HostSelection is the field the orchestration integration hangs off. preferred is the best host key for this match; acceptable is every host key that all tickets in the match can tolerate, so it doubles as a fallback list when provisioning on preferred fails. attributes carries the intersection of each filtered attribute, so the backend knows which playlist the match settled on without recomputing it. Note that the output Attribute is a nested message of EngineOutput with name and accepts fields; it is not the input Attribute type.

For the parameters that control how these inputs are scored, see Nemesis.


Every asynchronous result is a matchmaker.outcomes.v1.Outcome, regardless of which sink delivers it.

message Outcome {
string id = 1;
string kind = 2; // e.g. "match.created"
string queue_id = 3;
google.protobuf.Timestamp occurred_at = 4;
string producer_id = 5;
uint64 producer_sequence = 6; // monotonic per producer
oneof payload {
MatchCreated match_created = 10;
BackfillCreated backfill_created = 11;
TicketsExpired tickets_expired = 12;
TicketsRemoved tickets_removed = 13;
BackfillRequestsExpired backfill_requests_expired = 14;
BackfillRequestsRemoved backfill_requests_removed = 15;
TicketOrphaned ticket_orphaned = 16;
BackfillRequestOrphaned backfill_request_orphaned = 17;
}
}
kindPayloadMeaningBackend must
match.createdMatchCreated { Match }A match was formedProvision a server and notify players
backfill.createdBackfillCreated { Backfill }A backfill request was filledSend the new players to the running server
ticket.expiredTicketsExpiredTickets aged out unmatchedTell the player the search timed out
ticket.removedTicketsRemoved + RemovalReasonTickets left the poolRe-queue or notify, per reason
backfill.expiredBackfillRequestsExpiredRequest aged out unfilledStop waiting for players
backfill.removedBackfillRequestsRemoved + reasonRequest left the poolStop waiting for players
ticket.orphanedTicketOrphanedA ticket referenced a ruleset that was removed mid-flightRe-queue the player
backfill.orphanedBackfillRequestOrphanedSame, for a backfill requestRe-post the request

The expired and removed families are batched: one outcome carries a repeated list of IDs plus an OutcomeBatch { operation_id, chunk_index, chunk_count } so a large sweep can be chunked across messages and reassembled. Orphaned outcomes are not batched; each one carries a single ticket or backfill request.

RemovalReason tells the backend whether the removal is the player’s problem or the operator’s:

ReasonCauseSuggested handling
CLEAR_COMMANDClearQueuePool was calledRe-queue
QUEUE_CONFIG_CHANGEDThe queue spec changed incompatiblyRe-queue
QUEUE_RETIREDThe queue was retiredStop routing to this queue
RULESET_PARAMS_CHANGEDEngine params were hot-reloaded and stranded the ticketRe-queue

Handle outcomes idempotently. Delivery is at-least-once on every sink. Use Outcome.id, or Match.id for match outcomes, as the dedupe key.


One set of failure conditions, expressed as gRPC status codes on 9001 and as HTTP status codes on 8080.

gRPC codeHTTPWhenRetry?
INVALID_ARGUMENT400Malformed request: non-UUID id, missing queue_id, payload over max_metadata_bytes / max_engine_input_bytesNo; fix the request
NOT_FOUND404queue_id does not exist, when creating a ticket or a backfill request. (Clearing a pool reports every failure as NOT_FOUND.)No; check the queue config
FAILED_PRECONDITION409The request conflicts with the queue’s current state: unknown or duplicated ruleset_id, engine_input that does not parse, a ticket already in a committed match, a retiring queue. Also returned for an unknown queue_id when cancelling or reactivating.Depends; see below
UNAVAILABLE503The queue runtime is starting, restarting, or shutting downYes, with backoff
INTERNAL500A serialization or internal failureRetry once, then alert

Over HTTP the body carries the same detail as the gRPC status message:

{ "code": "FAILED_PRECONDITION", "message": "queue is retiring" }

Two cases need explicit handling in the game backend:

Queue retiring. FAILED_PRECONDITION / 409, carrying x-ivk-reason: QUEUE_RETIRING as gRPC response metadata or as an HTTP response header. Do not retry against this queue; it is going away. Route players elsewhere or surface a maintenance message.

Service restarting. UNAVAILABLE during a restart or upgrade. Retry with exponential backoff. The pool is restored from its checkpoint, so tickets created before the restart are still in queue. Show this to a player as a hard failure and you turn a few seconds of unavailability into a visible outage.

Because ticket creation uses caller-supplied IDs, retrying it after a timeout is safe: the same ID cannot produce two pool entries.