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"]);
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") render(await client.getBracket(tournament.id));
}

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.
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.
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 | bye | void | complete | disputedready means both slots are filled. 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. Creation response only, shown once, stored only as a hash.

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.

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.
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 }
  }'

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

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.
gamestring (1 to 80)New game.
descriptionstring (up to 2000)New description.
visibilitypublic | unlisted | privateNew visibility.

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" }'

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

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. Access tokens are never included here.

Auth: Whoever may read the tournament

Path parameters

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

* required

Response · 200 · The roster.

{
  "participants": [
    { "id": "8b2e...", "name": "Team Vortex", "seed": 1, "checkedIn": false }
  ]
}
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"] }'

Response · 201 · The created participants, each with a one-time accessToken.

{
  "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.

Tokens are stored as SHA-256 hashes. Lose one and the fix is a new participant, not a lookup.

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"

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_..."

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_..."

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_..."

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_..."

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 every match. 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

Response · 200 · The bracket. 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

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
  }'

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

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.

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

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.completed | tournament.started | tournament.finished | match.disputedAt least one.

* 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.completed", "tournament.finished"]
  }'

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 current session belongs to.

Auth: Session only

Response · 200 · The account.

{
  "user": {
    "id": "d21f...",
    "email": "you@example.com",
    "displayName": "Capzay",
    "avatarUrl": "https://cdn.discordapp.com/avatars/...",
    "createdAt": "2026-08-01T12:00:00.000Z"
  }
}

Errors

StatusCodeWhen
401unauthorizedNo session.
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

Response · 200 · The updated account.

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

Errors

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

API keys

Credentials for your bot. Minted and revoked from an interactive login only.

GET/api/v1/keys

List active keys

Revoked keys are omitted. Only the prefix is stored, so a key cannot be recovered from here.

Auth: Session only

Response · 200 · Active keys.

{
  "keys": [
    {
      "id": "3a90...",
      "name": "Discord bot",
      "keyPrefix": "gt_live_9f2c",
      "scopes": ["read", "write"],
      "lastUsedAt": "2026-08-11T17:55:10.000Z",
      "expiresAt": null,
      "createdAt": "2026-08-01T12:00:00.000Z"
    }
  ]
}
POST/api/v1/keys

Mint an API key

An API key can never mint another key. Otherwise one leaked key is permanent: the attacker mints a fresh one and revoking the original achieves nothing. Twenty active keys per account.

Auth: Session only

Body

FieldTypeDescription
name *string (1 to 60)What the key is for.
scopesarray of read | writeDefaults to both.
expiresInDaysinteger (1 to 365)Omit for a key that does not expire.

* required

Response · 201 · The key. secret is shown once and stored only as a hash.

{
  "key": {
    "id": "3a90...",
    "name": "Discord bot",
    "keyPrefix": "gt_live_9f2c",
    "scopes": ["read", "write"],
    "secret": "gt_live_9f2c..."
  }
}

Errors

StatusCodeWhen
403forbiddenThe caller authenticated with an API key.
422unprocessableTwenty active keys already exist.
DELETE/api/v1/keys/:id

Revoke a key

A soft delete, so audit rows naming the key stay meaningful while investigating what it did.

Auth: Session only

Path parameters

FieldTypeDescription
id *string (uuid)Key id.

* required

Response · 200 · Confirmation.

{ "revoked": true }

Errors

StatusCodeWhen
404not_foundNo such active key on this account.

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.completedA result was recorded and the bracket advanced.
match.disputedTwo participants reported conflicting results.
tournament.finishedThe final match is decided.

Every delivery body carries type, tournamentId and a data object. 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>

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.