Guides
Sign in with TrueTick (for apps)

Sign in with TrueTick

A way for an app you didn't build yourself — a launcher, a Discord bot, a companion dashboard — to see a user's TrueTick servers without that user ever handing it a password or an API key.

Who this is for

  • Third-party apps other people run against their own TrueTick account — launchers, bots, companion apps, anything you ship to users who aren't you. Use this flow.
  • Your own scripts, CI jobs, and automation — use an API key instead. It's simpler (one header, no polling loop) and it's yours: you mint it, you scope it, you revoke it.

If you're not sure which one you need: does a user install or connect your app, or is it you running a script against your own account? The former is this guide; the latter is API keys.

What the user sees

Your app shows the user a short code and a URL. They open it (usually already pre-filled if you send them the complete link), see TrueTick's own sign-in screen if they aren't already logged in, and then a consent screen showing:

  • Your app's name ("Sign in to {your app}"), and a link to the homepage URL you registered, if you gave us one.
  • Which account they're signed in as.
  • A bullet list of exactly what your app will be able to see: their email address, and (for the servers:list scope) the servers they own or were invited to — status, software and version, region, players online, their role, and how to join them.
  • An explicit line on what it cannot do: start, stop, change, or delete a server, or see any billing information.
  • The device code, so the user can check it matches what your app displayed, and a line telling them to continue only if they started signing in to your app themselves just now. A client_id is not a secret — anyone can start a grant under your app's name and send someone the code — so that line and the narrow scope are what protect your users from a phishing grant.

Every bullet on that screen maps to something the resulting token can actually call — the panel doesn't show a generic "this app wants access to your account," it spells out in plain language what that means.

The user can revoke access at any time from Profile → Integrations → Connected apps in their TrueTick dashboard, independent of your app. A password change does not revoke connected apps — if a user reports your app misbehaving, tell them where to go to cut it off directly.

Get a client_id

client_id is required to get an app token — there's no anonymous or self-serve path. Email support@truetick.gg with your app's name, its homepage URL, and the scopes it needs; we'll register it and send back the client_id.

⚠️

Don't skip this step to "prototype first." POST /v1/public/device/start without a client_id isn't a lesser version of this flow — it's a different flow entirely: the CLI's own device grant, which mints a full-scope ttk_ API key bound to the user's account (see Authentication). Wrong credential, wrong shape, and it hands out far more than a third-party app should ever hold.

The flow

This is RFC 8628 (opens in a new tab) device authorization, with one deviation worth calling out up front:

⚠️

TrueTick's device endpoints send and expect JSON bodies, not the application/x-www-form-urlencoded bodies classic OAuth device-flow clients assume. An off-the-shelf OAuth device-flow library built against the form-encoded convention will not work here unchanged — the response shapes follow RFC 8628, the wire format doesn't.

Start the grant

curl -X POST https://api.truetick.gg/v1/public/device/start \
  -H "content-type: application/json" \
  -d '{"client_id": "YOUR_CLIENT_ID"}'
{
  "device_code": "8f14e45f...",
  "user_code": "WDJB-MJHT",
  "verify_url": "https://truetick.gg/dashboard/cli-auth?code=WDJB-MJHT",
  "verification_uri": "https://truetick.gg/device",
  "verification_uri_complete": "https://truetick.gg/device?code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}

Ignore verify_url — it's the CLI's own legacy field (points at the dashboard's ttk_-key approval page, not the app-sign-in one) and stays populated for backward compatibility. Third-party apps use verification_uri / verification_uri_complete.

scope is optional in the request body and defaults to servers:list. Show the user user_code and either open verification_uri_complete directly (no code to type) or show verification_uri plus user_code if you can't open a browser yourself (a CLI, a game client, a TV app). The grant expires after expires_in seconds (600 = 10 minutes) if nobody approves it.

A 400 here means {"error": "invalid_client"} (bad, unregistered or disabled client_id), {"error": "invalid_scope"}, {"error": "invalid_request"} (a scope without a client_id), or {"error": "invalid body"} (the request body wasn't valid JSON, or was over 4 KiB). A 500 with {"error": "server_error"} is on our side and transient — retry later.

A 429 means the client IP has hit this endpoint's limit: 20 calls per hour per IP address, shared by everything behind that IP. It carries a Retry-After header (in seconds). A desktop launcher is fine — each player signs in from their own machine. A server-side app that starts sign-ins for many users from one IP (a Discord bot, a web backend) will hit it; email support@truetick.gg before you ship one.

Poll for the result

Every interval seconds (5, from the response above), ask whether the user has approved yet:

curl -X POST https://api.truetick.gg/v1/public/device/poll \
  -H "content-type: application/json" \
  -d '{"device_code": "8f14e45f..."}'

While the user hasn't acted yet, or has, you'll get one of:

ResponseMeaningWhat to do
200 {"access_token": "tta_...", "token_type": "Bearer", "scope": "servers:list"}Approved.Store the token. This is returned once — there's no refresh endpoint yet, so treat it like a long-lived credential. Stop polling.
400 {"error": "authorization_pending"}User hasn't approved (or denied) yet.Keep polling at the same interval.
400 {"error": "slow_down"}You're polling too fast.Add 5 seconds to your interval and keep polling.
400 {"error": "access_denied"}User declined.Stop. Don't retry this device_code.
400 {"error": "expired_token"}expires_in elapsed with no resolution.Stop. Start a fresh grant if the user still wants to connect.
500 {"error": "server_error"}A transient failure on our side.Keep polling at the same interval until expires_in runs out.

Call the API

The tta_ app token goes in the same Authorization: Bearer header shape as any bearer token:

curl -H "Authorization: Bearer $TOKEN" https://api.truetick.gg/v1/me/servers
{
  "my": [
    {
      "id": "my-smp",
      "address": "my-smp.truetick.gg:25565",
      "state": "running",
      "type": "PAPER",
      "version": "1.21.1",
      "region": "na",
      "role": "owner",
      "playersOnline": 3
    }
  ],
  "shared": [
    {
      "id": "friends-lobby",
      "address": "",
      "state": "hibernated",
      "type": "PAPER",
      "version": "1.21.1",
      "region": "eu",
      "role": "member"
    }
  ]
}

my is servers on the signed-in user's own account; shared is servers they have access to on someone else's account (role is owner, member, or limited accordingly). playersOnline is absent, not zero, when there's no live reading for that server yet — don't treat a missing field as "nobody's online." address is what a player pastes into Minecraft (Java); it's empty for a server that's a private backend of a server network — it has no public address of its own, and players join through the network's proxy. Nothing about who owns a shared server is returned. state is one of hibernated, starting, running, stopping, installing, install_failed, or start_failed.

GET /v1/whoami works the same way and returns the signed-in user's email (accountId is always empty for an app token):

curl -H "Authorization: Bearer $TOKEN" https://api.truetick.gg/v1/whoami
{ "accountId": "", "email": "user@example.com", "emailVerified": true }

What an app token can't do

An app token is deliberately narrow. It can call GET /v1/whoami and GET /v1/me/servers — that's the entire surface. Every other endpoint in the API Reference — starting a server, reading console output, touching files or backups, anything billing — returns PERMISSION_DENIED for a tta_ token, full stop, regardless of the scope it was granted. (A handful of endpoints outside the main RPC surface, like the log-streaming SSE endpoint, don't recognize Authorization: Bearer at all — they only look for an x-api-key header, so a tta_ token there gets Unauthenticated/401 rather than a 403. Either way: not reachable.) There's no escalation path from a device-flow app token to the full API; if your app needs to act on a server rather than just show its status, that's not this flow (there isn't a delegated-write flow yet — talk to us if that's what you need).

If a call that used to work suddenly returns 401 Unauthenticated, the most likely cause is that the user revoked your app from Profile → Integrations → Connected apps — treat it as "not signed in anymore," not a transient error, and start a fresh device-authorization grant rather than retrying the old token.

There's no endpoint for an app to revoke its own token yet, so a "Sign out" button in your app can only forget the token locally. To cut the token off, the user revokes your app under Profile → Integrations → Connected apps.

SDK

@truetick/sdk (0.2.0+; under Node use 0.3.0 or later — see the SDK page) wraps both halves of this flow:

import { signInWithDevice, AppClient } from "@truetick/sdk";
 
const { token, scope } = await signInWithDevice({
  clientId: "YOUR_CLIENT_ID",
  onCode: ({ userCode, verificationUriComplete }) => {
    console.log(`Open ${verificationUriComplete} (or enter ${userCode})`);
  },
});
 
const app = new AppClient({ token });
const me = await app.whoAmI();
const { my, shared } = await app.listMyServers();

signInWithDevice drives the start/poll loop for you — including the slow_down backoff — and resolves once the user approves. Pass scope to request something other than the servers:list default, and signal (an AbortSignal) if you need to cancel a pending grant (the user closed the dialog, your app is shutting down). AppClient only exposes whoAmI() and listMyServers(), matching the token's actual reach above.