Skip to main content

Game Plugin Protocol

A game server plugin lets your own game server answer for one Discord server: when a ticket opens, Ticket Tool asks the plugin about the opener's linked player and shows staff what it says, and players link their account by typing a code in game. This page describes protocol version 1 so anyone can build a plugin for any game.

Official plugins

Official open-source plugins exist for Minecraft, FiveM and RedM, Garry's Mod, Rust and CS2: see Game Server Plugins. Their source and SDKs are in ticket-tool-app/integrations.

Plugin types

A key belongs to one plugin type, and the type decides which player ID Ticket Tool sends and checks.

plugin.typeGameTicket Tool sendsHow the member is matched
minecraftMinecraftuuid, nameThe Minecraft account they linked in game with /ttlink
fivemFiveM and RedMdiscordIdTheir own Discord ID. No linking: FiveM servers see each player's discord: identifier
gmodGarry's ModsteamId (SteamID64), nameThe Steam account they linked on Ticket Tool and shared with the Discord server
rustRuststeamId, nameSame as gmod
cs2Counter-Strike 2steamId, nameSame as gmod

One fivem type covers FiveM and RedM; the plugin says which in hello.plugin.platform, for example FiveM b3095 or RedM b1491. Only minecraft keys can use /link.

How it fits together

1
Admin creates a key
Dashboard → Automations → Connections → Game server plugins. Shown once.
2
Plugin polls out
It holds an HTTPS long-poll open to Ticket Tool. No port to open.
3
A ticket opens
Ticket Tool queues a question about the opener's linked player and wakes the poll.
4
Plugin answers
It posts the profile and bans back within the lookup's deadline.
  • Outbound only. The plugin makes every connection. Ticket Tool never connects to your game server or to its database.
  • One key, one server. A key belongs to one Discord server and one plugin type. It can answer lookups and report account links for that server only. It can't read tickets or change settings.
  • Signed both ways. Every request is signed with a secret derived from the key, with a timestamp and a nonce. Every successful reply is signed too; some errors are not (see Unsigned errors).

Why long-polling

Version 1 uses HTTPS long-polling rather than a WebSocket. Game hosts often allow outbound HTTPS and little else, and some sit behind proxies that break long-lived upgrades; a long-poll is an ordinary HTTPS request that any client can make (Java's java.net.http, Node's fetch), it needs no extra library, and a dropped connection is just a poll to retry. A question still reaches the plugin as soon as it is queued, because a waiting poll returns the moment there is work. A WebSocket transport may be added in a later version and would be offered in the /hello reply.

Keys

An integration key looks like this:

ttp_0123456789abcdef.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
PartFormatUse
ttp_Fixed prefixLets secret scanners and humans recognize the key
Key id16 lowercase hex charactersPublic. Sent with every request in X-TT-Key-Id
. and secret43 base64url charactersNever sent anywhere. Only used to derive the secret

The plugin never sends the key itself. It derives a signing secret once, at start-up:

signingSecret = lowercase hex( HMAC-SHA256( key = the full key string,
                                            message = "ticket-tool plugin signing v1" ) )

The result is 64 hex characters. Use that hex string (its UTF-8 bytes) as the HMAC key when signing.

Ticket Tool stores a SHA-256 hash of the key and the derived signing secret, encrypted. The key itself is shown once, when it is created or rotated. Rotating issues new key material on the same entry and stops the old key at once; revoking deletes it. Both are on the Connections page, which also shows when each plugin last connected and which version it reported.

Requests

Base URL: https://api.ticket-tool.app/api/plugin/v1. https://dash.ticket-tool.app/api/plugin/v1 serves the same endpoints, and is what the official plugin uses by default.

Every endpoint takes POST with a JSON body ({} when there is nothing to send) and Content-Type: application/json. Bodies are limited to 32 KB.

Headers

HeaderValue
X-TT-Key-IdThe key id
X-TT-TimestampCurrent Unix time in seconds
X-TT-Nonce16 to 64 characters of A-Z a-z 0-9 _ -, random and never reused
X-TT-Signaturev1= followed by the lowercase hex HMAC described below
X-TT-Protocol1
X-TT-PluginFree text shown on the dashboard, e.g. ticket-tool-minecraft/1.0.0 (Paper 1.21.1)

Ticket Tool shows the first 100 characters of X-TT-Plugin next to the key. X-TT-Protocol may be left out; when it is sent, it must be 1.

Signing a request

Build the canonical string from six lines joined with \n (no trailing newline):

v1
POST
/poll
1760529600
abcdefghijklmnop
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
  1. The literal v1
  2. The HTTP method in upper case
  3. The endpoint: the path after the base URL, such as /poll. Not the full URL, so proxies and base URL changes don't affect signatures
  4. The value of X-TT-Timestamp
  5. The value of X-TT-Nonce
  6. The lowercase hex SHA-256 of the exact body bytes you send

Then X-TT-Signature is v1= + lowercase hex HMAC-SHA256(signingSecret, canonical string).

Ticket Tool rejects a request when:

  • a signing header is missing (missing_headers) or the nonce has the wrong format (bad_nonce)
  • the timestamp is more than 300 seconds from its clock (stale_timestamp)
  • the signature doesn't match the body, endpoint, method, timestamp and nonce (bad_signature)
  • the nonce was already used with this key in the last 10 minutes (replayed)

Every signature failure (missing_headers, bad_nonce, stale_timestamp and bad_signature) includes serverTime, Ticket Tool's current Unix time, so you can correct for clock skew.

Worked example

Use these values to test an implementation. A request POST /poll with body {}:

Key             ttp_0123456789abcdef.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Signing secret  20998da3e0f449ee48f630226afec7b5991112ac4233721a30ba008bb50d8a9c
Timestamp       1760529600
Nonce           abcdefghijklmnop
X-TT-Signature  v1=d71627ae221c55861c68c1e5d230d7faec74072f75d952ccaa58a2b67b608d3d

Verifying replies

Once the key is recognized, every reply is signed with the same secret. Replies carry X-TT-Timestamp, X-TT-Nonce (your request's nonce, echoed) and X-TT-Signature, over this canonical string:

v1
RESPONSE
<endpoint>
<reply timestamp>
<your request's nonce>
<sha256 hex of the reply body>

Accept a reply only when the nonce is the one you sent, the timestamp is within 300 seconds, and the signature matches. Because the nonce is yours, a recorded reply can't be passed off as the answer to a different request. For the example above, a reply body {"ok":true,"requests":[]} at timestamp 1760529601 is signed v1=57e67ded8456ec7d4a98676cfbd5d346e3e0d5d6a1e74f9685fb00496cc3837d.

Unsigned errors

Errors from the checks Ticket Tool runs on every request, before the endpoint itself handles it, come back unsigned: invalid_key, missing_headers, bad_nonce, stale_timestamp, bad_signature, replayed, unsupported_protocol, too_large, rate_limited (both the per-address and the per-key limit), plan_not_entitled, 503 unavailable and 500 internal. Errors from the endpoint itself, after those checks pass, are signed like any other reply, so the same code (unavailable, for example) can arrive either way.

A plugin must accept an unsigned reply only as an error (ok: false with a status of 400 or above), and act on it as the Errors table says. It must never treat an unsigned reply as a success.

Endpoints

POST /hello

Send on start-up and after a config reload.

{
  "protocol": 1,
  "plugin": {
    "type": "minecraft",
    "version": "1.0.0",
    "platform": "Paper 1.21.1"
  },
  "banProviders": ["litebans"]
}
{
  "ok": true,
  "protocol": 1,
  "pluginType": "minecraft",
  "key": { "name": "Survival" },
  "server": { "id": "123456789012345678", "name": "My Discord server" },
  "poll": { "waitSeconds": 25 }
}

plugin.type must match the key's plugin type, or the reply is 400 wrong_plugin_type with the key's type in expected. A plugin that speaks a newer protocol reads protocol here and uses that version.

POST /poll

Body {}. Ticket Tool holds the request open for up to poll.waitSeconds (25) and replies as soon as there is something to do:

{
  "ok": true,
  "requests": [
    {
      "id": "8f14e45f-ceea-4e7a-9b2e-1c2d3e4f5a6b",
      "type": "player.lookup",
      "expiresAt": "2026-10-15T12:00:04.000Z",
      "player": {
        "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
        "name": "Notch"
      },
      "include": ["profile", "bans"]
    }
  ]
}

player carries the ID for the key's type and ignores the rest. Minecraft requests have uuid and name only, as shown. Every other type gets all four keys, with null for the ones it doesn't use:

{
  "id": "8f14e45f-ceea-4e7a-9b2e-1c2d3e4f5a6b",
  "type": "player.lookup",
  "expiresAt": "2026-11-15T12:00:04.000Z",
  "player": {
    "uuid": null,
    "steamId": "76561198000000000",
    "discordId": null,
    "name": "Josh"
  },
  "include": ["profile"]
}
FieldTypesNotes
player.uuidminecraftDashed UUID; null for other types
player.steamIdgmod, rust, cs2SteamID64 as a 17-digit decimal string starting 7656119
player.discordIdfivemThe member's Discord user ID (a snowflake) as a string
player.nameallLast-known name, or null. Not authoritative
  • Poll again straight away after every reply, and answer requests on another thread so the next poll isn't held up.
  • Use a read timeout of at least 35 seconds.
  • On a network error or a 5xx, back off (1, 2, 4 … up to 60 seconds, with jitter).
  • A request must be answered before expiresAt. That is usually 4 to 5 seconds after it was queued, and can be as short as about 1.5 seconds when a support flow checks bans while a member waits. Skip any that have expired.
  • A ticket's profile lookup and its ban lookup arrive as separate requests, one with include: ["profile"] and one with include: ["bans"]. Answer each on its own. Read include rather than assuming it, since a future request may ask for both.
  • The plugin counts as connected while a poll is open and for 40 seconds after.
  • Ignore request types and include parts you don't know.

POST /respond

Answer one request. Include each part that was asked for.

{
  "requestId": "8f14e45f-ceea-4e7a-9b2e-1c2d3e4f5a6b",
  "ok": true,
  "profile": {
    "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
    "name": "Notch",
    "online": true,
    "lastSeen": "2026-10-15T11:58:00Z",
    "firstSeen": "2011-02-03T18:00:00Z",
    "server": "survival-1"
  },
  "bans": {
    "banned": true,
    "provider": "litebans",
    "reason": "X-ray",
    "createdAt": "2026-09-01T20:15:00Z",
    "expiresAt": null,
    "actor": "ModAlex",
    "activeCount": 1
  }
}
FieldTypeNotes
profile.uuidstringWith or without dashes
profile.namestringCurrent player name
profile.onlinebooleanOnline on this server right now
profile.lastSeenISO 8601 or nullLast time on this server
profile.firstSeenISO 8601 or nullFirst join
profile.serverstring or nullBackend or world, on a proxy network
bans.bannedbooleanAn active ban right now
bans.providerstringMinecraft: litebans, advancedban, libertybans or vanilla. Other types: see Ban providers
bans.reasonstring or nullUp to 2,000 characters; the card shows 500
bans.createdAtISO 8601 or nullWhen the ban started
bans.expiresAtISO 8601 or nullnull means permanent
bans.actorstring or nullWho issued it
bans.activeCountinteger, optionalActive bans on record, when more than one

Profiles for other games

Every type uses the same profile object; only the ID field differs. A FiveM answer:

{
  "requestId": "8f14e45f-ceea-4e7a-9b2e-1c2d3e4f5a6b",
  "ok": true,
  "profile": {
    "discordId": "123456789012345678",
    "steamId": "76561198000000000",
    "name": "Alex Smith",
    "online": true,
    "lastSeen": "2026-11-15T11:58:00Z",
    "firstSeen": "2026-01-03T18:00:00Z",
    "server": "city-1",
    "fields": [
      { "label": "Character", "value": "Alex Smith" },
      { "label": "Job", "value": "Police (Sergeant)" }
    ]
  }
}
FieldTypesNotes
profile.uuidminecraftRequired for Minecraft, unchanged
profile.steamIdgmod, rust, cs2 (required); fivem (optional)SteamID64. For FiveM, the player's steam: identifier converted to SteamID64
profile.discordIdfivem (required)Must equal the requested discordId
profile.nameallIn-game name. 1 to 64 characters for types other than Minecraft
profile.fieldsall except minecraftUp to 6 { "label", "value" } lines for the card: label up to 32 characters, value up to 100

online, lastSeen, firstSeen and server work as for Minecraft. Use fields for what staff find useful and the game knows, such as a FiveM character and job (ESX, QBCore or ox_core), Rust or Garry's Mod playtime, a Garry's Mod rank (the ULX or SAM usergroup) or a CS2 admin flag.

Ticket Tool rejects a profile about someone else, a steamId or discordId that doesn't match the request, with 400 invalid_body and an issues entry for that field. The request stays open, so a corrected answer before expiresAt is still accepted.

Ban providers

bans has the same shape for every type. Set provider to one of these:

plugin.typeprovider values
minecraftlitebans, advancedban, libertybans, vanilla
fivemtxadmin, server (the resource's own list or framework bans)
gmodulx, sam, server (the default ban list, banned_user.cfg)
rustserver (native bans)
cs2simpleadmin (CS2-SimpleAdmin), server

When the plugin can't answer, send ok: false with an error code:

{
  "requestId": "8f14e45f-ceea-4e7a-9b2e-1c2d3e4f5a6b",
  "ok": false,
  "error": "no_ban_provider"
}
errorMeaning
unknown_playerThe player has never joined and has no ban on record
no_ban_providerBans were asked for but no supported ban plugin is installed
unsupportedThe request type or a part isn't supported by this plugin
errorAnything else; add a short message for the server's admins

Ticket Tool replies {"ok": true}, or 410 unknown_request when the request expired, was already answered, or was never sent to this key. Send only what staff should see: reasons appear on the private staff card. IP addresses are not part of the protocol, and fields it doesn't define are dropped.

POST /link

A player typed /ttlink <code> in game.

{
  "code": "ABCD-EFGH",
  "player": { "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5", "name": "Notch" }
}
{
  "ok": true,
  "result": "linked",
  "discord": { "username": "alex" },
  "player": { "name": "Notch" }
}

result is relinked when the player was already linked to that Discord account. Codes are 8 characters, single-use and valid for 10 minutes. Ticket Tool ignores case, spaces and dashes in the code, so ABCD-EFGH and abcdefgh are the same.

Ticket Tool looks the UUID up with Mojang before linking, and player.name in the reply is the name Mojang holds; the name you send is only used to read the request. Only real Java Edition accounts can link, so offline-mode UUIDs and Geyser/Floodgate UUIDs are refused.

StatuserrorMeaning
404invalid_codeUnknown, used or expired code
422unknown_playerMojang has no account with this UUID. The code is used up
503unavailableMojang didn't answer. retryable is true and the code still works: retry in a few seconds
429rate_limitedMore than 20 /link calls this minute with this key. The code is not used up
400unsupportedThe key isn't a Minecraft key, so it can't link accounts. Other types never call /link

These replies are signed. Any other failed attempt uses the code up.

The plugin must take the UUID and name from the player who ran the command, never from command arguments, and should only accept the command from players (not the console). The new link is shared with this Discord server only; the member can share it with others from their Linked accounts page.

A link counts as verified only on the Discord server whose key reported it. A plugin is its server owner's word, so on any other server the member shares the account with, staff see it as linked in game on another server, not verified there, and its ban answers aren't counted as a clean record or a ban.

POST /goodbye

Body {}. Send when the plugin shuts down so lookups show "unavailable" straight away instead of after 40 seconds.

Errors

StatuserrorWhat to do
400unsupported_protocolThe X-TT-Protocol version isn't served here. Read protocol and use it
400invalid_bodyFix the message. On /respond, issues says which fields
401invalid_keyThe key is unknown, revoked or rotated. Stop and tell the admin
401missing_headers, bad_nonce, bad_signatureA signing bug. Check against the worked example
401stale_timestampClock skew. Adjust by serverTime and retry once
401replayedA nonce was reused. Generate a fresh one per request
403plan_not_entitledThe Discord server's plan no longer includes game lookups. Retry hourly
413too_largeBody over 32 KB
429rate_limitedBack off for a minute. Unsigned, except the signed /link limit
429too_many_pollsThree polls with this key are already open. Keep one poll at a time
500internalA fault on Ticket Tool's side. Retry with backoff
503unavailableRetry with backoff

Limits

  • 240 requests a minute per key, and 20 /link calls a minute per key. Only correctly signed requests count against a key's limit. Each address also has a limit of its own, checked before the key is looked up
  • 3 open polls at a time per key
  • Up to 20 requests in one poll reply
  • Up to 10 keys per Discord server, across all plugin types. A lookup asks at most 5 connected plugins of its type and merges their answers: the server where the player is online wins the profile, and a ban on any server counts

Versioning

The protocol version is a whole number, sent in X-TT-Protocol and /hello. Within version 1, changes are additive only: new optional fields, new request types, new include parts and new error codes. Plugins must ignore fields and types they don't recognize. A change that would break a version 1 plugin ships as version 2 under /api/plugin/v2, with version 1 kept running alongside it and the change announced in the changelog.

Plans

Game server plugins use the same plan feature as lookups and ban lookup, so they work on Community and Pro. If a server's plan changes, the plugin's requests are refused with plan_not_entitled until it is back on a plan that includes them.