API reference

There is no private admin API. Every screen in Gauntlet calls the endpoints below, so anything the site can do, your bot can do. All request and response bodies are JSON, all timestamps are ISO 8601 in UTC, and every path below is relative to https://gauntletbrackets.com.

On this page

TypeScript client

Optional. Everything on this page is plain HTTP and works from any language, but if you are writing TypeScript there is a published wrapper with types for every object, typed errors, and an async iterator over the live stream.

npm install @team-gauntlet/client
TypeScript
import { GauntletClient } from "@team-gauntlet/client";

const client = new GauntletClient({
  baseUrl: "https://gauntletbrackets.com",
  token: process.env.GAUNTLET_API_KEY, // gt_live_...
});

const tournament = await client.createTournament({
  name: "Spring Invitational",
  format: "double_elim",
});

await client.addParticipants(tournament.id, ["Team Vortex", "Team Halcyon", "Team Meridian"]);
await client.generateBracket(tournament.id); // pending to ready, roster locked
await client.open(tournament.id);            // ready to underway, results accepted

for await (const event of client.watch(tournament.id)) {
  if (event.event !== "bracket.updated") continue;

  // Frames say that something changed, never what it changed to, so refetch.
  const { matches } = await client.getBracket(tournament.id);
  const decided = matches.filter((match) => match.winnerId !== null);
  console.log(`${decided.length} of ${matches.length} decided`);
}

It has no runtime dependencies and calls exactly the endpoints below, so nothing it does is unavailable to curl. Failures throw a GauntletError carrying the same status, code and details documented in Errors. Key management is deliberately absent from it, because those endpoints accept an interactive session only.

Authentication

Send an API key as a bearer token. A key inherits its owner's tournaments and nothing else: it cannot touch a bracket its owner does not administer, and it cannot mint further keys. Browser sessions authenticate with a cookie instead, and participants with the cookie they get by opening their magic link.

curl https://gauntletbrackets.com/api/v1/tournaments \
  -H "Authorization: Bearer gt_live_..."
CredentialHow it arrivesWhat it can do
API keyAuthorization: Bearer gt_live_...Everything its owner can, minus minting keys
SessionhttpOnly cookie from Discord sign inEverything, including keys
ParticipanthttpOnly cookie from /p/<token>Submit results for their own matches
NoneNo header, no cookieRead public tournaments and search

Mint and revoke keys from your settings. Cookie-authenticated writes additionally require a same-origin request, so a third party page cannot make your browser act as you. Bearer-token callers are exempt, because a bot has no ambient cookie to abuse.

Idempotency

Send Idempotency-Key on any POST. A retry with the same key replays the original response instead of acting twice, so a dropped connection cannot advance a bracket twice. Reusing a key with a different body is rejected with 409, and a replayed response carries Idempotency-Replayed: true.

Concurrency

Include expectedVersion when reporting a result. If another organiser got there first you get 409 with the current version in details, rather than silently overwriting them. Refetch, decide, retry.

Errors

Every failure has the same shape, so one handler covers all of them.

{
  "error": {
    "code": "conflict",
    "message": "Match was updated by someone else",
    "details": { "currentVersion": 2 }
  }
}
StatusCodeMeaning
400bad_requestMalformed JSON, or a field that failed validation. details carries the issues.
401unauthorizedNo credential, or one that has been revoked or expired.
403forbiddenAuthenticated, but not allowed to do this.
404not_foundMissing, or hidden from this caller. The two are indistinguishable by design.
409conflictVersion mismatch, reused idempotency key, or a wrong lifecycle state.
422unprocessableValid JSON that the domain refuses, such as a roster over the cap.
422invalid_bracketThe format engine rejected the request, such as too few entrants.
429rate_limitedOver the limit. Retry-After says how long to wait.
500internal_errorA bug on this end. Nothing further is disclosed.

Rate limits

Counted per credential, or per address for anonymous callers. Going over returns 429 with a Retry-After header.

BucketLimitApplies to
Reads300 / minuteEvery GET, including embeds.
Writes60 / minuteResult reports and other mutations.
Creates20 / minuteTournaments, participants, webhooks, starting a bracket.
Credentials10 / minuteMinting keys and redeeming magic links.
Streams60 / minuteOpening an SSE connection.

Objects

Responses are built from explicit allow-lists, so a new database column stays private until someone publishes it deliberately.

Tournament

Returned wherever a tournament appears. Secrets and owner ids are never included.

FieldTypeDescription
idstring (uuid)Stable identifier.
slugstringURL segment, with an unguessable suffix.
namestringDisplay name, up to 120 characters.
gamestring | nullGame or discipline.
descriptionstring | nullFree text, up to 2000 characters.
formatsingle_elim | double_elim | round_robin | swissFixed once the tournament is created.
configobjectFormat options. See Format config.
statepending | ready | underway | complete | cancelledLifecycle. Participants can only be added while pending. ready means the bracket exists but play has not been opened; results are refused until underway.
visibilitypublic | unlisted | privateWho may read it.
registrationclosed | open | inviteWho may enter a team. Orthogonal to visibility, which governs reading: a tournament can be publicly watchable and closed to entries. The join code itself is never returned by the API; organisers read it from the manage console.
requireApprovalbooleanWhether signups wait in a queue instead of taking a seat immediately.
participantCountintegerEntrants currently registered.
archivedAtstring (ISO 8601) | nullSet when archived: closed to writes, still readable, hidden from public search. Independent of state.
embedUrlstring | nullIframe source for this bracket. Null for private tournaments.
imageUrlstring | nullThe bracket as a PNG, for anywhere that unfurls an image but will not run an iframe. Rendered dark; add ?theme=light for the light palette. Null for private tournaments. Every webhook carries a versioned form of this URL; see Webhooks.
startedAtstring (ISO 8601) | nullWhen play was opened.
completedAtstring (ISO 8601) | nullWhen the final was decided.
createdAtstring (ISO 8601)Creation time.

Match

A node in the bracket graph. Advancement is a pointer walk: the winner is written into winnerToMatchId at winnerToSlot, and the loser into loserToMatchId, which is why every format advances identically.

FieldTypeDescription
idstring (uuid)Stable identifier.
keystringGenerator-stable key such as "W2-1" or "GF".
sidewinners | losers | grand_final | group | swissWhich part of the bracket the match belongs to.
roundinteger1-based round within that side.
slotintegerPosition within the round, top to bottom.
labelstringHuman label, for example "Winners round 2".
groupKeystring | nullGroup identifier for round robin pools.
p1Idstring (uuid) | nullParticipant in slot 1, null until filled.
p2Idstring (uuid) | nullParticipant in slot 2, null until filled.
winnerIdstring (uuid) | nullDecided winner.
statepending | ready | underway | bye | void | complete | disputedpending means a slot is still waiting on an upstream match. ready means both slots are filled and nobody has started. underway means an organiser marked it as being played. disputed means the two reports disagreed.
walkoverbooleanTrue when the match resolved without being played.
winnerToMatchIdstring (uuid) | nullWhere the winner advances.
winnerToSlot1 | 2 | nullWhich slot the winner fills.
loserToMatchIdstring (uuid) | nullWhere the loser drops, double elimination.
loserToSlot1 | 2 | nullWhich slot the loser fills.
versionintegerOptimistic lock. Pass it back as expectedVersion when reporting a result.
completedAtstring (ISO 8601) | nullWhen the result was recorded.

Participant

An entrant. The magic-link token appears once, in the creation response, and never again.

FieldTypeDescription
idstring (uuid)Stable identifier.
namestringDisplay name, up to 80 characters.
seedinteger1-based seed, assigned in insertion order.
checkedInbooleanCheck-in flag. Listing only.
accessTokenstringMagic-link token. Returned to the tournament's owner whenever the roster is read, so a team's link can always be looked up and sent again. Omitted for every other caller.

Standings row

Included in the bracket response for round robin and Swiss.

FieldTypeDescription
participantstring (uuid)Participant id.
rankinteger1-based, ties share a rank.
playedintegerDecided matches.
winsintegerMatches won.
lossesintegerMatches lost.
scoreForintegerPoints scored.
scoreAgainstintegerPoints conceded.
tiebreaknumberMedian-Buchholz for Swiss, head to head otherwise.

Format config

The config object on a tournament. Every field is optional and each format ignores what does not apply to it.

FieldTypeDescription
seedMethodstandard | random | as_enteredstandard folds seeds 1 vs n. Defaults to standard.
thirdPlaceMatchbooleanSingle elimination only.
grandFinalResetbooleanDouble elimination. Adds the reset match.
groupCountinteger (1 to 32)Round robin pools, snake seeded.
roundsinteger (1 to 32)Swiss rounds. Defaults to ceil(log2(n)).
randomSeedintegerMakes random seeding reproducible.

Tournaments

Create brackets, search public ones, and edit what has not been generated yet.

GET/api/v1/tournaments

List or search tournaments

With the default scope=mine this returns the tournaments the credential owns, at every visibility. With scope=public it searches published tournaments and needs no credential, which is exactly what the browse page calls.

Auth: Session or API key for scope=mine, none for scope=public

Query parameters

FieldTypeDescription
scopemine | publicDefaults to mine.
qstringCase-insensitive substring of the name or game. Up to 80 characters.
gamestringCase-insensitive substring of the game.
formatsingle_elim | double_elim | round_robin | swissExact format.
statepending | ready | underway | complete | cancelledExact lifecycle state.
archivedexclude | include | onlyDefaults to exclude. Honoured only for scope=mine; public search never returns archived tournaments.
sortrecent | largestNewest first, or most entrants first. Defaults to recent.
limitinteger (1 to 50)Page size. Defaults to 24.
offsetinteger (0 to 10000)Rows to skip. Defaults to 0.

With the client

// Every public bracket for one game, biggest field first.
const { tournaments, total } = await client.listTournaments({
  scope: "public",
  game: "Rocket League",
  sort: "largest",
  limit: 10,
});
console.log(`${total} found`, tournaments.map((t) => t.name));

Response · 200 · Matching tournaments plus the total, so a caller can page without guessing.

{
  "tournaments": [
    {
      "id": "0f1c...",
      "slug": "spring-invitational-Rk2p8Q",
      "name": "Spring Invitational",
      "game": "Rocket League",
      "format": "double_elim",
      "state": "underway",
      "visibility": "public",
      "participantCount": 16,
      "startedAt": "2026-08-11T18:00:00.000Z",
      "createdAt": "2026-08-10T09:12:41.000Z"
    }
  ],
  "total": 37,
  "limit": 24,
  "offset": 0
}

Errors

StatusCodeWhen
401unauthorizedscope=mine without a credential.
POST/api/v1/tournaments

Create a tournament

Creates a draft. No bracket exists yet, so format and config can still be chosen freely. The slug is derived from the name with a random suffix appended.

Auth: Session or API key with write scope

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Body

FieldTypeDescription
name *string (1 to 120)Display name.
gamestring (1 to 80)Game or discipline.
descriptionstring (up to 2000)Free text shown on the public page.
format *single_elim | double_elim | round_robin | swissCannot be changed once set.
visibilitypublic | unlisted | privateDefaults to public.
registrationclosed | open | inviteWho may enter a team. Defaults to closed, which is how tournaments behaved before signups existed. invite mints a join code, readable from the manage console.
requireApprovalbooleanQueue signups for review instead of seating them. Defaults to false.
configobjectFormat config. Defaults to {}.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2026-08-11-spring" \
  -d '{
    "name": "Spring Invitational",
    "game": "Rocket League",
    "format": "double_elim",
    "visibility": "public",
    "config": { "seedMethod": "standard", "grandFinalReset": true }
  }'

With the client

const tournament = await client.createTournament({
  name: "Spring Invitational",
  game: "Rocket League",
  format: "double_elim",
  visibility: "public",
  config: { seedMethod: "standard", grandFinalReset: true },
});

// The slug is derived from the name with a random suffix, so read it back
// rather than guessing it.
console.log(`Entries open at /t/${tournament.slug}`);

Response · 201 · The created tournament.

{
  "tournament": {
    "id": "0f1c...",
    "slug": "spring-invitational-Rk2p8Q",
    "name": "Spring Invitational",
    "format": "double_elim",
    "state": "pending",
    "visibility": "public",
    "participantCount": 0
  }
}

Errors

StatusCodeWhen
400bad_requestA field is missing, too long, or unrecognised.
401unauthorizedNo credential, or a read-only API key.
409conflictThe Idempotency-Key was reused with a different body.
GET/api/v1/tournaments/:id

Fetch one tournament

Accepts a UUID or a slug so links and API calls can share one route.

Auth: None for public, credential for unlisted or private

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

With the client

// A slug works anywhere an id does, so a URL someone sent you is enough.
const tournament = await client.getTournament("spring-invitational-a4f2");
if (tournament.state === "pending") console.log("Entries are still open");

Response · 200 · The tournament.

{ "tournament": { "id": "0f1c...", "slug": "spring-invitational-Rk2p8Q", "state": "pending" } }

Errors

StatusCodeWhen
404not_foundIt does not exist, or the caller may not read it. Both answer the same way on purpose.
PATCH/api/v1/tournaments/:id

Update a tournament

format and config are deliberately not editable: changing either after a bracket exists would invalidate every generated match.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Body

FieldTypeDescription
namestring (1 to 120)New display name.
slugstring (3 to 60)A readable URL in place of the generated one. Public tournaments only, and it breaks every link already shared. Normalised to lowercase, digits and hyphens.
gamestring (1 to 80)New game.
descriptionstring (up to 2000)New description.
visibilitypublic | unlisted | privateNew visibility.
registrationclosed | open | inviteOpen or close signups.
requireApprovalbooleanTurn the approval queue on or off.
rotateJoinCodebooleanIssues a fresh join code, which is how a leaked one is revoked. The code is never accepted from the caller, since a chosen code is a guessable one, and never returned here. Switching to invite mints one automatically.

Example request

curl -X PATCH https://gauntletbrackets.com/api/v1/tournaments/spring-invitational-Rk2p8Q \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "visibility": "unlisted" }'

With the client

// Going public once the schedule is settled. format and config are absent
// from the input type on purpose: changing either would invalidate every match.
await client.updateTournament(id, {
  visibility: "public",
  description: "Saturdays, 7pm UK. Best of three until the final.",
});

Response · 200 · The updated tournament.

{ "tournament": { "id": "0f1c...", "visibility": "unlisted" } }

Errors

StatusCodeWhen
400bad_requestAn unknown field was sent, including format or state.
404not_foundNot yours, or not there.
409conflictThat slug is already taken.
422unprocessableA custom slug was sent for an unlisted or private tournament.
DELETE/api/v1/tournaments/:id

Delete a tournament

Permanently removes the tournament and every participant, match, result and webhook belonging to it. Allowed in any state, including archived. There is no undo; archive instead if the bracket should stay readable.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

With the client

// Permanent, and it takes every participant, match and result with it.
// Archive instead if the bracket should stay readable.
await client.deleteTournament(id);

Response · 200 · Confirmation.

{ "deleted": true }

Errors

StatusCodeWhen
404not_foundNot yours, or not there.

Participants

Build the roster, and hand each entrant a link that lets them report their own results.

GET/api/v1/tournaments/:id/participants

List the roster

Ordered by seed. Each team's accessToken is included when the caller administers the tournament, and omitted for everyone else. This is how you retrieve a link to send a team, at any point in the tournament's life.

Auth: Whoever may read the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

With the client

// Read as the owner, every entry carries its accessToken, so the magic links
// can be handed out again without having kept the creation response.
for (const team of await client.listParticipants(id)) {
  console.log(team.seed, team.name, team.accessToken);
}

Response · 200 · The roster. `accessToken` appears for the owner only.

{
  "participants": [
    { "id": "8b2e...", "name": "Team Vortex", "seed": 1, "checkedIn": false, "accessToken": "gtp_..." }
  ]
}
POST/api/v1/tournaments/:id/participants

Add participants

Adds entrants in one call and returns a magic-link token for each. Seeds continue from the current roster size. Only possible while the tournament is a draft.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Body

FieldTypeDescription
names *array of strings (1 to 256 entries)Each name is 1 to 80 characters.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/participants \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "names": ["Team Vortex", "Team Halcyon"] }'

With the client

const entered = await client.addParticipants(id, ["Team Vortex", "Night Owls"]);
for (const team of entered) {
  console.log(`${team.name}: https://gauntletbrackets.com/p/${team.accessToken}`);
}

Response · 201 · The created participants, each with its access token.

{
  "participants": [
    { "id": "8b2e...", "name": "Team Vortex", "seed": 1, "accessToken": "gtp_..." },
    { "id": "5d71...", "name": "Team Halcyon", "seed": 2, "accessToken": "gtp_..." }
  ]
}

Errors

StatusCodeWhen
409conflictThe tournament has already started.
422unprocessableThe roster would exceed 256 participants.

Send each entrant https://gauntletbrackets.com/p/<accessToken>. Opening it exchanges the token for an httpOnly cookie and redirects, so the secret leaves the URL bar, browser history and any Referer header.

Nothing here is shown only once. A team's link stays readable from the roster for as long as the team exists, so losing this response costs nothing and sending a team its link a second time does not break the first.

PATCH/api/v1/tournaments/:id/participants

Reseed the roster

Rewrites every seed so the roster reads in the order given. Seeds are what the generator reads, so this is how you decide who meets whom. Only possible while the tournament is a draft.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Body

FieldTypeDescription
order *array of strings (uuid)Every participant id, once each, in the order you want them seeded. The first becomes seed 1.

* required

Example request

curl -X PATCH https://gauntletbrackets.com/api/v1/tournaments/$ID/participants \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "order": ["5d71...", "8b2e..."] }'

With the client

// Reseed by last season's finish. Send the finished order, every id once:
// a partial move would have to say what happens to what it displaces anyway.
const lastSeason: Record<string, number> = { "Team Vortex": 1, "Night Owls": 2 };

const roster = await client.listParticipants(id);
const seeded = [...roster].sort(
  // Anyone who did not play last season goes to the bottom, in roster order.
  (a, b) => (lastSeason[a.name] ?? 99) - (lastSeason[b.name] ?? 99),
);

await client.reorderParticipants(id, seeded.map((team) => team.id));

Response · 200 · The whole roster, renumbered.

{
  "participants": [
    { "id": "5d71...", "name": "Team Halcyon", "seed": 1, "accessToken": "gtp_..." },
    { "id": "8b2e...", "name": "Team Vortex", "seed": 2, "accessToken": "gtp_..." }
  ]
}

Errors

StatusCodeWhen
409conflictThe bracket has already been generated.
422unprocessableThe order omits, duplicates or invents a participant.

The whole order, not a single move: seeds are unique per tournament, so moving one team has to say what happens to everything it displaces. Sending the finished list also means a retry lands on the same result as the first attempt.

PATCH/api/v1/tournaments/:id/participants/:participantId

Rename a team

Corrects a team's name. Allowed in any state, unlike removing one: matches reference the participant by id, so this changes what everyone reads and moves nothing in the bracket.

Auth: Session or API key administering the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.
participantId *string (uuid)The team to rename.

* required

Body

FieldTypeDescription
name *string (1 to 80)The new name.

* required

Example request

curl -X PATCH https://gauntletbrackets.com/api/v1/tournaments/$ID/participants/$PARTICIPANT_ID \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Team Vortex Reserve" }'

With the client

// A typo fix, safe at any point in the event: matches reference the team by
// id, so this changes what everyone reads and moves nothing in the bracket.
await client.renameParticipant(id, participantId, "Night Owls");

Response · 200 · The renamed team.

{
  "participant": { "id": "8b2e...", "name": "Team Vortex Reserve", "seed": 1 }
}

Errors

StatusCodeWhen
404not_foundNo such participant in this tournament.
409conflictAnother team already has that name.

The team's link is unaffected. Renaming does not reissue a token, which is what made remove-and-re-add the wrong way to fix a typo.

DELETE/api/v1/tournaments/:id/participants/:participantId

Remove a participant

Drops a team from the roster. Only while the tournament is pending: after the bracket is generated the participant is referenced by matches. The vacated seed number is not reused, which is harmless, seeds only need to order the roster.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.
participantId *string (uuid)The participant to remove.

* required

With the client

// A no-show, dropped before the bracket exists. Once it is generated the
// participant is referenced by matches and this is refused.
await client.removeParticipant(id, participantId);

Response · 200 · Confirmation.

{ "removed": "8b2e..." }

Errors

StatusCodeWhen
409conflictThe bracket has already been generated.
404not_foundNo such participant in this tournament.

Co-organisers

Share a bracket with someone else. They get everything you have except deleting it and changing this list, and their own API keys reach it.

GET/api/v1/tournaments/:id/admins

List who administers this

The owner and every co-organiser, by username. Readable by any of them, so someone with access can see who else has it. No email address is returned: Gauntlet never discloses one account's address to another.

Auth: Session or API key administering the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Example request

curl https://gauntletbrackets.com/api/v1/tournaments/$ID/admins \
  -H "Authorization: Bearer gt_live_..."

With the client

const { owner, admins } = await client.listAdmins(id);
console.log(`${owner?.username} plus ${admins.length} co-organiser(s)`);

Response · 200 · The owner, then co-organisers in the order they were added.

{
  "owner": { "id": "1f0c...", "username": "gauntlet", "displayName": "You" },
  "admins": [
    { "id": "9a3d...", "username": "sam", "displayName": "Sam", "createdAt": "2026-08-12T13:00:00.000Z" }
  ]
}

Errors

StatusCodeWhen
403forbiddenYou do not administer this tournament.
POST/api/v1/tournaments/:id/admins

Add a co-organiser

Grants someone the same control you have over this one tournament, by username. Owner only: a co-organiser who could grant access could grant it to someone who removes you.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Body

FieldTypeDescription
username *string (3 to 30)The public handle of an existing account, matched case-insensitively. Email addresses are not accepted.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/admins \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "username": "sam" }'

With the client

// By username, never by email: you should not need a colleague's address to
// work with them, and an endpoint taking one would confirm they have an account.
await client.addAdmin(id, "jordan");

Response · 201 · The person now administering the tournament.

{ "added": { "id": "9a3d...", "username": "sam" } }

Errors

StatusCodeWhen
403forbiddenYou are a co-organiser, not the owner.
409conflictThat person already owns the tournament.
422unprocessableNo account with that username.

By username, never by email. An organiser should not need a colleague's address to work with them, and an endpoint that accepted one would confirm whether that address has an account here.

Nothing is sent to them. Pass on the link yourself; the tournament appears in their dashboard.

A co-organiser can do everything except delete the tournament and change this list. Their own API keys reach it, because a key resolves to its holder and that is who is checked.

DELETE/api/v1/tournaments/:id/admins?userId=:userId

Remove a co-organiser

Revokes access. The owner cannot be removed.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Query parameters

FieldTypeDescription
userId *string (uuid)The co-organiser to remove.

* required

Example request

curl -X DELETE "https://gauntletbrackets.com/api/v1/tournaments/$ID/admins?userId=$USER_ID" \
  -H "Authorization: Bearer gt_live_..."

With the client

// The list is what turns a username back into the id this call needs.
const { admins } = await client.listAdmins(id);
const leaving = admins.find((person) => person.username === "jordan");
if (leaving) await client.removeAdmin(id, leaving.id);

Response · 200 · The removed user id.

{ "removed": "9a3d..." }

Errors

StatusCodeWhen
403forbiddenYou are a co-organiser, not the owner.
404not_foundThat person does not administer this tournament.

Registration

Let teams enter themselves, with a join code, with your approval, or not at all.

POST/api/v1/tournaments/:id/signup

Enter a team

Puts a team on the roster, or in the approval queue when the organiser reviews entries. Governed by the tournament's `registration` field: `closed` refuses everyone, `open` accepts anyone who can read the tournament, and `invite` additionally requires the join code. Team names are unique per tournament, case-insensitively.

Auth: None. This is the one write endpoint anonymous callers may use

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Body

FieldTypeDescription
name *string (1 to 80 chars)The team name.
joinCodestringRequired when registration is `invite`. Case and separators are forgiven, so `abcd-2345` matches `ABCD2345`.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/signup \
  -H "Content-Type: application/json" \
  -d '{ "name": "Team Vortex", "joinCode": "ABCD2345" }'

With the client

// The entrant's side, from your own form. No credential at all.
const anyone = new GauntletClient({ baseUrl: "https://gauntletbrackets.com" });

const result = await anyone.signUp("spring-invitational-a4f2", {
  name: "Night Owls",
  joinCode: "ABCD2345", // only when registration is `invite`
});

console.log(result.status === "pending" ? "Waiting on the organiser" : "You are in");

Response · 201 · `status` is `confirmed` when the team took a seat, `pending` when it is waiting on the organiser. No credential is returned.

{
  "status": "confirmed",
  "participant": { "id": "8b2e...", "name": "Team Vortex", "seed": 1 }
}

Errors

StatusCodeWhen
409registration_closedThe tournament is not taking entries.
409registration_bad_codeWrong or missing join code.
409registration_not_pendingThe bracket has already been generated.
409registration_archivedThe tournament is archived.
409registration_fullThe tournament is at 256 teams.
409conflictThat team name is already entered.

No credential comes back. Entering says who is taking part; the link that lets a team report its own results is the organiser's to hand over, and they read it off the roster when they want to send it.

GET/api/v1/tournaments/:id/signups

List the approval queue

Teams waiting for approval, oldest first. Organiser only: a pending entry is a request, not a roster place.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

With the client

const waiting = await client.listSignups(id);
console.log(`${waiting.length} entries to review`);

Response · 200 · The queue.

{
  "signups": [
    { "id": "1f0c...", "name": "Team Meridian", "createdAt": "2026-08-12T09:00:00.000Z" }
  ]
}
POST/api/v1/tournaments/:id/signups/:signupId

Approve a signup

Moves a queued team onto the roster and assigns it a seed. Its access link is then readable from the roster, to send on if they are reporting their own results.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.
signupId *string (uuid)The queued signup.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

With the client

// Clear the queue, oldest entry first. Each approval takes the next seed.
for (const entry of await client.listSignups(id)) {
  const team = await client.approveSignup(id, entry.id);
  console.log(`Seeded ${team.name} at ${team.seed}`);
}

Response · 201 · The new participant.

{ "participant": { "id": "8b2e...", "name": "Team Meridian", "seed": 3 } }

Errors

StatusCodeWhen
404not_foundAlready approved, rejected, or never there.
409conflictThe bracket has already been generated.
DELETE/api/v1/tournaments/:id/signups/:signupId

Reject a signup

Discards a queued team. The token it was issued never becomes usable.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.
signupId *string (uuid)The queued signup.

* required

With the client

// Frees the name, so someone else can enter under it.
await client.rejectSignup(id, signupId);

Response · 200 · Confirmation.

{ "rejected": "1f0c..." }

Errors

StatusCodeWhen
404not_foundAlready resolved, or never there.

Bracket

Generate the bracket, read it, and watch it change.

POST/api/v1/tournaments/:id/start

Generate the bracket

Seeds the field, builds every match with its routing pointers, resolves byes, locks the roster, and moves the tournament from pending to ready. It does not begin play: results are refused until POST /open. Accepts an idempotency key.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/start \
  -H "Authorization: Bearer gt_live_..." \
  -H "Idempotency-Key: start-$ID"

With the client

// Seeds the field and locks the roster: pending to ready. Play is still
// closed, open() is the step that starts it.
const bracket = await client.generateBracket(id);
console.log(`${bracket.matches.length} matches, ${bracket.participants.length} teams`);

Response · 201 · The full bracket, identical in shape to GET /bracket without standings.

{
  "tournament": { "id": "0f1c...", "state": "ready", "startedAt": null },
  "participants": [ { "id": "8b2e...", "name": "Team Vortex", "seed": 1 } ],
  "matches": [
    {
      "id": "c40a...",
      "key": "W1-1",
      "side": "winners",
      "round": 1,
      "slot": 1,
      "label": "Winners round 1",
      "p1Id": "8b2e...",
      "p2Id": "5d71...",
      "state": "ready",
      "winnerToMatchId": "9ab3...",
      "winnerToSlot": 1,
      "version": 0
    }
  ]
}

Errors

StatusCodeWhen
409conflictThe tournament is not pending, or is archived.
422invalid_bracketToo few participants for the chosen format.
POST/api/v1/tournaments/:id/open

Open for play

Moves a ready tournament to underway and stamps startedAt. Until this runs, every result endpoint refuses with 409. This is the deliberate go-live step: generating the bracket does not start the event.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/open \
  -H "Authorization: Bearer gt_live_..."

With the client

// The deliberate go-live step. Until this runs every result is refused with
// a 409, which is what stops a mis-seeded bracket being played.
await client.open(id);

Response · 201 · The full bracket, with the tournament now underway.

{
  "tournament": { "id": "0f1c...", "state": "underway", "startedAt": "2026-08-11T18:00:00.000Z" },
  "participants": [ { "id": "8b2e...", "name": "Team Vortex", "seed": 1 } ],
  "matches": [ { "id": "c40a...", "key": "W1-1", "state": "ready", "version": 0 } ]
}

Errors

StatusCodeWhen
409conflictThe bracket has not been generated, or is already underway.
POST/api/v1/tournaments/:id/reset

Discard the bracket

Deletes every generated match and returns the tournament to pending so the roster can be edited and reseeded. Permitted only from ready: once a tournament is underway no action may destroy a reported result, and delete is the explicit way out.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/reset \
  -H "Authorization: Bearer gt_live_..."

With the client

// Wrong seeding, caught before anyone played. Refused once the tournament is
// underway, so no reported result can be destroyed by it.
await client.reset(id);

Response · 201 · The tournament, back in pending.

{ "tournament": { "id": "0f1c...", "state": "pending", "startedAt": null } }

Errors

StatusCodeWhen
409conflictThe tournament is pending, underway or complete.
POST/api/v1/tournaments/:id/archive

Archive

Closes the tournament to writes while leaving it fully readable and embeddable. Archived tournaments are hidden from public search. Archiving does not change state, so unarchiving restores the exact prior condition.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/archive \
  -H "Authorization: Bearer gt_live_..."

With the client

// Off the shelf: still readable and still embeddable, closed to every write.
await client.archive(id);

Response · 201 · The tournament, with archivedAt set.

{ "tournament": { "id": "0f1c...", "state": "complete", "archivedAt": "2026-08-11T20:00:00.000Z" } }

Errors

StatusCodeWhen
409conflictAlready archived.
POST/api/v1/tournaments/:id/unarchive

Unarchive

Clears archivedAt and reopens the tournament to writes.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/unarchive \
  -H "Authorization: Bearer gt_live_..."

With the client

// Reopens it to writes, for the correction that turned up a week later.
await client.unarchive(id);

Response · 201 · The tournament, with archivedAt cleared.

{ "tournament": { "id": "0f1c...", "state": "complete", "archivedAt": null } }
GET/api/v1/tournaments/:id/bracket

Fetch the bracket

Tournament, participants and the matches that are part of the event as it stands: being played, waiting to be played, or already decided. Round robin and Swiss also get a computed standings array.

Auth: Whoever may read the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Query parameters

FieldTypeDescription
matchesplayable | allDefaults to playable, which omits the pending and void matches: empty later rounds with no teams in them yet. Pass all when you are drawing the whole bracket rather than acting on it.
statecomma-separated match statesExactly these states, for example state=underway for a now-playing board. Overrides matches when both are given.

With the client

// A now-playing board: just the matches an organiser has started.
const live = await client.getBracket(id, { state: ["underway"] });

// The default, for anything acting on the event: playing, playable or decided,
// with the empty future rounds left out.
const current = await client.getBracket(id);

// The whole graph, for drawing the bracket rather than acting on it.
const full = await client.getBracket(id, { matches: "all" });

Response · 200 · The bracket. standings is always computed from every match, whichever ones you asked to be returned. standings is present only for round robin and Swiss.

{
  "tournament": { "id": "0f1c...", "format": "swiss", "state": "underway" },
  "participants": [ { "id": "8b2e...", "name": "Team Vortex", "seed": 1 } ],
  "matches": [ { "id": "c40a...", "key": "S1-1", "side": "swiss", "round": 1, "state": "complete" } ],
  "standings": [
    {
      "participant": "8b2e...",
      "rank": 1,
      "played": 3,
      "wins": 3,
      "losses": 0,
      "scoreFor": 9,
      "scoreAgainst": 2,
      "tiebreak": 5
    }
  ]
}
GET/api/v1/tournaments/:id/stream

Subscribe to live updates

A Server-Sent Events stream. One way traffic, so there is no upgrade handshake and no sticky sessions: EventSource reconnects on its own. A comment frame every 25 seconds keeps idle proxies from closing it.

Auth: Whoever may read the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

With the client

// Frames carry the reason, not the new state, so one code path renders the
// first load and every update after it.
const controller = new AbortController();

for await (const event of client.watch(id, { signal: controller.signal })) {
  if (event.event !== "bracket.updated") continue;

  const { matches } = await client.getBracket(id, { state: ["underway"] });
  console.log("on now:", matches.map((match) => match.label).join(", ") || "nothing");
}

// watch() does not reconnect, unlike a browser EventSource: it cannot send an
// Authorization header, which private tournaments need. Wrap it in a retry
// loop if the subscription has to outlive a network blip.

Response · 200 · text/event-stream. A ready frame arrives immediately, then one frame per change.

retry: 5000
event: ready
data: {"tournamentId":"0f1c..."}

event: bracket.updated
data: {"reason":"match.completed","matchId":"c40a..."}

: keepalive

Refetch GET /bracket when a bracket.updated frame arrives. Frames carry the reason, not the new state, so one code path renders both the first load and every update.

Rate limited to 60 new connections per minute per address, because viewers behind one office NAT share it.

Results

Two ways in: the organiser decides, or both teams agree.

POST/api/v1/matches/:id/result

Organiser reports a result

Sets the winner, writes the per-game scores, advances the winner and, in double elimination, drops the loser. Overrides participant submissions, which is how disputes get resolved.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *string (uuid)Match UUID, as returned in the bracket.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Body

FieldTypeDescription
winnerId *string (uuid)Must be one of the two participants in the match.
gamesarray of { p1Score, p2Score } (up to 21)Per-game scores. Each score is 0 to 999.
expectedVersionintegerThe match version you read. Omit to force the write and overwrite whoever got there first.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/matches/$MATCH/result \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "winnerId": "8b2e...",
    "games": [ { "p1Score": 3, "p2Score": 1 } ],
    "expectedVersion": 0
  }'

With the client

// expectedVersion makes this an optimistic write: a co-organiser who reported
// first causes a 409 instead of silently losing their result.
try {
  await client.reportResult(match.id, {
    winnerId: match.p1Id!,
    games: [{ p1Score: 3, p2Score: 1 }],
    expectedVersion: match.version,
  });
} catch (error) {
  if (error instanceof GauntletError && error.isVersionConflict) {
    const fresh = await client.getBracket(id);
    console.log("Someone reported it first:", fresh.matches.find((m) => m.id === match.id));
  } else {
    throw error;
  }
}

Response · 201 · The whole bracket after advancement, so a client never has to reassemble it.

{
  "tournament": { "id": "0f1c...", "state": "underway" },
  "participants": [ { "id": "8b2e...", "name": "Team Vortex", "seed": 1 } ],
  "matches": [
    { "id": "c40a...", "state": "complete", "winnerId": "8b2e...", "version": 1 },
    { "id": "9ab3...", "state": "ready", "p1Id": "8b2e..." }
  ]
}

Errors

StatusCodeWhen
404not_foundNo such match, or it belongs to someone else's tournament.
409conflictexpectedVersion did not match. The current version comes back in details.
422unprocessableThe winner is not in this match, or the match is not playable.
POST/api/v1/matches/:id/underway

Start or stop a match

Moves a match between ready and underway, so a viewer, an overlay or a bot can tell a match that is being played right now from one that is merely playable. Nothing else in the bracket moves, and the match version is untouched: starting a match does not invalidate a report form someone already has open.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *string (uuid)Match UUID, as returned in the bracket.

* required

Headers

FieldTypeDescription
Idempotency-KeystringUp to 255 characters. A repeat with the same key replays the first response instead of acting twice.

Body

FieldTypeDescription
underway *booleantrue starts the match, false puts it back to ready. Sent explicitly rather than toggled, so a retry cannot undo the start.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/matches/$MATCH/underway \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "underway": true }'

With the client

// Cut the stream to the next match and say so on the bracket.
const { matches } = await client.getBracket(id, { state: ["ready"] });
const next = matches[0];
if (next) await client.setMatchUnderway(next.id, true);

// Called off, back to ready. Reporting a result clears it on its own, so this
// direction is for the match that never actually started.
if (next) await client.setMatchUnderway(next.id, false);

Response · 201 · The one match that changed.

{ "match": { "id": "c40a...", "key": "W2-1", "state": "underway", "version": 0 } }

Errors

StatusCodeWhen
404not_foundNo such match, or it belongs to someone else's tournament.
409conflictThe match is not in the state the change starts from: it already has a result, its teams are undecided, or the tournament is not open for play.

Filter for these with GET /bracket?state=underway.

Subscribers see a bracket.updated frame with reason match.underway.

POST/api/v1/matches/:id/submit

Participant reports their own result

Records one side's claim. When both sides claim the same thing the bracket advances automatically. When they disagree the match is flagged disputed and left for an organiser.

Auth: Participant cookie, from a redeemed magic link

Path parameters

FieldTypeDescription
id *string (uuid)Match UUID, as returned in the bracket.

* required

Body

FieldTypeDescription
claimedWinnerId *string (uuid)Who the submitter says won.
p1Score *integer (0 to 999)Score for slot 1.
p2Score *integer (0 to 999)Score for slot 2.

* required

With the client

// The team's own side, using the gtp_ token from their magic link. One
// client per credential: this one can only see its own tournament.
const team = new GauntletClient({ baseUrl: "https://gauntletbrackets.com", token: "gtp_..." });

const outcome = await team.submitResult(matchId, {
  claimedWinnerId: myParticipantId,
  p1Score: 3,
  p2Score: 1,
});

// recorded: waiting on the opponent. confirmed: both agreed and the bracket
// moved. disputed: they did not, and an organiser now has to settle it.
console.log(outcome.status, outcome.message);

Response · 201 · status is recorded when waiting on the opponent, confirmed when both agreed and the bracket moved, or disputed when they did not.

{
  "status": "confirmed",
  "message": "Both players agreed. The bracket has been updated."
}

Errors

StatusCodeWhen
401unauthorizedNo participant cookie.
403forbiddenThe participant is not in this match.
409conflictThe match is already complete.

Webhooks

Push bracket changes to your own service, signed so you can trust them. Every payload carries imageUrl, a PNG of the bracket as it stands, so mirroring a live bracket into a chat message is one edit per delivery and no second call.

GET/api/v1/tournaments/:id/webhooks

List webhooks

Secrets are never listed back. Only the creation response has one.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

With the client

// failureCount is the health signal. A subscriber that keeps failing is
// deactivated, so this is where you find out yours stopped receiving.
for (const hook of await client.listWebhooks(id)) {
  console.log(hook.url, hook.active ? "active" : "disabled", hook.failureCount);
}

Response · 200 · Registered endpoints and their delivery health.

{
  "webhooks": [
    {
      "id": "7cd2...",
      "url": "https://example.com/gauntlet",
      "eventTypes": ["match.completed"],
      "active": true,
      "failureCount": 0,
      "createdAt": "2026-08-10T09:20:00.000Z"
    }
  ]
}
POST/api/v1/tournaments/:id/webhooks

Register a webhook

The URL is resolved and checked before it is stored: anything pointing at a private or link-local address is refused, so this feature cannot be used to reach inside the network the server sits in. Ten webhooks per tournament.

Auth: Session or API key owning the tournament

Path parameters

FieldTypeDescription
id *stringTournament UUID or slug. Both resolve to the same tournament.

* required

Body

FieldTypeDescription
url *string (https URL, up to 2000)Where to POST deliveries.
eventTypes *array of match.underway | match.completed | match.disputed | tournament.generated | tournament.started | tournament.finishedAt least one. Every payload carries tournamentId and imageUrl; the match events also carry matchId, and match.underway carries underway, which is false when a match is taken back off.

* required

Example request

curl -X POST https://gauntletbrackets.com/api/v1/tournaments/$ID/webhooks \
  -H "Authorization: Bearer gt_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/gauntlet",
    "eventTypes": ["match.underway", "match.completed", "tournament.finished"]
  }'

With the client

// Everything a scoreboard needs: what went live, what finished, and what
// needs a human.
const hook = await client.createWebhook(id, {
  url: "https://example.com/gauntlet",
  eventTypes: ["match.underway", "match.completed", "match.disputed"],
});

// The only time the secret is ever returned, and it is what proves a later
// delivery came from Gauntlet. Put it somewhere your receiver can read it
// before you go any further; listing webhooks will not give it back.
console.log(`GAUNTLET_WEBHOOK_SECRET=${hook.secret}`);

Response · 201 · The webhook, including the signing secret. Shown once.

{
  "webhook": {
    "id": "7cd2...",
    "url": "https://example.com/gauntlet",
    "eventTypes": ["match.completed", "tournament.finished"],
    "secret": "whsec_..."
  }
}

Errors

StatusCodeWhen
422unprocessableThe URL resolves to a private address, or 10 webhooks already exist.

Account

The signed-in account. There is no id parameter anywhere here on purpose.

GET/api/v1/me

Fetch your account

Who the credential belongs to. The cheapest way to check an API key: it reads no tournament, and a revoked key fails here rather than at the first real call. `email` is returned to a session only — a key proves who holds it without disclosing the address behind it.

Auth: Session or API key

With the client

// Which account a key actually belongs to. A gtp_ participant token cannot
// read this; it is not an account.
const me = await client.getMe();
console.log(`Signed in as ${me.username}`);

Response · 200 · The account.

{
  "user": {
    "id": "d21f...",
    "email": "you@example.com",  // session only; absent for an API key
    "displayName": "Gauntlet",
    "avatarUrl": "https://cdn.discordapp.com/avatars/...",
    "createdAt": "2026-08-01T12:00:00.000Z"
  }
}

Errors

StatusCodeWhen
401unauthorizedNo credential, a revoked key, or a gtp_ participant token, which is not an account.
PATCH/api/v1/me

Change your display name

Email and avatar are not editable: they come from the identity provider and are refreshed on every sign in, so a value set here would be reverted. Display name is the one field the account owns, and it is left alone by later sign ins.

Auth: Session only

Body

FieldTypeDescription
displayName *string (1 to 60)What organisers and participants see.

* required

With the client

// email and avatarUrl are absent from the input type on purpose: the identity
// provider owns both and refreshes them at every sign in.
await client.updateMe({ displayName: "Sam R." });

Response · 200 · The updated account.

{ "user": { "id": "d21f...", "displayName": "Gauntlet" } }

Errors

StatusCodeWhen
400bad_requestAn unknown field was sent, including email.
401unauthorizedNo session, or an API key was used.

Webhook signatures

Deliveries are POSTed as JSON with X-Gauntlet-Signature in the form t=<unix>,v1=<hex>, an HMAC-SHA256 over <timestamp>.<body> keyed with the secret from the creation response. Verify with a constant-time compare and reject timestamps older than five minutes, otherwise a captured delivery can be replayed at you forever.

TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
EventFires when
tournament.generatedThe bracket has been generated and the roster locked.
tournament.startedThe organiser opened the tournament for play.
match.underwayAn organiser started a match, or took it back off. `underway` says which.
match.completedA result was recorded and the bracket advanced.
match.disputedTwo participants reported conflicting results.
tournament.finishedThe final match is decided.

Every delivery body carries event, sentAt and a data object. Branch on event, which is also repeated in the X-Gauntlet-Event header. data always carries tournamentId; the match events add matchId.

{
  "event": "match.underway",
  "sentAt": "2026-08-12T19:04:11.902Z",
  "data": {
    "tournamentId": "0f1c...",
    "matchId": "c40a...",
    "underway": true,
    "imageUrl": "https://gauntletbrackets.com/embed/spring-invitational/bracket.png?v=1786..."
  }
}

Endpoints resolving to private or link-local addresses are refused at registration, so a webhook cannot be pointed back inside the network the server runs in.

Embedding

One iframe, no script tag, no API key. The embed renders on the server and updates itself over SSE while the event runs. Every tournament payload carries its own embedUrl, so this is something you read off the API rather than a snippet to copy.

<iframe src="https://gauntletbrackets.com/embed/<slug>"
        width="100%" height="600" frameborder="0"
        title="Spring Invitational"></iframe>

Where an iframe will not run, there is an image. Chat clients, forums and anything that unfurls a link take the bracket as a PNG, on the same terms: public and unlisted only, and every tournament payload carries its own imageUrl.

https://gauntletbrackets.com/embed/<slug>/bracket.png

It renders dark, which is what a chat client wants. Add ?theme=light for the light palette.

The catch is that it will not refresh on its own. Discord and its neighbours resolve an image once, when the message is posted, and cache the result against the message: no header and no re-render on our side re-triggers that. Keeping a posted bracket current means editing the message to point at a URL the client has not seen before, which is why every webhook delivery carries a versioned imageUrl. A bot mirroring a bracket is that loop and nothing else:

// One message, edited in place, for the life of the event.
app.post("/gauntlet", async (req, res) => {
  res.sendStatus(200);
  const { imageUrl } = req.body.data;
  if (!imageUrl) return; // private tournament, nothing public to show

  await fetch(`https://discord.com/api/v10/webhooks/${ID}/${TOKEN}/messages/${MESSAGE_ID}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ embeds: [{ image: { url: imageUrl } }] }),
  });
});

Editing rather than reposting keeps the bracket in one place and re-pings nobody. Verify the signature first in anything you actually ship; the delivery format is above.

Only public and unlisted tournaments can be framed. Every other route sends frame-ancestors 'none', so the organiser console cannot be wrapped in someone else's page and clicked through.