AppHub Tether
A local HTTP protocol built into the AppHub Store launcher — giving your game or app real-time access to license verification, user identity, notifications, and more, with no SDK required.
http://127.0.0.1:7420 — the launcher listens exclusively on localhost and rejects external connections.
How it works
When AppHub Store is running, it exposes a lightweight HTTP server on port 7420. Your game calls this server at startup to verify its license before any game logic runs. The most important endpoint is /verify, which returns a cryptographically signed response your code must validate before trusting.
/verify?appid=...&nonce=...
AppHub checks the license and returns a signed JSON response within milliseconds from its local cache.
itemKey.
ok is true and the signature is valid, the user is licensed. Otherwise, show a message and quit.
License results are cached inside the launcher — 5 minutes for paid apps and 30 minutes for free apps — so repeated calls within a session are instant. The signature is always freshly computed with the current nonce and timestamp.
Authentication
Starting with Tether 1.2.0, all endpoints except /ping require authentication. The launcher accepts two methods, checked in this order:
| Method | Header | When to use |
|---|---|---|
| Launch Token | X-AppHub-Token: <token> |
When your app was opened by AppHub — the token is injected as APPHUB_TETHER_TOKEN in the environment. Preferred method. |
| AppID:AppKey | Authorization: AppID:AppKey |
When your app was not launched through AppHub (standalone, debugger, etc.). |
Reading the launch token at startup:
const token = process.env.APPHUB_TETHER_TOKEN ?? '';
const headers = token
? { 'X-AppHub-Token': token }
: { 'Authorization': 'YOUR_APP_ID:YOUR_APP_KEY' };
const token: string = process.env.APPHUB_TETHER_TOKEN ?? '';
const headers: Record<string, string> = token
? { 'X-AppHub-Token': token }
: { 'Authorization': 'YOUR_APP_ID:YOUR_APP_KEY' };
$token = getenv('APPHUB_TETHER_TOKEN') ?: '';
$authHeader = $token
? 'X-AppHub-Token: ' . $token
: 'Authorization: YOUR_APP_ID:YOUR_APP_KEY';
String token = System.getenv("APPHUB_TETHER_TOKEN");
String authHeader = (token != null && !token.isEmpty())
? "X-AppHub-Token"
: "Authorization";
String authValue = (token != null && !token.isEmpty())
? token
: "YOUR_APP_ID:YOUR_APP_KEY";
import os
token = os.environ.get('APPHUB_TETHER_TOKEN', '')
headers = {'X-AppHub-Token': token} if token else {'Authorization': 'YOUR_APP_ID:YOUR_APP_KEY'}
// Read token from environment (GameMaker 2024)
var _token = os_get_environment("APPHUB_TETHER_TOKEN");
global.tether_auth_header = (_token != "")
? "X-AppHub-Token: " + _token
: "Authorization: YOUR_APP_ID:YOUR_APP_KEY";
var token = Environment.GetEnvironmentVariable("APPHUB_TETHER_TOKEN") ?? "";
var (headerName, headerValue) = token.Length > 0
? ("X-AppHub-Token", token)
: ("Authorization", "YOUR_APP_ID:YOUR_APP_KEY");
local token = os.getenv("APPHUB_TETHER_TOKEN") or ""
local auth_header = (token ~= "") and ("X-AppHub-Token: " .. token)
or "Authorization: YOUR_APP_ID:YOUR_APP_KEY"
Developer Mode
When Developer Mode is enabled in AppHub → Settings → Application, authentication is bypassed entirely — any local process can call any endpoint without credentials. Use this only during development; never assume it is active in a shipped build. The /ping response includes "devMode": true when active.
401 / 403 response
A request to a protected endpoint with missing or invalid credentials returns HTTP 403:
{ "ok": false, "reason": "unauthorized" }
Handle this by showing a message asking the user to open AppHub Store.
Verify a license
Call GET /verify at app startup, before any game logic. Pass your app's packagename and a random nonce. Validate the response signature using your itemKey from the AppHub developer dashboard.
const APP_ID = 'com.studio.mygame';
const ITEM_KEY = 'YOUR_ITEM_KEY'; // from AppHub developer dashboard
async function verifyLicense() {
const token = process.env.APPHUB_TETHER_TOKEN ?? '';
const headers = token
? { 'X-AppHub-Token': token }
: { 'Authorization': `${APP_ID}:YOUR_APP_KEY` };
const nonce = crypto.randomUUID().replace(/-/g, '');
const url = `http://127.0.0.1:7420/verify?appid=${APP_ID}&nonce=${nonce}`;
let data;
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
if (res.status === 403) return false; // not authenticated
data = await res.json();
} catch {
return false; // AppHub not running
}
if (data.reason === 'free') return true;
const { ok, ts, sig, nonce: retNonce } = data;
if (retNonce !== nonce || Math.abs(Date.now() - ts) > 60_000 || !sig) return false;
const message = `${ok ? 1 : 0}:${APP_ID}:${ts}:${nonce}`;
const keyBytes = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(ITEM_KEY),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const rawSig = await crypto.subtle.sign('HMAC', keyBytes, new TextEncoder().encode(message));
const computed = Array.from(new Uint8Array(rawSig))
.map(b => b.toString(16).padStart(2, '0')).join('');
return computed === sig && ok;
}
if (!await verifyLicense()) {
alert('Please open AppHub Store to play.');
}
const APP_ID = 'com.studio.mygame';
const ITEM_KEY = 'YOUR_ITEM_KEY';
interface VerifyResponse {
ok: boolean; reason: string; appid: string;
ts: number; nonce: string; sig?: string;
cached?: boolean; offline?: boolean;
}
async function verifyLicense(): Promise<boolean> {
const token = process.env.APPHUB_TETHER_TOKEN ?? '';
const headers: Record<string, string> = token
? { 'X-AppHub-Token': token }
: { 'Authorization': `${APP_ID}:YOUR_APP_KEY` };
const nonce = crypto.randomUUID().replace(/-/g, '');
const url = `http://127.0.0.1:7420/verify?appid=${APP_ID}&nonce=${nonce}`;
let data: VerifyResponse;
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
if (res.status === 403) return false;
data = await res.json();
} catch {
return false;
}
if (data.reason === 'free') return true;
const { ok, ts, sig, nonce: retNonce } = data;
if (retNonce !== nonce || Math.abs(Date.now() - ts) > 60_000 || !sig) return false;
const message = `${ok ? 1 : 0}:${APP_ID}:${ts}:${nonce}`;
const keyBytes = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(ITEM_KEY),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const rawSig = await crypto.subtle.sign('HMAC', keyBytes, new TextEncoder().encode(message));
const computed = Array.from(new Uint8Array(rawSig))
.map(b => b.toString(16).padStart(2, '0')).join('');
return computed === sig && ok;
}
if (!await verifyLicense()) {
alert('Please open AppHub Store to play.');
}
<?php
define('APP_ID', 'com.studio.mygame');
define('ITEM_KEY', 'YOUR_ITEM_KEY'); // from AppHub developer dashboard
function verifyLicense(): bool {
$token = getenv('APPHUB_TETHER_TOKEN') ?: '';
$authHeader = $token
? 'X-AppHub-Token: ' . $token
: 'Authorization: ' . APP_ID . ':YOUR_APP_KEY';
$nonce = bin2hex(random_bytes(16));
$url = 'http://127.0.0.1:7420/verify?appid=' . APP_ID . '&nonce=' . $nonce;
$ctx = stream_context_create(['http' => [
'timeout' => 5,
'header' => $authHeader,
]]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) return false;
$data = json_decode($raw, true);
$ok = $data['ok'] ?? false;
$reason = $data['reason'] ?? '';
if ($reason === 'free') return true;
$retNonce = $data['nonce'] ?? '';
$ts = $data['ts'] ?? 0;
$sig = $data['sig'] ?? '';
if ($retNonce !== $nonce) return false;
if (abs((int)(microtime(true) * 1000) - $ts) > 60000) return false;
if (!$sig) return false;
$message = ($ok ? '1' : '0') . ':' . APP_ID . ':' . $ts . ':' . $nonce;
$computed = hash_hmac('sha256', $message, ITEM_KEY);
return hash_equals($computed, $sig) && $ok;
}
if (!verifyLicense()) {
die('Please open AppHub Store to play.');
}
import java.net.URI;
import java.net.http.*;
import java.security.*;
import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import org.json.JSONObject; // org.json library
static final String APP_ID = "com.studio.mygame";
static final String ITEM_KEY = "YOUR_ITEM_KEY"; // from AppHub developer dashboard
static boolean verifyLicense() throws Exception {
String token = System.getenv("APPHUB_TETHER_TOKEN");
String nonce = generateNonce();
String url = "http://127.0.0.1:7420/verify?appid=" + APP_ID + "&nonce=" + nonce;
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(java.time.Duration.ofSeconds(5))
.GET();
if (token != null && !token.isEmpty()) {
reqBuilder.header("X-AppHub-Token", token);
} else {
reqBuilder.header("Authorization", APP_ID + ":YOUR_APP_KEY");
}
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> resp = client.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 403) return false;
JSONObject data = new JSONObject(resp.body());
boolean ok = data.optBoolean("ok", false);
String reason = data.optString("reason", "");
if ("free".equals(reason)) return true;
String retNonce = data.optString("nonce", "");
long ts = data.optLong("ts", 0);
String sig = data.optString("sig", "");
if (!retNonce.equals(nonce)) return false;
if (Math.abs(java.time.Instant.now().toEpochMilli() - ts) > 60_000) return false;
if (sig.isEmpty()) return false;
String message = (ok ? "1" : "0") + ":" + APP_ID + ":" + ts + ":" + nonce;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(ITEM_KEY.getBytes("UTF-8"), "HmacSHA256"));
byte[] digest = mac.doFinal(message.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return MessageDigest.isEqual(sb.toString().getBytes(), sig.getBytes()) && ok;
}
static String generateNonce() throws Exception {
byte[] bytes = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(bytes);
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
import hmac, hashlib, secrets, time, json, os
import urllib.request
APP_ID = 'com.studio.mygame'
ITEM_KEY = 'YOUR_ITEM_KEY' # from AppHub developer dashboard
def verify_license():
token = os.environ.get('APPHUB_TETHER_TOKEN', '')
auth = {'X-AppHub-Token': token} if token else {'Authorization': f'{APP_ID}:YOUR_APP_KEY'}
nonce = secrets.token_hex(16)
url = f'http://127.0.0.1:7420/verify?appid={APP_ID}&nonce={nonce}'
req = urllib.request.Request(url, headers=auth)
try:
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read())
except urllib.error.HTTPError as e:
if e.code == 403: return False
return False
except Exception:
return False # AppHub not running
ok = data.get('ok') is True
reason = data.get('reason', '')
if reason == 'free': return True
ret_nonce = data.get('nonce', '')
ts = data.get('ts', 0)
sig = data.get('sig', '')
if ret_nonce != nonce: return False
if abs(time.time() * 1000 - ts) > 60_000: return False
if not sig: return False
message = f"{'1' if ok else '0'}:{APP_ID}:{ts}:{nonce}"
expected = hmac.new(ITEM_KEY.encode(), message.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) and ok
if not verify_license():
print('Please open AppHub Store to play.')
exit(1)
// obj_tether — Create Event
#macro APP_ID "com.studio.mygame"
#macro ITEM_KEY "YOUR_ITEM_KEY" // from AppHub dashboard — keep private
#macro MAX_AGE 60000 // 60 s in ms
// Auth: prefer launch token, fall back to AppID:AppKey
var _token = os_get_environment("APPHUB_TETHER_TOKEN");
global.tether_auth_key = (_token != "") ? "X-AppHub-Token" : "Authorization";
global.tether_auth_value = (_token != "") ? _token : APP_ID + ":YOUR_APP_KEY";
// Generate a 16-byte random nonce
var _buf = buffer_create(16, buffer_fixed, 1);
for (var _i = 0; _i < 16; _i++) buffer_write(_buf, buffer_u8, irandom(255));
buffer_seek(_buf, buffer_seek_start, 0);
global.tether_nonce = "";
for (var _i = 0; _i < 16; _i++)
global.tether_nonce += string_lower(string_format(buffer_read(_buf, buffer_u8), 2, 0));
buffer_delete(_buf);
var _url = "http://127.0.0.1:7420/verify?appid=" + APP_ID + "&nonce=" + global.tether_nonce;
var _headers = ds_map_create();
ds_map_add(_headers, global.tether_auth_key, global.tether_auth_value);
global.tether_req = http_request(_url, "GET", _headers, "");
ds_map_destroy(_headers);
// obj_tether — Async - HTTP Event
if (async_load[? "id"] != global.tether_req) exit;
if (async_load[? "status"] == 0) {
var _p = json_parse(async_load[? "result"]);
if (!is_struct(_p)) { game_end(); exit; }
var _ok = _p.ok;
var _reason = string(_p[$ "reason"] ?? "");
var _ts = real(_p[$ "ts"] ?? 0);
var _nonce = string(_p[$ "nonce"] ?? "");
if (_reason == "free") { room_goto(rm_main_menu); exit; }
if (_nonce != global.tether_nonce) { game_end(); exit; }
if (abs(get_timer() / 1000 - _ts) > MAX_AGE) { game_end(); exit; }
// HMAC verification requires a third-party extension (e.g. GMSSL).
// Contact AppHub support for a GameMaker-compatible signing extension.
// var _msg = string(_ok ? 1 : 0) + ":" + APP_ID + ":"
// + string(_ts) + ":" + global.tether_nonce;
// if (tether_hmac_sha256(ITEM_KEY, _msg) != string(_p[$ "sig"])) { game_end(); exit; }
if (_ok) {
room_goto(rm_main_menu);
} else {
show_message("Not licensed. Open AppHub Store.\nReason: " + _reason);
game_end();
}
delete _p;
} else if (async_load[? "status"] < 0) {
show_message("Could not connect to AppHub Store.\nOpen it before starting.");
game_end();
}
APP_ID="com.studio.mygame"
ITEM_KEY="YOUR_ITEM_KEY"
TOKEN="${APPHUB_TETHER_TOKEN:-}"
if [ -n "$TOKEN" ]; then
AUTH_HEADER="X-AppHub-Token: $TOKEN"
else
AUTH_HEADER="Authorization: $APP_ID:YOUR_APP_KEY"
fi
NONCE=$(openssl rand -hex 16)
RESPONSE=$(curl -s --connect-timeout 5 \
-H "$AUTH_HEADER" \
"http://127.0.0.1:7420/verify?appid=$APP_ID&nonce=$NONCE")
REASON=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('reason',''))")
OK=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ok',False))")
TS=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ts',0))")
RET_NONCE=$(echo "$RESPONSE"| python3 -c "import sys,json; print(json.load(sys.stdin).get('nonce',''))")
SIG=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sig',''))")
if [ "$REASON" = "free" ]; then echo "Licensed (free app)"; exit 0; fi
OK_NUM=0; [ "$OK" = "True" ] && OK_NUM=1
MESSAGE="${OK_NUM}:${APP_ID}:${TS}:${NONCE}"
COMPUTED=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "$ITEM_KEY" | awk '{print $2}')
if [ "$RET_NONCE" = "$NONCE" ] && [ "$COMPUTED" = "$SIG" ] && [ "$OK" = "True" ]; then
echo "Licensed"
else
echo "Please open AppHub Store to play."
exit 1
fi
using System;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
const string APP_ID = "com.studio.mygame";
const string ITEM_KEY = "YOUR_ITEM_KEY"; // from AppHub developer dashboard
static async Task<bool> VerifyLicense()
{
var token = Environment.GetEnvironmentVariable("APPHUB_TETHER_TOKEN") ?? "";
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower();
var url = $"http://127.0.0.1:7420/verify?appid={APP_ID}&nonce={nonce}";
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
if (token.Length > 0) client.DefaultRequestHeaders.Add("X-AppHub-Token", token);
else client.DefaultRequestHeaders.Add("Authorization", $"{APP_ID}:YOUR_APP_KEY");
string body;
try { body = await client.GetStringAsync(url); }
catch { return false; }
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
bool ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean();
string reason = root.TryGetProperty("reason", out var rEl) ? rEl.GetString()! : "";
if (reason == "free") return true;
if (!root.TryGetProperty("nonce", out var nEl) || nEl.GetString() != nonce) return false;
if (!root.TryGetProperty("ts", out var tsEl)) return false;
long ts = tsEl.GetInt64();
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ts) > 60_000) return false;
if (!root.TryGetProperty("sig", out var sigEl)) return false;
string sig = sigEl.GetString()!;
string message = $"{(ok ? 1 : 0)}:{APP_ID}:{ts}:{nonce}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(ITEM_KEY));
byte[] computed = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
string hex = Convert.ToHexString(computed).ToLower();
return hex == sig && ok;
}
if (!await VerifyLicense())
Console.WriteLine("Please open AppHub Store to play.");
-- Requires: luarocks install luasocket lua-cjson luacrypto
local http = require("socket.http")
local ltn12 = require("ltn12")
local json = require("cjson")
local crypto = require("crypto")
local APP_ID = "com.studio.mygame"
local ITEM_KEY = "YOUR_ITEM_KEY" -- from AppHub developer dashboard
local token = os.getenv("APPHUB_TETHER_TOKEN") or ""
local auth_header = (token ~= "")
and ("X-AppHub-Token: " .. token)
or ("Authorization: " .. APP_ID .. ":YOUR_APP_KEY")
local function hex(bytes)
return (bytes:gsub(".", function(c) return ("%02x"):format(c:byte()) end))
end
local function random_hex(n)
math.randomseed(os.time())
local t = {}
for i = 1, n do t[i] = ("%02x"):format(math.random(0, 255)) end
return table.concat(t)
end
local function verify_license()
local nonce = random_hex(16)
local url = "http://127.0.0.1:7420/verify?appid=" .. APP_ID .. "&nonce=" .. nonce
local raw, code = http.request { url = url, headers = { [auth_header:match("^([^:]+)")] = auth_header:match(": (.+)") } }
if not raw or code ~= 200 then return false end
local ok_parse, data = pcall(json.decode, raw)
if not ok_parse then return false end
local ok = data.ok == true
local reason = data.reason or ""
if reason == "free" then return true end
local ret_nonce = data.nonce or ""
local ts = data.ts or 0
local sig = data.sig or ""
if ret_nonce ~= nonce then return false end
if math.abs(os.time() * 1000 - ts) > 60000 then return false end
if sig == "" then return false end
local message = (ok and "1" or "0") .. ":" .. APP_ID .. ":" .. ts .. ":" .. nonce
local computed = hex(crypto.hmac.digest("sha256", message, ITEM_KEY, true))
return computed == sig and ok
end
if not verify_license() then
print("Please open AppHub Store to play.")
os.exit(1)
end
Security model
Responses for paid apps include an HMAC-SHA256 signature (sig) computed with your app-specific itemKey. A fake Tether server that doesn't know your itemKey cannot produce a valid signature.
| Check | Why |
|---|---|
response.nonce === nonce_you_sent | Confirms the response is for this specific request, not a replayed one |
abs(Date.now() - response.ts) < 60 000 | Responses expire after 60 seconds — prevents old captures from being reused |
HMAC-SHA256 valid using itemKey | Only AppHub knows your itemKey — a fake server can't forge it |
The signature message format is:
sig = HMAC-SHA256(itemKey, "${ok ? 1 : 0}:${appid}:${ts}:${nonce}")
itemKey outside your binary. It should be stored as an opaque constant. Any client-side check can be reversed with enough effort — for maximum security, combine Tether with your own server-side session validation.
Tether Link — game invites
Tether Link is the social layer built on top of AppHub Tether. It lets your game send and receive friend invites through the AppHub launcher, without implementing your own notification system.
Your game AppHub Launcher (User A) AppHub Launcher (User B)
───────────────── ────────────────────────── ──────────────────────────
POST /invite
friend_id: B sends game_invite notification → User B sees Accept/Decline
room_data: {...}
User B clicks Accept
Launcher B launches your app
→ inbox push: game_invite + room_data
Launcher A ← game_invite_accepted notification
GET /inbox ← push: type: "game_invite_accepted"
For launcher UI invites (Shift+Tab overlay, Friends panel context menu), register your session state with POST /session so AppHub always has the current room_data ready — even when the invite is sent by the player, not your code. For programmatic invites from within the game, use POST /invite directly.
Minimal integration:
const APP_ID = 'com.studio.mygame';
const token = process.env.APPHUB_TETHER_TOKEN ?? '';
const headers = token ? { 'X-AppHub-Token': token } : { 'Authorization': `${APP_ID}:YOUR_APP_KEY` };
// Send an invite with your room payload
async function sendInvite(friendId, roomData) {
await fetch('http://127.0.0.1:7420/invite', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ friend_id: friendId, appid: APP_ID, room_data: roomData }),
});
}
// Poll for responses (every 3–5 s during active session)
async function pollInbox() {
const res = await fetch(`http://127.0.0.1:7420/inbox?appid=${APP_ID}`, { headers });
const { events } = await res.json();
for (const e of events) {
if (e.type === 'game_invite') connectToRoom(e.room_data);
if (e.type === 'game_invite_accepted') console.log('Friend joining:', e.from_username);
if (e.type === 'game_invite_declined') updateLobbyUI('declined');
}
}
// Send invite
async Task SendInvite(int friendId, object roomData) {
var payload = JsonSerializer.Serialize(new { friend_id = friendId, appid = APP_ID, room_data = roomData });
var req = new HttpRequestMessage(HttpMethod.Post, "http://127.0.0.1:7420/invite") {
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
req.Headers.Add(token.Length > 0 ? "X-AppHub-Token" : "Authorization",
token.Length > 0 ? token : $"{APP_ID}:YOUR_APP_KEY");
await client.SendAsync(req);
}
// Poll inbox every 3–5 s
async Task PollInbox() {
var req = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:7420/inbox?appid={APP_ID}");
req.Headers.Add(token.Length > 0 ? "X-AppHub-Token" : "Authorization",
token.Length > 0 ? token : $"{APP_ID}:YOUR_APP_KEY");
var body = await (await client.SendAsync(req)).Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
foreach (var ev in doc.RootElement.GetProperty("events").EnumerateArray()) {
var type = ev.GetProperty("type").GetString();
if (type == "game_invite") ConnectToRoom(ev.GetProperty("room_data"));
if (type == "game_invite_accepted") Debug.Log("Friend joining: " + ev.GetProperty("from_username"));
}
}
import urllib.request, json, os, time
APP_ID = 'com.studio.mygame'
token = os.environ.get('APPHUB_TETHER_TOKEN', '')
auth = {'X-AppHub-Token': token} if token else {'Authorization': f'{APP_ID}:YOUR_APP_KEY'}
def send_invite(friend_id, room_data):
payload = json.dumps({'friend_id': friend_id, 'appid': APP_ID, 'room_data': room_data}).encode()
req = urllib.request.Request('http://127.0.0.1:7420/invite', data=payload,
headers={**auth, 'Content-Type': 'application/json'}, method='POST')
urllib.request.urlopen(req, timeout=5)
def poll_inbox():
req = urllib.request.Request(f'http://127.0.0.1:7420/inbox?appid={APP_ID}', headers=auth)
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read())
for e in data.get('events', []):
if e['type'] == 'game_invite': connect_to_room(e.get('room_data'))
if e['type'] == 'game_invite_accepted': print('Friend joining:', e.get('from_username'))
if e['type'] == 'game_invite_declined': update_lobby_ui('declined')
# During active session
while session_active:
poll_inbox()
time.sleep(3)
Other Tether features
Integration checklist
| Requirement | Notes |
|---|---|
| Authentication (Tether 1.2.0+) | |
Read APPHUB_TETHER_TOKEN at startup | Present when app was launched by AppHub |
Send X-AppHub-Token header if token is set | On every request except /ping |
Fall back to Authorization: AppID:AppKey | When APPHUB_TETHER_TOKEN is absent (launched outside AppHub) |
Handle 403 as unauthorized | Show message asking user to open AppHub Store |
| License check | |
Call /verify before any game logic | In your loading screen, _ready(), or main() |
| Generate a fresh random nonce on every call | 16+ hex chars — do not reuse |
| Set a 5-second timeout | AppHub responds instantly on localhost; timeout means it's not running |
Verify response.nonce matches what you sent | Before trusting any other field |
Verify response.ts is within 60 seconds | Rejects replayed responses |
Verify response.sig with your itemKey | Skip only when reason === "free" |
| Treat connection failure as unauthorized | Launcher not running = user cannot play |
| Show a clear message pointing to AppHub Store | Don't silently crash |
| Do not ship any bypass or fallback | The check must always run for paid apps |
| Tether Link (multiplayer) | |
Call POST /session on room create/destroy and joinability changes | Keeps launcher UI invites up-to-date automatically |
Set joinable: false during loading screens or private sessions | Launcher disables the Invite button automatically |
Use POST /invite to send programmatic game invites | Include your room_data so the friend's app can join |
Poll GET /inbox every 3–5 s during active sessions | Stop polling when no session is active |
Handle game_invite → connect to room_data | Auto-launched if app wasn't running |
| Implement a 5-minute client-side invite timeout | Expired invites produce no event |
Offline behavior
AppHub caches license results locally — 5 minutes for paid apps and 30 minutes for free apps. If the network is unavailable but a cached result exists, it is served with "offline": true. If there is no cache at all, the launcher returns "ok": false, "reason": "offline".
Design your offline handling accordingly — most apps can treat offline the same as unauthorized, prompting the user to check their connection and open AppHub again.