GetPaird

GetPaird.io

Free · Tournaments on the go

Install
Skip to content
Tournament Organizers: Plus is free until October 1st! Learn more →
Developers · API v1

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 keys

Send 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 seeWhat 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".

GET /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"
GET /v1/tournaments/{tid}/info

Tournament metadata, schedule and location.

FieldTypeDescription
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
GET /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.

FieldTypeDescription
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": { ... } }
  }
]
GET /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).

GET /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.

FieldTypeDescription
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).

GET /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" }
]
GET /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.

FieldTypeDescription
uid number | null The player's account id.
name string Display name.
email 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 }
]
GET /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.

EndpointDescription
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 endpoints

Setup & 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.

FieldTypeDescription
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

Typedata
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.

CodeMeaningWhen
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].

We use cookies to analyze traffic and improve your experience. Cookie Policy