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.type | Game | Ticket Tool sends | How the member is matched |
|---|---|---|---|
minecraft | Minecraft | uuid, name | The Minecraft account they linked in game with /ttlink |
fivem | FiveM and RedM | discordId | Their own Discord ID. No linking: FiveM servers see each player's discord: identifier |
gmod | Garry's Mod | steamId (SteamID64), name | The Steam account they linked on Ticket Tool and shared with the Discord server |
rust | Rust | steamId, name | Same as gmod |
cs2 | Counter-Strike 2 | steamId, name | Same 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
- 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
| Part | Format | Use |
|---|---|---|
ttp_ | Fixed prefix | Lets secret scanners and humans recognize the key |
| Key id | 16 lowercase hex characters | Public. Sent with every request in X-TT-Key-Id |
. and secret | 43 base64url characters | Never 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
| Header | Value |
|---|---|
X-TT-Key-Id | The key id |
X-TT-Timestamp | Current Unix time in seconds |
X-TT-Nonce | 16 to 64 characters of A-Z a-z 0-9 _ -, random and never reused |
X-TT-Signature | v1= followed by the lowercase hex HMAC described below |
X-TT-Protocol | 1 |
X-TT-Plugin | Free 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
- The literal
v1 - The HTTP method in upper case
- 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 - The value of
X-TT-Timestamp - The value of
X-TT-Nonce - 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"]
}
| Field | Types | Notes |
|---|---|---|
player.uuid | minecraft | Dashed UUID; null for other types |
player.steamId | gmod, rust, cs2 | SteamID64 as a 17-digit decimal string starting 7656119 |
player.discordId | fivem | The member's Discord user ID (a snowflake) as a string |
player.name | all | Last-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 withinclude: ["bans"]. Answer each on its own. Readincluderather 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 andincludeparts 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
}
}
| Field | Type | Notes |
|---|---|---|
profile.uuid | string | With or without dashes |
profile.name | string | Current player name |
profile.online | boolean | Online on this server right now |
profile.lastSeen | ISO 8601 or null | Last time on this server |
profile.firstSeen | ISO 8601 or null | First join |
profile.server | string or null | Backend or world, on a proxy network |
bans.banned | boolean | An active ban right now |
bans.provider | string | Minecraft: litebans, advancedban, libertybans or vanilla. Other types: see Ban providers |
bans.reason | string or null | Up to 2,000 characters; the card shows 500 |
bans.createdAt | ISO 8601 or null | When the ban started |
bans.expiresAt | ISO 8601 or null | null means permanent |
bans.actor | string or null | Who issued it |
bans.activeCount | integer, optional | Active 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)" }
]
}
}
| Field | Types | Notes |
|---|---|---|
profile.uuid | minecraft | Required for Minecraft, unchanged |
profile.steamId | gmod, rust, cs2 (required); fivem (optional) | SteamID64. For FiveM, the player's steam: identifier converted to SteamID64 |
profile.discordId | fivem (required) | Must equal the requested discordId |
profile.name | all | In-game name. 1 to 64 characters for types other than Minecraft |
profile.fields | all except minecraft | Up 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.type | provider values |
|---|---|
minecraft | litebans, advancedban, libertybans, vanilla |
fivem | txadmin, server (the resource's own list or framework bans) |
gmod | ulx, sam, server (the default ban list, banned_user.cfg) |
rust | server (native bans) |
cs2 | simpleadmin (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"
}
error | Meaning |
|---|---|
unknown_player | The player has never joined and has no ban on record |
no_ban_provider | Bans were asked for but no supported ban plugin is installed |
unsupported | The request type or a part isn't supported by this plugin |
error | Anything 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.
| Status | error | Meaning |
|---|---|---|
| 404 | invalid_code | Unknown, used or expired code |
| 422 | unknown_player | Mojang has no account with this UUID. The code is used up |
| 503 | unavailable | Mojang didn't answer. retryable is true and the code still works: retry in a few seconds |
| 429 | rate_limited | More than 20 /link calls this minute with this key. The code is not used up |
| 400 | unsupported | The 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
| Status | error | What to do |
|---|---|---|
| 400 | unsupported_protocol | The X-TT-Protocol version isn't served here. Read protocol and use it |
| 400 | invalid_body | Fix the message. On /respond, issues says which fields |
| 401 | invalid_key | The key is unknown, revoked or rotated. Stop and tell the admin |
| 401 | missing_headers, bad_nonce, bad_signature | A signing bug. Check against the worked example |
| 401 | stale_timestamp | Clock skew. Adjust by serverTime and retry once |
| 401 | replayed | A nonce was reused. Generate a fresh one per request |
| 403 | plan_not_entitled | The Discord server's plan no longer includes game lookups. Retry hourly |
| 413 | too_large | Body over 32 KB |
| 429 | rate_limited | Back off for a minute. Unsigned, except the signed /link limit |
| 429 | too_many_polls | Three polls with this key are already open. Keep one poll at a time |
| 500 | internal | A fault on Ticket Tool's side. Retry with backoff |
| 503 | unavailable | Retry with backoff |
Limits
- 240 requests a minute per key, and 20
/linkcalls 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.