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.

CloudStore — cloud saves & persistent data

CloudStore lets your game save and load JSON data in the cloud, tied to the AppHub user currently logged in. The launcher acts as a transparent proxy: your app sends the data, AppHub injects the user token and your app credentials, and the data is stored securely on the Vanelton Media servers.

Data is scoped by AppID + cloudkey. Two different apps using the same cloudkey string never see each other's data.

Your game                       AppHub Launcher                 CloudStore API
─────────────────               ───────────────────────         ────────────────────────
POST /cloudstore/set       →    Injects user token         →    POST /api/cloudstore/set.php
  Authorization: AppID:Key      Forwards AppID:Key               token:    <current user>
  cloudkey: "save-slot-1"       cloudkey: "save-slot-1"          Authorization: AppID:Key
  dataobject: { level: 5 }      dataobject: { level: 5 }         dataobject: { level: 5 }
                           ←    Returns response           ←      { success: true, ... }

Your app never handles the user token directly. You only need your own AppID and AppKey.

Authentication note

CloudStore endpoints require Authorization: AppID:AppKey specifically — a launch token (X-AppHub-Token) alone is not sufficient. The AppID is needed to scope all saved data to your app. If your app is launched by AppHub and also needs CloudStore, send both headers:

X-AppHub-Token: <launch_token>
Authorization: com.studio.mygame:YourAppKey
Content-Type: application/json

If you always use Authorization: AppID:AppKey (the recommended approach for apps that use CloudStore), you do not need X-AppHub-Token at all.

Minimal integration

const TETHER   = 'http://127.0.0.1:7420';
const APP_AUTH = 'com.studio.mygame:YourAppKey';
const HEADERS  = { 'Authorization': APP_AUTH, 'Content-Type': 'application/json' };

async function cloudSave(key, data) {
    const res = await fetch(`${TETHER}/cloudstore/set`, {
        method: 'POST', headers: HEADERS,
        body: JSON.stringify({ cloudkey: key, dataobject: data }),
    });
    return res.json();
}

async function cloudLoad(key) {
    const res = await fetch(`${TETHER}/cloudstore/get`, {
        method: 'POST', headers: HEADERS,
        body: JSON.stringify({ cloudkey: key }),
    });
    return res.json(); // .dataobject contains your saved value
}

async function cloudDelete(key) {
    const res = await fetch(`${TETHER}/cloudstore/delete`, {
        method: 'POST', headers: HEADERS,
        body: JSON.stringify({ cloudkey: key }),
    });
    return res.json();
}

// Save game progress
await cloudSave('save-slot-1', { level: 15, score: 4800 });

// Load it back
const result = await cloudLoad('save-slot-1');
if (result.ok) {
    const save = result.dataobject; // { level: 15, score: 4800 }
}
private const string Tether  = "http://127.0.0.1:7420";
private const string AppAuth = "com.studio.mygame:YourAppKey";

private static readonly HttpClient http = new HttpClient();

static AppHubCloudStore() {
    http.DefaultRequestHeaders.Add("Authorization", AppAuth);
}

static async Task<JObject> Post(string path, object body) {
    var content = new StringContent(
        JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
    var resp = await http.PostAsync($"{Tether}{path}", content);
    return JObject.Parse(await resp.Content.ReadAsStringAsync());
}

public static Task<JObject> Save(string key, object data) =>
    Post("/cloudstore/set", new { cloudkey = key, dataobject = data });

public static Task<JObject> Load(string key) =>
    Post("/cloudstore/get", new { cloudkey = key });

public static Task<JObject> Delete(string key) =>
    Post("/cloudstore/delete", new { cloudkey = key });

// Usage
var saved  = await Save("progress", new { level = 5, hp = 80 });
var loaded = await Load("progress");
if (loaded["ok"]?.Value<bool>() == true) {
    var data = loaded["dataobject"];
}
// GDScript — Godot 4
const TETHER   = "http://127.0.0.1:7420"
const APP_AUTH = "com.studio.mygame:YourAppKey"

func _cloud_post(path: String, body: Dictionary) -> Dictionary:
    var http = HTTPRequest.new()
    add_child(http)
    var headers = ["Authorization: " + APP_AUTH, "Content-Type: application/json"]
    http.request(TETHER + path, headers, HTTPClient.METHOD_POST, JSON.stringify(body))
    var result = await http.request_completed
    http.queue_free()
    if result[0] != HTTPRequest.RESULT_SUCCESS:
        return { "ok": false, "reason": "connection_failed" }
    return JSON.parse_string(result[3].get_string_from_utf8())

func cloud_save(key: String, data: Variant) -> bool:
    var res = await _cloud_post("/cloudstore/set", { "cloudkey": key, "dataobject": data })
    return res.get("ok", false)

func cloud_load(key: String) -> Variant:
    var res = await _cloud_post("/cloudstore/get", { "cloudkey": key })
    if res.get("ok", false):
        return res.get("dataobject", null)
    return null

func cloud_delete(key: String) -> bool:
    var res = await _cloud_post("/cloudstore/delete", { "cloudkey": key })
    return res.get("ok", false)
Writing to an existing cloudkey replaces the entire value — load, modify, then save. Do not store passwords, tokens, or payment data in CloudStore. If ok: false, reason: "no_account", the user is not logged in — consider showing an in-game prompt to log in via the launcher.

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
CloudStore (cloud saves)
Always send Authorization: AppID:AppKey on CloudStore requestsLaunch token alone is not accepted — AppID is needed to scope the data
Call POST /cloudstore/get on startup to load saved dataAfter /verify — load the player's progress before any game logic
Load → modify → save (never partial write)Writing to an existing key replaces the entire value
Handle ok: false, reason: "no_account"User not logged in — show an in-game prompt to sign in via the launcher
Use descriptive cloudkey namesE.g. "save-slot-1", "settings", "achievements"
Batch saves on meaningful events, not on every frameSave at checkpoints, level complete, settings close — not continuously
Do not store passwords, tokens, or payment data in CloudStoreCloudStore is for game state, not sensitive credentials

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.