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.
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.
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.
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" }
}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.
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;
}
});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).
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.
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.
# 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.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.
| Field | Type | Description |
|---|---|---|
| id | string | Unique event ID (evt_…). Duplicate deliveries of an event reuse the same ID, so deduplicate on this field. |
| type | string | The event type, e.g. round.published. |
| created | integer | When the event occurred (unix milliseconds). |
| apiVersion | string | Payload schema version. Currently 2026-07. |
| tid | string | Tournament ID. Use it with the REST API. |
| tournament | object | Summary: name, game, format. null for ping events. |
| data | object | Event-specific payload; see Event Types. |
| Header | Description |
|---|---|
| X-TopDeck-Event | The event type, for routing before parsing the body. |
| X-TopDeck-Event-Id | Same as the payload id. |
| X-TopDeck-Signature | t=<ms>,v1=<hex hmac>; see Verifying Signatures. |
| User-Agent | TopDeck-Webhooks/1.0 |
{
"id": "evt_57c9cdee7de1de4f4a0dc332494846be",
"type": "round.published",
"created": 1751500800000,
"apiVersion": "2026-07",
"tid": "H8kR2mN4pQ",
"tournament": {
"name": "Weekly cEDH",
"game": "Magic: The Gathering",
"format": "EDH"
},
"data": { }
}Fires when the organizer opens check-in for a stage.
| data field | Type | Description |
|---|---|---|
| stage | integer | The stage number check-in was started for. |
{
"stage": 1
}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 field | Type | Description |
|---|---|---|
| stage | integer | Stage number. |
| round | integer | Round number within the stage. |
| roundLabel | integer | string | Display label: the round number for swiss, or "Top 4"-style strings for bracket rounds. |
| tables | array | One 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". |
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.{
"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"
}
]
}The round timer started. Useful for round clocks and "time remaining" displays.
| data field | Type | Description |
|---|---|---|
| stage | integer | Stage number. |
| round | integer | Round number. |
| roundLabel | integer | string | Display label for the round. |
| startedAt | integer | null | Round start time (unix milliseconds). |
| roundTimeMinutes | integer | null | Configured round length in minutes, if the event uses a round timer. |
{
"stage": 1,
"round": 3,
"roundLabel": 3,
"startedAt": 1751501100000,
"roundTimeMinutes": 50
}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 field | Type | Description |
|---|---|---|
| stage | integer | Stage number. |
| round | integer | Round number. |
| roundLabel | integer | string | Display label for the round. |
| standings | array | All 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. |
{
"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
}
]
}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 field | Type | Description |
|---|---|---|
| stage | integer | Stage number. |
| round | integer | string | Round number (bracket auto-rounds may report "active"). |
| result | string | What 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). |
| table | object | null | Snapshot 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_games | number | null | Games won by the match winner. Only for 1v1 (Pairs) matches; null for multiplayer pods, score-based formats, and draws or unfinished matches. |
| table.loser_games | number | null | Games won by the loser. null in the same cases as winner_games. |
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.{
"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"
}
}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 field | Type | Description |
|---|---|---|
| player | object | id and name of the registered player. |
| registeredAt | integer | Registration time (unix milliseconds). |
{
"player": {
"id": "u_carol",
"name": "Carol"
},
"registeredAt": 1751499000000
}A player was dropped from the tournament (by themselves or by staff).
| data field | Type | Description |
|---|---|---|
| player | object | id and name of the dropped player. |
| droppedInRound | integer | The round after which the drop takes effect. |
{
"player": {
"id": "u_bob",
"name": "Bob"
},
"droppedInRound": 3
}The tournament was finalized. The payload includes the winner and full final standings, without decklists (fetch the REST API if you need those).
| data field | Type | Description |
|---|---|---|
| endedAt | integer | null | When the tournament was finalized (unix milliseconds). |
| participantCount | integer | null | Number of participants. |
| winner | object | null | id and name of the first-place finisher. |
| standings | array | Full final standings, same shape as round.ended standings and the REST standings endpoint. Decklists excluded. |
{
"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" }
]
}curl https://topdeck.gg/api/v2/tournaments/{tid}/standings \
-H "Authorization: YOUR_API_KEY"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.
{
"message": "Test event from the TopDeck.gg developer portal"
}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:
v1 is an HMAC-SHA256, hex encoded, computed over the timestamp and the raw request body joined with a period: `${t}.${rawBody}`.
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.
express.raw(), or request.get_data() in Flask).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);
}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)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.
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.
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.
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.
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.
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)const seen = new Set(); // use a real store
function handleEvent(event) {
if (seen.has(event.id)) return; // duplicate
seen.add(event.id);
process(event);
}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.
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.