TopDeck.gg Webhooks

TopDeck.gg can push signed tournament events (pairings, results, registrations) to your server as they happen. This page covers setup, payloads, signatures, and delivery behavior.

Version 2026-07

Overview

Webhooks push tournament events to your server as they happen, so you don't have to poll. When pairings go up, a result comes in, or a tournament finishes, TopDeck.gg sends a signed POST request to each endpoint you've registered.

They're for software that reacts to live tournaments: stream overlays, venue displays, coverage tools, standings boards.

How it fits with the REST API

Payloads carry a snapshot of whatever the event is about: pairings for a published round, the table and score for a reported result, standings when a round ends. The object shapes match the REST API. When you need guaranteed-current state, or data we leave out of payloads (decklists), fetch the REST API.

Free
Webhooks are free to use, same as the API. Projects consuming TopDeck.gg data must include a visible credit and link back to TopDeck.gg.
What a delivery looks like
POST https://example.com/webhooks/topdeck
X-TopDeck-Event: round.published
X-TopDeck-Event-Id: evt_57c9cdee7de1de4f4a0dc332494846be
X-TopDeck-Signature: t=1751500800000,v1=5f8a…

{
  "id": "evt_57c9cdee7de1de4f4a0dc332494846be",
  "type": "round.published",
  "created": 1751500800000,
  "apiVersion": "2026-07",
  "tid": "H8kR2mN4pQ",
  "tournament": {
    "name": "Weekly cEDH",
    "game": "Magic: The Gathering",
    "format": "EDH"
  },
  "data": { "...": "event-specific, see Event Types" }
}

Quick Start

1. Create an endpoint. Go to the developer portal, add your HTTPS URL, and pick which event types you want (or all of them).

2. Save your signing secret. Every endpoint gets a whsec_… secret. Use it to verify that deliveries really came from TopDeck.gg (see Verifying Signatures).

3. Get staffed. Your endpoint receives events for every tournament where your TopDeck account holds a staff role. If you organize your own events, you're already set.

4. Send a test event. Use Send Test on the portal to fire a ping at your endpoint and confirm your handler responds with a 2xx.

5. Go live. Publish a round on a staffed tournament and watch the events arrive. The portal's delivery log shows every attempt, response code, and timing.

Minimal receiver (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();

// Keep the raw body — signatures are
// computed over the exact bytes sent
app.post('/webhooks/topdeck',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-topdeck-signature'];
    if (!verify(req.body, sig, process.env.TOPDECK_WEBHOOK_SECRET)) {
      return res.status(400).send('bad signature');
    }

    // Ack fast, process async
    res.sendStatus(200);

    const event = JSON.parse(req.body);
    switch (event.type) {
      case 'round.published':
        updateOverlay(event.tid, event.data.tables);
        break;
      case 'match.result_reported':
        updateTable(event.tid, event.data.table);
        break;
    }
  });

Event Routing & Access

There's no per-tournament configuration. You register an endpoint once, and it receives events for every tournament where you hold a staff role (owner, admin, judge, or viewer).

Building tools for other organizers?

Have the organizer add your TopDeck account to their event staff (a viewer role is enough). From then on, their tournament's events reach your endpoints. If they remove the staff role, the events stop. There's nothing else to revoke or reconfigure.

Filtering

Each endpoint can subscribe to specific event types (or all of them). Every payload carries the tid, so you can also route or ignore tournaments in your own code.

Note
Endpoint and staff-role changes can take up to a minute to apply to event routing.
Access model
# Your endpoint receives events for a tournament when:
#
#   endpoint.owner  ∈  tournament staff
#                      (owner / admin / judge / viewer)
#
# and the event type matches your subscription.
#
# Third-party tools: get added to event staff.
# Organizers: your own events just work.

The Event Envelope

Every delivery is a JSON object with the same top-level shape, regardless of type. Write one handler for the envelope, then switch on type.

FieldTypeDescription
idstringUnique event ID (evt_…). Duplicate deliveries of an event reuse the same ID, so deduplicate on this field.
typestringThe event type, e.g. round.published.
createdintegerWhen the event occurred (unix milliseconds).
apiVersionstringPayload schema version. Currently 2026-07.
tidstringTournament ID. Use it with the REST API.
tournamentobjectSummary: name, game, format. null for ping events.
dataobjectEvent-specific payload; see Event Types.

Request headers

HeaderDescription
X-TopDeck-EventThe event type, for routing before parsing the body.
X-TopDeck-Event-IdSame as the payload id.
X-TopDeck-Signaturet=<ms>,v1=<hex hmac>; see Verifying Signatures.
User-AgentTopDeck-Webhooks/1.0
Envelope
{
  "id": "evt_57c9cdee7de1de4f4a0dc332494846be",
  "type": "round.published",
  "created": 1751500800000,
  "apiVersion": "2026-07",
  "tid": "H8kR2mN4pQ",
  "tournament": {
    "name": "Weekly cEDH",
    "game": "Magic: The Gathering",
    "format": "EDH"
  },
  "data": { }
}
EVENTtournament.checkin_started

Fires when the organizer opens check-in for a stage.

data fieldTypeDescription
stageintegerThe stage number check-in was started for.
Example data
{
  "stage": 1
}
EVENTround.published

Pairings for a round were posted. The payload includes every table for the round, in the same shape as the REST rounds endpoints. Re-publishing a round after re-pairing fires a new event; re-publishing identical pairings does not.

data fieldTypeDescription
stageintegerStage number.
roundintegerRound number within the stage.
roundLabelinteger | stringDisplay label: the round number for swiss, or "Top 4"-style strings for bracket rounds.
tablesarrayOne entry per table, same shape as the REST rounds endpoints: table (number), players (name, id, plus teamTag for team events), winner, winner_id, winner_games, loser_games, status. Byes appear as a final entry with table: "Byes".
Note
Player id is the user's TopDeck ID for registered players, or a generated ID for organizer-added players without an account. Events using Discord identifiers also include discord and discordId on each player. Decklists are never included in webhook payloads; fetch the REST API if you need them.
Example data
{
  "stage": 1,
  "round": 3,
  "roundLabel": 3,
  "tables": [
    {
      "table": 1,
      "players": [
        { "name": "Alice", "id": "u_alice" },
        { "name": "Bob", "id": "u_bob" },
        { "name": "Carol", "id": "u_carol" },
        { "name": "Dave", "id": "u_dave" }
      ],
      "winner": null,
      "winner_id": null,
      "winner_games": null,
      "loser_games": null,
      "status": "Active"
    },
    {
      "table": "Byes",
      "players": [ { "name": "Erin", "id": "u_erin" } ],
      "status": "Bye"
    }
  ]
}
EVENTround.started

The round timer started. Useful for round clocks and "time remaining" displays.

data fieldTypeDescription
stageintegerStage number.
roundintegerRound number.
roundLabelinteger | stringDisplay label for the round.
startedAtinteger | nullRound start time (unix milliseconds).
roundTimeMinutesinteger | nullConfigured round length in minutes, if the event uses a round timer.
Example data
{
  "stage": 1,
  "round": 3,
  "roundLabel": 3,
  "startedAt": 1751501100000,
  "roundTimeMinutes": 50
}
EVENTround.ended

The organizer ended a round. Results for the round are final at this point (barring edits), so the payload includes current standings for the whole field, in the same shape as the REST standings endpoint but without decklists.

data fieldTypeDescription
stageintegerStage number.
roundintegerRound number.
roundLabelinteger | stringDisplay label for the round.
standingsarrayAll players in standings order: standing, name, id, points, and win rates. Team events also carry a per-seat players roster. No decklists; fetch the REST API if you need them.
Example data
{
  "stage": 1,
  "round": 3,
  "roundLabel": 3,
  "standings": [
    {
      "name": "Alice",
      "id": "u_alice",
      "standing": 1,
      "points": 9,
      "winRate": 0.75,
      "opponentWinRate": 0.61
    },
    {
      "name": "Bob",
      "id": "u_bob",
      "standing": 2,
      "points": 7,
      "winRate": 0.66,
      "opponentWinRate": 0.58
    }
  ]
}
EVENTmatch.result_reported

A table's result was reported, corrected, or cleared. This is the highest-volume event type: a 300-player event fires one per table, per round. The payload includes a snapshot of the affected table.

data fieldTypeDescription
stageintegerStage number.
roundinteger | stringRound number (bracket auto-rounds may report "active").
resultstringWhat happened: winner (a winner or points were set), wld (best-of win/loss/draw counts were set), loss / unloss (a pod player was marked or unmarked as eliminated), or reset (the result was cleared).
tableobject | nullSnapshot of the table after the report, same shape as the REST rounds endpoints: table, players, winner, winner_id, winner_games, loser_games, status. A winner_id of "Draw" means the match was drawn.
table.winner_gamesnumber | nullGames won by the match winner. Only for 1v1 (Pairs) matches; null for multiplayer pods, score-based formats, and draws or unfinished matches.
table.loser_gamesnumber | nullGames won by the loser. null in the same cases as winner_games.
Corrections
Results can be re-reported and judge-corrected. Each correction fires a new event (new id) for the same table. The table snapshot reflects current state when the payload is built, so under rapid corrections an earlier event can carry the later snapshot. Treat the latest created as authoritative, or refetch the round from the REST API.
Example data
{
  "stage": 1,
  "round": 3,
  "result": "wld",
  "table": {
    "table": 5,
    "players": [
      { "name": "Alice", "id": "u_alice" },
      { "name": "Bob", "id": "u_bob" }
    ],
    "winner": "Alice",
    "winner_id": "u_alice",
    "winner_games": 2,
    "loser_games": 1,
    "status": "Completed"
  }
}
EVENTplayer.registered

A player registered for the tournament, whether by self-registration, organizer add, waitlist promotion, or CSV import. Bulk imports fire one event per player.

data fieldTypeDescription
playerobjectid and name of the registered player.
registeredAtintegerRegistration time (unix milliseconds).
Example data
{
  "player": {
    "id": "u_carol",
    "name": "Carol"
  },
  "registeredAt": 1751499000000
}
EVENTplayer.dropped

A player was dropped from the tournament (by themselves or by staff).

data fieldTypeDescription
playerobjectid and name of the dropped player.
droppedInRoundintegerThe round after which the drop takes effect.
Example data
{
  "player": {
    "id": "u_bob",
    "name": "Bob"
  },
  "droppedInRound": 3
}
EVENTtournament.finished

The tournament was finalized. The payload includes the winner and full final standings, without decklists (fetch the REST API if you need those).

data fieldTypeDescription
endedAtinteger | nullWhen the tournament was finalized (unix milliseconds).
participantCountinteger | nullNumber of participants.
winnerobject | nullid and name of the first-place finisher.
standingsarrayFull final standings, same shape as round.ended standings and the REST standings endpoint. Decklists excluded.
Example data
{
  "endedAt": 1751520000000,
  "participantCount": 64,
  "winner": { "id": "u_alice", "name": "Alice" },
  "standings": [
    {
      "name": "Alice",
      "id": "u_alice",
      "standing": 1,
      "points": 21,
      "winRate": 0.85,
      "opponentWinRate": 0.64
    },
    { "...": "all participants, in standings order" }
  ]
}
Need decklists?
curl https://topdeck.gg/api/v2/tournaments/{tid}/standings \
  -H "Authorization: YOUR_API_KEY"
EVENTping

A test event, sent when you click Send Test on the developer portal. Signed like any other event, so you can use it to verify your signature handling end to end. tid and tournament are null.

Example data
{
  "message": "Test event from the TopDeck.gg developer portal"
}

Verifying Signatures

Every delivery is signed with your endpoint's whsec_… secret so you can confirm it came from TopDeck.gg and wasn't tampered with. The X-TopDeck-Signature header looks like:

t=1751500800000,v1=5f8a2c…

v1 is an HMAC-SHA256, hex encoded, computed over the timestamp and the raw request body joined with a period: `${t}.${rawBody}`.

Verification steps

1. Split the header on , and parse t and v1.

2. Compute HMAC-SHA256(secret, `${t}.${rawBody}`) over the raw request bytes. Don't re-serialize parsed JSON.

3. Compare against v1 with a constant-time comparison.

4. Optionally reject events where t is more than 5 minutes old to guard against replays.

Important
Signatures are computed over the exact bytes sent. If your framework parses JSON before you can read the body, enable raw-body capture for the webhook route (e.g. express.raw(), or request.get_data() in Flask).
Node.js
const crypto = require('crypto');

function verifyTopDeckSignature(rawBody, header, secret) {
  if (!header) return false;
  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');
  // Equal-length check first: timingSafeEqual throws on a length mismatch
  const a = Buffer.from(parts.v1);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python
import hmac, hashlib

def verify_topdeck_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    if "t" not in parts or "v1" not in parts:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{parts['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)

Delivery & Retries

Responding

Respond with any 2xx status within 10 seconds. Anything else, including timeouts and redirects, counts as a failed attempt. Acknowledge right away, then process the event asynchronously.

Retries

Failed deliveries are retried up to 8 times with exponential backoff, starting around 30 seconds and capping at 10 minutes between attempts. Every attempt shows up in the delivery log on the developer portal.

Semantics

Delivery is at-least-once: the same event can occasionally arrive twice, so deduplicate on id. Ordering isn't guaranteed either; use created to order events. Payloads are snapshots taken shortly after the event, so fetch the REST API when exact current state matters.

Auto-disable

After 50 consecutive failed deliveries, an endpoint is disabled automatically and you'll get an email. Fix the receiver and re-enable it from the portal. The failure counter resets on re-enable and on any successful delivery.

Endpoint requirements

URLs must be HTTPS on a publicly resolvable host; private and internal addresses are rejected. Redirects aren't followed. Events and delivery logs are kept for about 30 days.

Retry schedule (approximate)
attempt 1   immediately
attempt 2   +30s
attempt 3   +1m
attempt 4   +2m
attempt 5   +4m
attempt 6   +8m
attempt 7   +10m
attempt 8   +10m
then        dropped (visible in delivery log)
Idempotent handling
const seen = new Set(); // use a real store

function handleEvent(event) {
  if (seen.has(event.id)) return; // duplicate
  seen.add(event.id);
  process(event);
}

Managing Endpoints

Everything lives on the developer portal. You can run up to 5 endpoints per account (say, staging and production), each with its own secret and event-type subscription.

From there you can send a signed ping and see the response code and latency immediately, check the last 50 delivery attempts per endpoint (event, status code, duration, error), and pause an endpoint to stop deliveries without deleting it. Test pings are delivered even while an endpoint is paused, so you can verify a fix before re-enabling.

Keep secrets secret
Treat whsec_… values like passwords. Secrets are shown in full only when an endpoint is created or its secret is rotated. If a secret leaks (or is lost), rotate it from the portal — the old secret stops working immediately.
Manage
https://topdeck.gg/developers
Open Developer Portal
Top