6. Match Made

When the matchmaking loop forms a match, two separate things happen: each participating game client receives a MATCH_FOUND message over its WebSocket connection, and the tenant's backend receives a webhook. This page covers both.

Client: MATCH_FOUND

The server pushes this action over the same connection a player used to begin_search:

{
  "action": "MATCH_FOUND",
  "data": {
    "metadata": {
      "matchId": "b3f1a2e0-4c1d-4e9a-9f2e-2a6f7c1d8b90",
      "team": 0
    }
  }
}

data.metadata.matchId identifies the match — the same matchId your backend receives on the Notification Webhook below, so you can correlate the two. data.metadata.team is the zero-based index of the team this player was placed on, per the game mode's Match Shape. The client isn't sent the other participants — that list is only included in the webhook payload sent to your backend.

This is the last message on the connection

Once MATCH_FOUND is sent, the search is complete — there's nothing further to send or receive on this connection for matchmaking purposes.

Backend: Notification Webhook

Separately, Pairiquium sends a POST request to the game mode's Notification Webhook — a URL you configure per game mode from the Config page (see Game Modes). Use it to record matches, trigger server-side game setup, or anything else your backend needs to do.

{
  "gameMode": "RANKED_1V1",
  "matchId": "b3f1a2e0-4c1d-4e9a-9f2e-2a6f7c1d8b90",
  "participants": [
    {
      "UserId": "player-123",
      "SearchDurationSeconds": 12,
      "Team": 0,
      "Mmr": 1500,
      "PartyId": "5d0c2d6e-7a53-4a56-9c3f-0a4f1a9b2f10"
    },
    {
      "UserId": "player-456",
      "SearchDurationSeconds": 8,
      "Team": 1,
      "Mmr": 1540
    }
  ]
}
FieldNotes
gameModeThe machine-readable game mode ID the match was made for.
matchIdUnique ID for this match — the same value sent to game clients in MATCH_FOUND.
participantsOne entry per player in the match.
participants[].UserIdThe player ID from their ticket.
participants[].SearchDurationSecondsHow long the player was searching before this match was made.
participants[].TeamThe zero-based index of the team this player was placed on, per the game mode's Match Shape.
participants[].MmrThe player's MMR at match time. Only present for MMR-based game modes.
participants[].PingThe player's measured round-trip time in ms. Only present for ping-based game modes.
participants[].PartyIdPresent when the player queued as part of a party. Players sharing a PartyId queued together. Absent for solo players.

Your endpoint must respond with a 2xx status within 5 seconds — a timeout, error status, or unreachable URL is treated as a failed notification.

The URL you configure must be a public https address. These are rejected when you save the game mode, and checked again before every delivery:

  • http URLs, and URLs with a username or password in them.
  • localhost, *.local and *.internal names, and single-word hostnames.
  • IP addresses on private, loopback, link-local or otherwise reserved ranges — IPv4 or IPv6, in any spelling (for example 10.0.0.5, 127.0.0.1, 169.254.169.254, [::1]).

Redirects aren't followed

A 3xx response is treated as a failed notification, and the request is never sent on to the Location it points at. Configure the final URL directly.

To receive webhooks while developing on your own machine, expose it through a tunnel such as ngrok or Cloudflare Tunnel and use the public https URL it gives you.

Verifying webhook signatures

Every webhook is signed so your backend can tell it came from Pairiquium and wasn't altered or replayed. Each game mode has its own signing secret (whsec_…): it's shown once when you create the game mode, and you can roll it at any time from the Webhook Signing Secrets list in the API Keys section of the Config page. Deleting a game mode deletes its secret.

Each request carries these headers:

HeaderNotes
Pairiquium-Signaturet=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw request body>, keyed with the game mode's signing secret.
Pairiquium-Webhook-IdThe matchId. Identical on every retry, so use it to de-duplicate — delivery is at-least-once.
Pairiquium-EventAlways match.found for now.

To verify a request:

  1. Read the raw request body before any JSON parsing — the signature covers the exact bytes sent.
  2. Split Pairiquium-Signature into t and v1.
  3. Compute the HMAC-SHA256 of `${t}.${rawBody}` with your signing secret, as lowercase hex.
  4. Compare it to v1 in constant time.
  5. Reject the request if t is more than 5 minutes from your clock, so a captured request can't be replayed later.
import { createHmac, timingSafeEqual } from 'node:crypto';
 
export function verifyPairiquiumSignature(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
 
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const provided = Buffer.from(parts.v1, 'hex');
  return provided.length === expected.length && timingSafeEqual(expected, provided);
}
import hashlib, hmac, time
 
def verify_pairiquium_signature(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    try:
        t = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
 
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)
using System.Security.Cryptography;
using System.Text;
 
public static bool VerifyPairiquiumSignature(string secret, string rawBody, string header, int toleranceSeconds = 300)
{
    var parts = header.Split(',')
        .Select(p => p.Split('=', 2))
        .Where(p => p.Length == 2)
        .ToDictionary(p => p[0].Trim(), p => p[1].Trim());
 
    if (!parts.TryGetValue("t", out var tValue) || !long.TryParse(tValue, out var t)) return false;
    if (!parts.TryGetValue("v1", out var provided)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > toleranceSeconds) return false;
 
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
 
    byte[] providedBytes;
    try { providedBytes = Convert.FromHexString(provided); } catch (FormatException) { return false; }
    return CryptographicOperations.FixedTimeEquals(expected, providedBytes);
}

Rolling a secret takes effect immediately

The old secret stops working the moment you roll. Roll, copy the new secret, and update your backend straight away — webhooks that arrive in between will fail verification (return a non-2xx and they're retried, so no match is lost). Game modes created before signing was introduced send unsigned webhooks until you roll their secret for the first time.

The webhook payload is also described in the OpenAPI spec; see API Specs.

Next

While a search is waiting to be matched, the client needs to stay alive and handle drops gracefully — see Heartbeats & Disconnects.