{
  "openapi": "3.1.0",
  "info": {
    "title": "Pairiquium Integration API",
    "version": "1.3.0",
    "license": {
      "name": "Apache 2.0",
      "identifier": "Apache-2.0"
    },
    "description": "The HTTP surface a game backend integrates with: minting a matchmaking ticket, and the webhook Pairiquium calls when a match is made. The real-time client protocol is described separately in the AsyncAPI document."
  },
  "servers": [
    {
      "url": "https://tickets.pairiquium.com",
      "description": "Latency-routed to the nearest region. Tickets can still be minted for any active region via `preferredRegion`."
    }
  ],
  "paths": {
    "/v1/tickets": {
      "post": {
        "operationId": "generateTicket",
        "summary": "Mint a matchmaking ticket for a player",
        "description": "Call this from **your backend** after you have authenticated the player. Never call it from a game client: it requires your secret API key. Hand the returned `ticket` and `wsEndpoint` to the client, which connects to `wsEndpoint` with `?ticket=<ticket>`.\n\nTickets are single-use, expire after 300 seconds, and are locked to one region. Mint a new ticket for every connection attempt, including reconnects.\n\nRequires an API key with the `tickets:create` scope.",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GenerateTicketRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ticket minted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GenerateTicketResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid JSON, failed validation, or `preferredRegion` is not an active region.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "No `X-MaaS-API-Key` header was sent. The body is `{\"message\": \"Unauthorized\"}`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "The API key is not valid (body `{\"message\": \"Forbidden\"}`), does not have the `tickets:create` scope (body `{\"error\": \"Forbidden\", \"message\": \"...\"}`), or the tenant is on the signup waitlist (body `{\"error\": \"...\"}`). The body shape depends on which check failed, so rely on the status code.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Unexpected server error. Safe to retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiErrorResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "matchFound": {
      "post": {
        "operationId": "matchFoundWebhook",
        "security": [],
        "summary": "A match has been made",
        "description": "Pairiquium sends this to the `NotificationWebhook` URL configured on the game mode when a match is made. Use it to allocate or select a game server, then deliver connection details to the players.\n\n- Respond with a `2xx` status within 5 seconds. Any other status, or a timeout, is treated as a failure and retried.\n- The URL must be a public `https` address; `http`, credentials in the URL, `localhost`/`.local`/`.internal` names and private, loopback or link-local IP addresses are rejected. Redirects are not followed: a `3xx` response is a failure.\n- Delivery is at-least-once. De-duplicate on the `Pairiquium-Webhook-Id` header (equal to `matchId`).\n- Every request is signed. Verify the `Pairiquium-Signature` header before trusting the body: it is `t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `<t>.<raw request body>` keyed with the game mode's webhook signing secret (shown in the dashboard). Compare in constant time and reject timestamps more than 5 minutes from your clock. Game modes created before signing was introduced send no signature until their secret is rolled.",
        "parameters": [
          {
            "name": "Pairiquium-Signature",
            "in": "header",
            "required": false,
            "description": "`t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<raw body>\">`. Present once the game mode has a signing secret.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "Pairiquium-Webhook-Id",
            "in": "header",
            "required": true,
            "description": "Unique ID for this match (the `matchId`). Identical across retries; use it to de-duplicate.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "Pairiquium-Event",
            "in": "header",
            "required": true,
            "description": "The event type. Currently always `match.found`.",
            "schema": {
              "type": "string",
              "enum": [
                "match.found"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MatchWebhookPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged. Any 2xx status is accepted; the response body is ignored."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-MaaS-API-Key",
        "description": "Your secret API key. Keep it on your backend; never ship it in a game client."
      }
    },
    "schemas": {
      "GenerateTicketRequest": {
        "type": "object",
        "properties": {
          "userId": {
            "type": "string",
            "minLength": 1,
            "description": "Your identifier for the player. Becomes the participant `UserId` in match webhooks."
          },
          "attrs": {
            "description": "Player attributes used by game modes that match on them.",
            "type": "object",
            "properties": {
              "mmr": {
                "type": "integer",
                "minimum": 0,
                "maximum": 10000
              },
              "karma": {
                "type": "integer",
                "minimum": -1000,
                "maximum": 1000
              }
            }
          },
          "preferredRegion": {
            "description": "Region to lock the ticket to. Defaults to the region that handled the request.",
            "type": "string"
          }
        },
        "required": [
          "userId"
        ]
      },
      "GenerateTicketResponse": {
        "type": "object",
        "properties": {
          "ticket": {
            "type": "string",
            "description": "Single-use, short-lived, region-locked JWT. Pass it to the client."
          },
          "wsEndpoint": {
            "type": "string",
            "description": "WebSocket endpoint the ticket is valid for."
          }
        },
        "required": [
          "ticket",
          "wsEndpoint"
        ]
      },
      "ApiErrorResponse": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "details": {
            "description": "Per-field validation messages, on 400 responses.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          }
        },
        "required": [
          "error"
        ]
      },
      "MatchWebhookPayload": {
        "type": "object",
        "properties": {
          "gameMode": {
            "type": "string"
          },
          "matchId": {
            "type": "string"
          },
          "participants": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "UserId": {
                  "type": "string",
                  "description": "The `userId` supplied when the ticket was generated."
                },
                "SearchDurationSeconds": {
                  "type": "number",
                  "description": "How long this player was searching before the match was made."
                },
                "Team": {
                  "type": "integer",
                  "minimum": 0,
                  "maximum": 9007199254740991,
                  "description": "Zero-based team index."
                },
                "Mmr": {
                  "description": "Present when the game mode matches on MMR.",
                  "type": "number"
                },
                "Ping": {
                  "description": "Measured round-trip time in ms; present when the game mode matches on ping.",
                  "type": "number"
                },
                "PartyId": {
                  "description": "Present when the player queued as part of a party; players sharing a `PartyId` queued together.",
                  "type": "string"
                }
              },
              "required": [
                "UserId",
                "SearchDurationSeconds",
                "Team"
              ]
            }
          }
        },
        "required": [
          "gameMode",
          "matchId",
          "participants"
        ]
      }
    }
  }
}
