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
}
]
}| Field | Notes |
|---|---|
gameMode | The machine-readable game mode ID the match was made for. |
matchId | Unique ID for this match — the same value sent to game clients in MATCH_FOUND. |
participants | One entry per player in the match. |
participants[].UserId | The player ID from their ticket. |
participants[].SearchDurationSeconds | How long the player was searching before this match was made. |
participants[].Team | The zero-based index of the team this player was placed on, per the game mode's Match Shape. |
participants[].Mmr | The player's MMR at match time. Only present for MMR-based game modes. |
participants[].Ping | The player's measured round-trip time in ms. Only present for ping-based game modes. |
participants[].PartyId | Present 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:
httpURLs, and URLs with a username or password in them.localhost,*.localand*.internalnames, 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:
| Header | Notes |
|---|---|
Pairiquium-Signature | t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw request body>, keyed with the game mode's signing secret. |
Pairiquium-Webhook-Id | The matchId. Identical on every retry, so use it to de-duplicate — delivery is at-least-once. |
Pairiquium-Event | Always match.found for now. |
To verify a request:
- Read the raw request body before any JSON parsing — the signature covers the exact bytes sent.
- Split
Pairiquium-Signatureintotandv1. - Compute the HMAC-SHA256 of
`${t}.${rawBody}`with your signing secret, as lowercase hex. - Compare it to
v1in constant time. - Reject the request if
tis 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.