GetPaird API
A free, read-only REST API for GetPaird tournament data — standings, rounds, players and decklists across every supported TCG. Build coverage tools, meta trackers, Discord bots and more.
Usage & rate limits
The GetPaird API is free. It exposes read-only, public tournament data — exactly what the public live pages show, never more. All endpoints share one base URL:
https://getpaird.io/api/v1
A machine-readable OpenAPI 3.0 description of the API is available (no key needed) — point your client generator at it:
https://getpaird.io/api/v1/openapi.json
Rate limits
100 requests per minute per read endpoint, 20 per minute on the heavier POST search. Each developer key gets its own bucket, so one key's traffic never throttles another. Exceeding a limit returns 429 with a Retry-After header.
Freshness
Live payloads are served from a short server-side cache (about a minute); completed tournaments are cached longer. Poll gently — the data cannot move faster than the cache.
Authentication
Every tournament endpoint requires an API key. Keys are free: create one from your account page (up to 5 active keys, revocable at any time). The key is shown once at creation — store it like a password.
Manage my API keysSend the key in the Authorization header — either raw or as a Bearer token, both are accepted:
curl https://getpaird.io/api/v1/tournaments/my-tournament-slug/standings \ -H "Authorization: YOUR_API_KEY" # equivalent: curl https://getpaird.io/api/v1/tournaments/my-tournament-slug/standings \ -H "Authorization: Bearer YOUR_API_KEY"
An invalid or missing key returns 401 with the body below. Keys are personal: never ship one in client-side code or a public repository.
{ "error": "Invalid API key" }
What a key can reach
A key is scoped to the public API and nothing else. It is read-only and cannot touch the account that created it — no profile, no private decklists, no organizer or judge action. Used on any other endpoint it returns 403:
{ "error": "This API key is restricted to the public API. Only /api/v1/tournaments endpoints are available to developer keys." }
Your first call
The tournament identifier is the slug from its public page: for getpaird.io/tournaments/my-event, the slug is my-event. Start with this — no key needed — just to confirm the API answers you:
https://getpaird.io/api/v1/tournaments/YOUR-SLUG/standings
Opened directly in a browser it must return 401 {"error":"Invalid API key"}. That response is the good news — the server answered and is only telling you the key is missing. Now add the key:
curl -H "Authorization: YOUR_API_KEY" \ https://getpaird.io/api/v1/tournaments/YOUR-SLUG/standings
If it does not work
| What you see | What it means |
|---|---|
| 401 Invalid API key | The key is missing, mistyped, or revoked. Send the WHOLE key including the digits and the vertical bar before it — that prefix is part of the secret. |
| 403 | The key is valid but the endpoint is outside the public API. Developer keys only reach /api/v1/tournaments. |
| 404 Tournament not found | Wrong slug, or the tournament is private, still a draft, or a test event. Check the slug against its public page. |
| A browser tool (Hoppscotch, Postman web) reports a bare "network error" | Cross-origin protection: browsers may only call this API from getpaird.io itself. This affects browser-based testers ONLY — servers, bots and curl are unaffected. Switch your tool to its proxy/agent mode, or test from a terminal. |
| curl (35) schannel … CRYPT_E_NO_REVOCATION_CHECK | A Windows-only TLS quirk: your machine could not reach the certificate revocation service. Nothing to do with this API — add --ssl-no-revoke to the curl command. |
| curl: unknown option / PowerShell errors | In PowerShell, curl is an alias for a different tool. Write curl.exe explicitly. |
Never put a key in a web page or any code that runs in a browser — it is readable by every visitor. Keep it on a server or in a bot.
Managing your keys
On the account API-keys page you can set an optional expiry when you create a key, see each key's recent request count, and revoke any key instantly. If a key stops working with a 403 saying it is suspended, it was disabled for abuse — write to [email protected]. GetPaird records per-key usage (endpoints, volume, error and throttle rates) to keep the API healthy; no IP address is stored.
Attribution
Any project using the API must include a visible credit and link back to GetPaird. By using the API you agree to this requirement and to using the data in a way that respects players (no scraping for spam, no re-identification attempts on anonymized players).
<p>Data provided by <a href="https://getpaird.io" target="_blank">GetPaird</a> </p>
Tournament endpoints
The tournament identifier (tid) is the tournament's URL slug — the same one you see on its public page. Player ids are per-tournament registration ids: stable within one tournament, meaningless across tournaments (there is deliberately no cross-event player tracker). Anonymized accounts appear as "Player #id".
/v1/tournaments
Search tournaments. Two modes: TID mode (a list of known tournament slugs, any public status — follow an event live) or query mode (filters; completed tournaments only). Returns up to 25 tournaments, most recent first, each with standings and optional round data.
Request body
| Field | Type | Description |
|---|---|---|
| TID | string | string[] | Tournament slug or array of slugs (max 25). Disables query-mode filters. |
| game | string | Game name, exactly as on GetPaird (e.g. "Magic: The Gathering"). Required in query mode. |
| format | string | Format label, exactly as on GetPaird (e.g. "Modern", "Commander Multiplayer"). Required in query mode. |
| start | integer | Earliest tournament start date (unix seconds). |
| end | integer | Latest tournament start date (unix seconds). |
| last | integer | Days back from today. Alternative to start / end. |
| participantMin | integer | Minimum participant count. A tournament whose organizer has hidden its player count is not returned by a size-filtered search. |
| participantMax | integer | Maximum participant count. Same privacy rule as participantMin. |
| columns | string[] | Standings fields to include. Allowed: name, id, decklist, archetype, leader, wins, draws, losses, byes, winRate, points, winsSwiss, winsBracket, lossesSwiss, lossesBracket, winRateSwiss, winRateBracket. Default: name, wins, draws, losses. Requesting decklist also returns deckObj; the team roster (players) is always included on team events. |
| rounds | boolean | string[] | Include round data. A non-empty array (["round","tables"]) is treated as true. Default false. |
| tables | string[] | When rounds is set, narrow each table to these fields (table, players, winner, status). Requesting winner emits winner, winner_id, winner_games and loser_games. Omit for the full table shape. |
| players | string[] | When rounds is set, choose the per-player fields inside round tables (name, id, decklist, leader). decklist also emits deckObj. Omit for the full player shape (name, id, profileUrl). |
| leagues | boolean | Accepted for compatibility; GetPaird has no league event type, so it has no effect. |
Example
curl -X POST https://getpaird.io/api/v1/tournaments \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"game": "Magic: The Gathering",
"format": "Modern",
"last": 30,
"columns": ["name", "wins", "draws", "losses", "archetype"],
"rounds": true
}'
[
{
"TID": "modern-mayhem-12",
"tournamentName": "Modern Mayhem #12",
"game": "Magic: The Gathering",
"format": "Modern",
"startDate": 1752303600,
"swissNum": 5,
"topCut": 8,
"eventData": {
"city": "Lyon",
"country": "France",
"address": "12 Rue des Jeux",
"latitude": 45.7640430,
"longitude": 4.8356590,
"headerImage": "https://getpaird.io/storage/..."
},
"standings": [
{ "standing": 1, "name": "Alice", "wins": 6, "draws": 0, "losses": 1, "archetype": "Burn" }
],
"rounds": [
{ "round": 1, "tables": [ { "table": 1, "players": [...], "winner": "Alice", "winner_id": 1041, "winner_games": 2, "loser_games": 1, "status": "Completed" } ] }
]
}
]
/v1/tournaments/{tid}
Full tournament data in one response: metadata, standings and every published round.
{
"data": {
"tid": "modern-mayhem-12",
"name": "Modern Mayhem #12",
"game": "Magic: The Gathering",
"format": "Modern",
"startDate": 1752303600
},
"standings": [ ... ], // same rows as /standings
"rounds": [ ... ] // same shape as /rounds
}
Team events add isTeamEvent, teamSize and teamTags (the ordered per-seat labels) to data.
Links
Every payload carries links so you never build a getpaird.io URL by hand. /info (and full data) carry a `urls` object (web page, live view, results, and the API self-link) plus an `event` object when the tournament belongs to a multi-tournament event. Each standings row and each round-table player carries a `profileUrl` (the player's public profile, null for an anonymized account or a team) and, once the event is completed and the list published, a `decklistUrl` to the public snapshot. The player endpoint adds `urls.tournament`.
"urls": {
"web": "https://getpaird.io/tournaments/my-event",
"live": "https://getpaird.io/tournaments/my-event/live",
"results": "https://getpaird.io/tournaments/my-event/results",
"api": "https://getpaird.io/api/v1/tournaments/my-event/info"
},
"event": { "name": "Spring Weekend", "slug": "spring-weekend",
"urls": { "web": "https://getpaird.io/events/spring-weekend" } }
// on a standings row / a round-table player:
"profileUrl": "https://getpaird.io/meta/players/alice",
"decklistUrl": "https://getpaird.io/tournaments/my-event/lists/421"
/v1/tournaments/{tid}/info
Tournament metadata, schedule and location.
| Field | Type | Description |
|---|---|---|
| tid | string | Tournament slug. |
| name | string | Tournament name. |
| game | string | Game being played. |
| format | string | Game format label. |
| status | string | Complete, Ongoing, Not Started, or Cancelled. |
| startDate | number | Unix timestamp of start. |
| endDate | number | null | Unix timestamp of planned end. |
| swissNum | number | null | Planned Swiss rounds (sum across Swiss phases). |
| topCut | number | Size of the top cut (0 if none). |
| registeredCount | number | null | Current participating player count. null when the organizer has chosen not to publish it. |
| capacity | number | null | Player cap (null if uncapped). |
| location | object | null | Venue: name, address, city, postalCode, country, latitude, longitude (plus lat/lng aliases). |
| headerImage | string | null | URL of the event header image. |
| organization | object | null | Organizer: name, slug. |
| event | object | null | The parent event (name, slug, urls.web) when this tournament belongs to a multi-tournament event. null for a standalone or a non-public parent. |
| urls | object | web, live, results and the API self-link. |
| phases | array | The full competitive pipeline, in play order (see below). |
| isTeamEvent / teamSize / teamTags / sharedTeamGame | various | Present on team events only. sharedTeamGame is false (each seat plays its own board). |
The phase pipeline
`phases` describes how the event is actually run — a Swiss into a Top 8, a draft into constructed, a Swiss into an EDH Top Pod. Each phase carries its structure (`kind`, plus the raw `type` / `mode` / `eliminationMode`), the `cut` of players kept entering it (or `cutMinPoints` for a points-threshold cut, e.g. a Day-1 cut at 21 points), its planned `rounds`, `bestOf`, `status`, and its OWN `scoring` + `tiebreakerOrder` (so a phase that scores differently from the one before it is not flattened away). `swissNum` counts only true Swiss rounds (null when there is no Swiss phase); `topCut` is the size of the cut OUT of Swiss (the first elimination phase). Both are kept as convenience scalars.
"phases": [
{ "sequence": 1, "name": "Swiss", "kind": "swiss", "type": "swiss",
"mode": "constructed", "eliminationMode": null, "cut": null,
"rounds": 5, "bestOf": 3, "status": "completed", "decklistsOpen": false,
"scoring": { "system": "match_points", "winPoints": 3, ... },
"tiebreakerOrder": [ { "key": "omw", "label": "OMW%", "format": "pct" }, ... ] },
{ "sequence": 2, "name": "Top 8", "kind": "single_elimination",
"type": "single_elimination", "mode": "constructed", "cut": 8,
"rounds": 3, "bestOf": 3, "status": "active", ... }
]
// kind ∈ swiss | single_elimination | double_elimination | round_robin |
// swiss_top_cut | draft | multiplayer_swiss | multiplayer_elimination
/v1/tournaments/{tid}/standings
Current standings as a flat array. Once a top cut has started, the order folds the bracket results in (champion first); the Swiss tiebreaker percentages are preserved on each row. Returns an empty array while the organizer has hidden standings.
| Field | Type | Description |
|---|---|---|
| standing | number | Position, 1-based, in the returned order. |
| name | string | Player display name (team name on team events). |
| id | number | Player id (per-tournament registration id) — use it with /players/{id}. |
| points | number | Match points. |
| wins / losses / draws | number | Match record. wins INCLUDES byes (official standings semantics). |
| byes | number | Byes received across published rounds (also counted inside wins). On team events this counts team byes, not a single conceded seat. |
| winRate | number | Match-win percentage, 0.0-1.0. |
| opponentWinRate | number | Opponents' match-win percentage (OMW%). |
| gameWinRate / opponentGameWinRate | number | null | Game-win percentages (GW% / OGW%). null on multiplayer (pod) events, where game win rates do not apply. |
| winsSwiss / winsBracket / lossesSwiss / lossesBracket | number | Record split between the Swiss phase and the top-cut bracket. |
| winRateSwiss / winRateBracket | number | null | Per-phase win rate. null when the player played no match in that phase. |
| archetype | string | null | Deck archetype — see decklist visibility below. null on a team row (the deck belongs to a roster member). |
| leader | string | null | The deck's leader / commander card name(s) (Riftbound legend, MTG Commander) — visible with the cards. |
| decklist | string | null | Plain-text decklist — see decklist visibility below. null on a team row. |
| deckObj | object | null | Structured decklist — see decklist visibility below. null on a team row. |
| players | array | Team events only: the roster, each with id, name, teamTag, profileUrl and its OWN archetype / leader / decklist / deckObj. |
Precision, and which tiebreakers you get
The four percentages are returned with 4 decimals because that is exactly how the platform stores them: the standings columns are decimal(5,4). Returning more digits would invent precision the database does not hold. Do not reconstruct the ranking by comparing these floats yourself — the official order is the "standing" field, computed server-side with an epsilon so that a true tie falls through to the next tiebreaker instead of being decided by floating-point noise.
The four flat fields are kept for TopDeck compatibility, but every configured tiebreaker — not just these four — is now exposed. Each standings row carries a `tiebreakers` array in the exact order that decides rank; and /info advertises that order once, in `tiebreakerOrder`, alongside a `scoring` block (points per win/draw/loss/bye and the tiebreaker floor). So a Buchholz event, an OppAvgMP event or a multiplayer pod event is fully reproducible too.
// on /info
"tiebreakerOrder": [
{ "key": "omw", "label": "OMW%", "format": "pct" },
{ "key": "gw", "label": "GW%", "format": "pct" },
{ "key": "de", "label": "Direct Encounter", "format": "pairwise" }
],
"scoring": { "system": "match_points", "winPoints": 3, "drawPoints": 1,
"lossPoints": 0, "byePoints": 3, "tiebreakerFloor": 0.3333 }
// each standings row, in the SAME order:
"tiebreakers": [
{ "key": "omw", "label": "OMW%", "format": "pct", "value": 0.6122 },
{ "key": "gw", "label": "GW%", "format": "pct", "value": 0.8 },
{ "key": "de", "label": "Direct Encounter", "format": "pairwise", "value": null }
]
A `pairwise` tiebreaker (Direct Encounter) decides ties head-to-head, so it has no per-row number — its value is null but the entry is always present. Format is one of pct, int, dec2 or pairwise. Still: do not re-derive the order from these values, "standing" is authoritative (a true tie falls through to the next tiebreaker with an epsilon the client cannot reproduce).
[
{
"standing": 1,
"name": "Alice",
"id": 1041,
"points": 18,
"wins": 6, "losses": 1, "draws": 0, "byes": 1,
"winRate": 0.8571,
"opponentWinRate": 0.6122,
"gameWinRate": 0.8,
"opponentGameWinRate": 0.55,
"archetype": "Burn",
"decklist": "// Mainboard\n4 Lightning Bolt\n...",
"deckObj": { "Mainboard": { "Lightning Bolt": 4 }, "Sideboard": { ... } }
}
]
/v1/tournaments/{tid}/players/{id}
Player details and full match record for one tournament. The id is the per-tournament player id from standings and round tables. Unlike standings rows, wins here EXCLUDE byes (byes are reported separately) — a bye is not a played match.
On team events this is the player's own board record: each entry is the board they played, never the team-level match (which is scored in boards won, not games).
{
"id": 1041,
"name": "Alice",
"standing": 1,
"points": 18,
"winRate": 0.8571,
"wins": 5, "losses": 1, "draws": 0, "byes": 1,
"gamesPlayed": 14, "gamesWon": 11, "gamesLost": 3, "gamesDrawn": 0,
"matches": [
{
"round": 1,
"phase": 1, // the phase sequence (groups colliding stage labels)
"table": 4,
"result": "win", // win | loss | draw | bye | pending
"gamesWon": 2, "gamesLost": 1, "gamesDrawn": 0,
"opponent": { "name": "Bob", "id": 1042 }
},
{
"round": 2,
"phase": 1,
"table": "Byes",
"result": "bye",
"gamesWon": 0, "gamesLost": 0, "gamesDrawn": 0,
"opponent": null
}
],
"archetype": "Burn",
"leader": null, // commander / leader card name(s) on leader-based games
"decklist": "// Mainboard\n4 Lightning Bolt\n...",
"deckObj": { ... }
}
On multiplayer (pod) tournaments a match carries an opponents array instead of opponent, and game counts reflect pod results (1 per pod).
/v1/tournaments/{tid}/rounds
Every round the organizer has published, in play order, each with its tables and results. Swiss rounds are numbered; elimination rounds — including EDH multiplayer top cuts — carry a stage label ("Quarterfinals", "Semifinals", "Finals", "Grand Final", ...). Each round also carries `phase` (its sequence in the pipeline), `phaseName`, and `stage` (the same human label), so you can group rounds by phase. Rounds whose pairings are not yet published are omitted.
| Field | Type | Description |
|---|---|---|
| round | number | string | Global round number (Swiss) or the stage label (elimination). |
| phase | number | Sequence of the phase this round belongs to. |
| phaseName | string | Name of that phase (e.g. "Swiss", "Top 8"). |
| stage | string | The human round label, always a string. |
| tables | array | The tables/matches of the round (fields below). |
| — table | number | "Byes" | Table number, or "Byes" for the grouped bye row. |
| — players | array | Players seated: { name, id, profileUrl } (+ teamTag on team events). |
| — winner | string | null | Winner's name (team name on a team match), or null for draws / unfinished. |
| — winner_id | number | "Draw" | null | Winner's id, "Draw" for ties, null for unfinished. |
| — winner_games | number | null | Games won by the winner. 1v1 matches only; null for pods and draws. |
| — loser_games | number | null | Games won by the loser. Null in the same cases. |
| — status | string | Completed, Active, Pending, or Bye. |
| — forfeit | boolean | Present and true when the result was an assigned loss (no-show / forfeit): the seated player lost without playing. Not a draw — winner_id stays null. |
| — bracket | string | Elimination bracket side: winners, losers, or grand_final (double elimination). |
| — bracketPosition | number | Bracket slot index, for reconstructing the tree positionally. |
| — pod | number | Draft only: the pod this table belongs to (also on a draft bye player). |
| — advanced | number | Multiplayer cut only: the registration id auto-advanced from a drawn pod (MTR draw-advancement). |
| — players[].result | string | null | Multiplayer only: each seat's own result (win / loss / draw / null). |
| — match | string | Team events: boards of one team pairing share this key. An elimination team match ALSO emits a team-level row (team names, team winner, bracket / bracketPosition) sharing the same key; a round-robin team match (no boards) is one team-vs-team row. |
| — teamLevel | boolean | Present and true on a team-vs-team row (its players are the teams, id = the captain). A per-player decklist selection never attaches a deck here — the captain's deck is not the team's; the per-member decks stay on the board rows. |
[
{
"round": 1,
"phase": 1,
"phaseName": "Swiss",
"stage": "Round 1",
"tables": [
{
"table": 1,
"players": [ { "name": "Alice", "id": 1041 }, { "name": "Bob", "id": 1042 } ],
"winner": "Alice",
"winner_id": 1041,
"winner_games": 2,
"loser_games": 1,
"status": "Completed"
},
{
"table": "Byes",
"players": [ { "name": "Chloe", "id": 1043 } ],
"status": "Bye"
}
]
},
{ "round": "Quarterfinals", "phase": 2, "phaseName": "Top 8", "stage": "Quarterfinals", "tables": [ ... ] }
]
Multiplayer (EDH / pod) tournaments list every pod as one table with its full seating; game scores are null and the winner is the pod winner. A multiplayer top cut labels its rounds Finals / Semifinals / Quarterfinals like a 1v1 bracket. Team events list one table per board (seat), tagged with the seat label — in the Swiss rounds and, since 2026-08-16, in the top cut as well. A team-trio round-robin phase still pairs team representatives with no boards and lists one team-vs-team row (the two team names as the sides).
/v1/tournaments/{tid}/rounds/latest
The tables of the latest published round only, as a flat array — no round wrapper. Same table shape as /rounds. The cheapest way to poll a live event.
[
{ "table": 1, "players": [ ... ], "winner": null, "winner_id": null, "winner_games": null, "loser_games": null, "status": "Active" },
{ "table": "Byes", "players": [ { "name": "Chloe", "id": 1043 } ], "status": "Bye" }
]
/v1/tournaments/{tid}/attendees
Staff
The full roster — registered players, dropped players and waitlist entries — with contact info. Requires that your account holds a staff role on the tournament (a stranger's key gets 403). Because it is your own event, it works on your private and draft tournaments too, and it is never cached.
| Field | Type | Description |
|---|---|---|
| uid | number | null | The player's account id. |
| name | string | Display name. |
| string | null | Email address (null for an anonymized account). | |
| discord / discordId | string | null | Discord username / id, when linked. |
| status | string | player, dropped, or waitlist. |
| standing | number | null | Standing position for a player, null for dropped / waitlist. |
| archetype / leader / decklist / deckObj | various | Deck data — same visibility as /standings (use the web decklist export for the full staff view mid-event). |
| joinedAt | number | null | Waitlist only: when the player joined (unix seconds). |
| waitlistStatus | string | Waitlist only: waiting, offered, accepted, declined, or expired. |
| waitlistPosition | number | null | Waitlist only: queue position (only while waiting). |
| offeredAt / expirationTimestamp | number | null | Waitlist only: when a spot was offered / when the offer expires. |
[
{ "uid": 88, "name": "Alice", "email": "[email protected]", "status": "player", "standing": 1 },
{ "uid": 91, "name": "Bob", "email": "[email protected]", "status": "dropped", "standing": null },
{ "uid": 93, "name": "Wendy", "email": "[email protected]", "status": "waitlist",
"waitlistStatus": "waiting", "waitlistPosition": 1, "joinedAt": 1752300000 }
]
/v1/me/tournaments
The tournaments your account manages. Defaults to those with a future start date; pass ?filter=all to include in-progress and past events. Each entry carries tid, name, game, format, startDate, endDate, status, location, headerImage, registeredCount, capacity and urls.
[
{
"tid": "weekly-modern-14",
"name": "Weekly Modern #14",
"game": "Magic: The Gathering",
"format": "Modern",
"startDate": 1752303600,
"endDate": null,
"status": "Not Started",
"location": { "name": "The Game Store", "city": "Lyon", "country": "France" },
"headerImage": "https://getpaird.io/storage/...",
"registeredCount": 18,
"capacity": 32
}
]
Decklist visibility
Card-level data and the archetype label follow two different rules.
decklist & deckObj
The card lists are filled only when:
- the tournament has ended and its lists were published (the platform default — organizers can opt out per event), or
- the organizer enabled "Show decks live" for that tournament, which exposes locked lists while the event is still running.
Only the locked, submitted tournament list is ever exposed — never a player's private library decks.
archetype
The archetype label becomes public once the tournament is completed — the same rule as the public standings, results and metagame pages, where it is already shown. It is therefore returned on a completed tournament even when the organizer opted out of publishing the card lists (the cards stay hidden). It is also returned mid-event when "Show decks live" is on, and hidden mid-event otherwise.
decklist string
// Mainboard 4 Lightning Bolt 4 Monastery Swiftspear // Sideboard 2 Smash to Smithereens
deckObj object
{
"Mainboard": {
"Lightning Bolt": 4,
"Monastery Swiftspear": 4
},
"Sideboard": {
"Smash to Smithereens": 2
}
}
Section names follow each game's zones (Mainboard, Sideboard, Command Zone, Extra, ...). The text form always mirrors deckObj.
Decklists feed (no key required)
Separate from the tournament API, GetPaird also exposes the public deck library as an unauthenticated read-only feed (60 requests per minute per IP). It mirrors the public /decks discovery page.
| Endpoint | Description |
|---|---|
| GET /v1/decks | Paginated public deck listing. Filters: game, format, archetype, hub, q (search), sort (recent | popular | trending), per_page (max 50). |
| GET /v1/decks/{slug} | Full public deck: cards, primer HTML, social counters, fork attribution. |
| GET /v1/hubs | Platform deck hubs sorted by popularity. Optional game filter. |
Note: this feed predates the tournament API and keeps its own response conventions (data / meta envelope, Laravel-style validation errors).
Webhooks
GetPaird can push signed tournament events to your server as they happen — pairings, results, registrations — so you do not have to poll. They are for software that reacts to live tournaments: stream overlays, venue displays, coverage tools, standings boards. Free, like the API.
Manage my webhook endpointsSetup & access
Register an HTTPS endpoint on your account webhooks page and pick which event types you want (or all of them). Each endpoint gets a whsec_… signing secret, shown once. Your endpoint then receives events for every tournament where your account holds a staff role — so if you organize your own events you are already set, and to build tools for another organizer, have them add your account to their event staff.
Endpoint and staff-role changes take effect within about a minute. Every payload carries the tid, so you can also route or ignore tournaments in your own code.
The event envelope
Every delivery is a POST with the same top-level shape. Write one handler for the envelope, then switch on type. Object shapes inside data match the REST API. Decklists are never included in webhook payloads — fetch the REST API when you need them.
| Field | Type | Description |
|---|---|---|
| id | string | Unique event id (evt_…). Duplicate deliveries reuse the same id — deduplicate on it. |
| type | string | The event type, e.g. round.published. |
| created | integer | When the event occurred (unix milliseconds). |
| apiVersion | string | Payload schema version. Currently 2026-08. |
| tid | string | null | Tournament slug. null for ping. |
| tournament | object | null | Summary: name, game, format. null for ping. |
| data | object | Event-specific payload (see Event types). |
Request headers
X-GetPaird-Event: round.published # the event type, for routing before parsing X-GetPaird-Event-Id: evt_57c9cdee7de1… # same as the payload id X-GetPaird-Signature: t=1751500800000,v1=5f8a… # see "Verifying signatures" User-Agent: GetPaird-Webhooks/1.0
{
"id": "evt_57c9cdee7de1de4f4a0dc332494846be",
"type": "round.published",
"created": 1751500800000,
"apiVersion": "2026-08",
"tid": "weekly-cedh-14",
"tournament": { "name": "Weekly cEDH", "game": "Magic: The Gathering", "format": "Commander Multiplayer" },
"data": { "...": "event-specific, see below" }
}
Event types
| Type | data |
|---|---|
| round.published | stage, round, roundLabel, tables[] (same shape as the REST rounds endpoint). Fires when pairings are posted or re-published. |
| round.started | stage, round, roundLabel, startedAt (ms), roundTimeMinutes. Fires when the round timer starts. |
| round.ended | stage, round, roundLabel, standings[] (full field, no decklists). Fires when the organizer ends the round. |
| match.result_reported | stage, round, result (winner / reset), table (a snapshot of the affected table). Highest-volume event. Refetch the round for exact state under rapid corrections. |
| player.registered | player { id, name }, registeredAt (ms). Self-register, organizer add, waitlist promotion or CSV import. |
| player.dropped | player { id, name }, droppedInRound. Dropped by themselves or by staff. |
| tournament.finished | endedAt (ms), participantCount, winner { id, name }, standings[] (full final standings, no decklists). |
| ping | message. A test event from your webhooks page. tid and tournament are null. |
player id and standings ids are per-tournament registration ids — the same ids the REST API uses. Byes appear as a final table with table: "Byes".
Verifying signatures
Every delivery is signed with your endpoint's whsec_… secret. The X-GetPaird-Signature header is t=<ms>,v1=<hex>. v1 is an HMAC-SHA256, hex encoded, computed over the timestamp and the RAW request body joined with a period: `${t}.${rawBody}`. Compute the same and compare with a constant-time check. Verify over the exact bytes received — do not re-serialize parsed JSON — so enable raw-body capture on your webhook route.
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
if (!parts.v1) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(parts.v1), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Delivery & retries
Respond with any 2xx within 10 seconds; anything else (including timeouts and redirects) is a failed attempt. Acknowledge fast, then process asynchronously. Failed deliveries are retried with exponential backoff (starting ~30s, capping ~10 min). Delivery is at-least-once — deduplicate on id — and order is not guaranteed, so use created to order events. After 50 consecutive failures an endpoint is auto-disabled and you are emailed; fix your receiver and re-enable it from your webhooks page. URLs must be HTTPS on a publicly resolvable host; redirects are not followed. The delivery log keeps the last attempts for about 30 days.
Use "Send test" on your webhooks page to fire a signed ping and verify your handler end to end — delivered even while an endpoint is paused.
Error codes
The tournament API uses standard HTTP status codes. Every error returns a JSON body with a single error field describing what went wrong.
| Code | Meaning | When |
|---|---|---|
| 400 | Bad Request | Missing or invalid parameters (e.g. query mode without game + format, unknown columns value, a wrong-typed TID). |
| 401 | Unauthorized | Invalid or missing API key. |
| 403 | Forbidden | A valid key used on an endpoint outside the public API (developer keys only reach /api/v1/tournaments). |
| 404 | Not Found | Tournament (or player) does not exist or is not public. |
| 429 | Rate Limited | Too many requests per minute. Also carries a Retry-After header. |
| 503 | Unavailable | The API is temporarily switched off (maintenance / incident). |
// 400
{ "error": "Both \"game\" and \"format\" fields are required." }
// 401
{ "error": "Invalid API key" }
// 404
{ "error": "Tournament not found" }
// 429 (+ Retry-After header)
{ "error": "Rate limit exceeded", "retryAfterSeconds": 42 }
Questions, higher rate limits, or an integration to show off? Write to [email protected].