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)
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)
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.
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 |
| CloudStore (cloud saves) | |
Always send Authorization: AppID:AppKey on CloudStore requests | Launch token alone is not accepted — AppID is needed to scope the data |
Call POST /cloudstore/get on startup to load saved data | After /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 names | E.g. "save-slot-1", "settings", "achievements" |
| Batch saves on meaningful events, not on every frame | Save at checkpoints, level complete, settings close — not continuously |
| Do not store passwords, tokens, or payment data in CloudStore | CloudStore 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.