Console
AppHub

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.

AppHub Tether is only available while AppHub Store is running on the user's machine. All requests go to 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.

1
Generate a nonce A fresh random hex string (16+ chars) on every call — prevents replay attacks.
2
Call /verify?appid=...&nonce=... AppHub checks the license and returns a signed JSON response within milliseconds from its local cache.
3
Verify the signature Check nonce matches, timestamp is fresh (within 60 s), and HMAC-SHA256 is valid using your itemKey.
4
Proceed or exit If 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:

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

CheckWhy
response.nonce === nonce_you_sentConfirms the response is for this specific request, not a replayed one
abs(Date.now() - response.ts) < 60 000Responses expire after 60 seconds — prevents old captures from being reused
HMAC-SHA256 valid using itemKeyOnly 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}")
Never log, transmit, or expose your 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)
Game invites expire after 5 minutes. If no response arrives, implement a client-side timeout and update your lobby UI. The launcher automatically launches your app on the friend's side if it isn't already running.

Integration checklist

RequirementNotes
Authentication (Tether 1.2.0+)
Read APPHUB_TETHER_TOKEN at startupPresent when app was launched by AppHub
Send X-AppHub-Token header if token is setOn every request except /ping
Fall back to Authorization: AppID:AppKeyWhen APPHUB_TETHER_TOKEN is absent (launched outside AppHub)
Handle 403 as unauthorizedShow message asking user to open AppHub Store
License check
Call /verify before any game logicIn your loading screen, _ready(), or main()
Generate a fresh random nonce on every call16+ hex chars — do not reuse
Set a 5-second timeoutAppHub responds instantly on localhost; timeout means it's not running
Verify response.nonce matches what you sentBefore trusting any other field
Verify response.ts is within 60 secondsRejects replayed responses
Verify response.sig with your itemKeySkip only when reason === "free"
Treat connection failure as unauthorizedLauncher not running = user cannot play
Show a clear message pointing to AppHub StoreDon't silently crash
Do not ship any bypass or fallbackThe check must always run for paid apps
Tether Link (multiplayer)
Call POST /session on room create/destroy and joinability changesKeeps launcher UI invites up-to-date automatically
Set joinable: false during loading screens or private sessionsLauncher disables the Invite button automatically
Use POST /invite to send programmatic game invitesInclude your room_data so the friend's app can join
Poll GET /inbox every 3–5 s during active sessionsStop polling when no session is active
Handle game_invite → connect to room_dataAuto-launched if app wasn't running
Implement a 5-minute client-side invite timeoutExpired 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.