Skip to content
tokenAppsPLAY & COLLECT

TokenApps Platform SDK — Formal Specification (SDK_SPEC) v1.4

Document status: This document is normative. The SDK Guide is a tutorial covering the same material; where the two differ, this document wins. Per the conventions of normative documents, it is written in prescriptive language. The keywords MUST / MUST NOT / SHOULD / MAY are used in their RFC 2119 sense.

Subjects: the SDK file https://tokenapps.io/sdk/v1.js · the TokenApps player (host) · REST /api/game/*, /api/apps/*, /api/ads/*. Last updated 2026-08-25.


1. Versioning Policy

  • The SDK file path carries the major version: https://tokenapps.io/sdk/v1.js. It matches the envelope's v: 1.
  • Changes within v1 are always backward compatible. Permitted changes: adding new message types, adding optional fields to existing payloads, adding new SDK methods. Prohibited changes: removing existing fields, changing their meaning or type, and adding required fields to existing messages.
  • The game MUST ignore unknown message types and unknown payload fields (ignore them and keep running). The same applies to the host — this rule is what lets both sides update independently.
  • Backward-incompatible changes ship only as v2.js + v: 2. v1 remains maintained after v2 ships.
  • The SDK exposes TokenApps.version (in the form "1.4.0"). When branching is needed, however, feature detection (typeof TokenApps.requestContinue === "function") is RECOMMENDED over version comparison.
  • This document's version (v1.x) records only the additive history within v1. v1.0 = sessions, saves, TAP (TokenApps Points); v1.1 = rewarded ads (request_ad/ad_result); v1.2 = the continue sheet (request_continue/continue_result); v1.3 = in-game goods (get_products/request_purchase/inventory).

2. Transport Layer and Envelope

The game runs inside a sandboxed iframe. External HTTPS frames retain their own origin (allow-same-origin); platform-hosted relative frames have an opaque origin. All communication with the platform travels over a single postMessage channel. The envelope is always:

{ "__tokenapps": 1, "v": 1, "type": "<message name>", "payload": { … } }
  • targetOrigin is "*" in both directions for compatibility with external and opaque origins. Authentication is by window identity, not an origin claim:
    • The host MUST process only messages where event.source === iframe.contentWindow.
    • The SDK MUST process only messages where event.source === window.parent.
  • Messages without __tokenapps: 1, or whose type is not a string, MUST be silently discarded.

3. Trust Model (Normative)

# Rule Level
T1 The game only requests. Every determination — TAP awards, amounts, caps, ad rewards — is made by the server. Values the game sends (event names, scores, claims of completed viewing) are not trusted without verification Structural
T2 The game cannot receive platform account identifiers. player.id is a pseudonym fixed to the (user, game) pair MUST NOT
T3 A game token is invalid outside its own (user, game) save slot Structural
T4 The game MUST be fully playable in the guest state. Forcing sign-in or blocking guests is grounds for review rejection MUST
T5 The score (score) is display-only and does not lead to TAP or rewards (prize provisions of Korea's Game Industry Promotion Act (게임산업법)) Structural
T6 Ads: unavailable is a normal response. The game must always maintain a path that proceeds without ads. A game that makes play or content contingent on ad viewing is grounds for review rejection MUST
T7 Play time is measured by the host. The game has nothing to do and nothing it can tamper with Structural

4. Message Specification (Complete)

4.1 Game → Host

type payload Meaning Response
ready {} Session request. Retransmission = token refresh request (the SDK sends it automatically after receiving a 401). Until the first session arrives the SDK re-sends it five times, from 0.4 s doubling — so a game that is ready before the host page is listening does not lose its handshake. The host answers every ready with a session (MUST) session
track { event: string } Reports a gameplay event. event is at most 64 characters (the host discards the excess) points
score { score: number } Score submission (the host normalizes negatives to 0 and floors fractions) score_ack
open_purchase {} Request to open the platform's TAP-purchase sheet. If the platform declines, nothing happens none
request_ad { placement: string } Rewarded-ad request. placement is at most 32 characters ad_result
request_continue {} Request for the continue sheet (TAP / ad / decline) continue_result
get_products {} Request for this game's server-registered product list. Available to guests products
request_purchase { sku: string } Purchase request. sku matches ^[a-z0-9_-]{1,64}$no price field exists purchase_result
match_find { ref: string, mode?: string, kind?: "auto" | "async" | "sync", timeout?: number } Request an opponent. ref is the SDK's id for one find() and ties every later message of this match together. kind: "sync" a live opponent only, "async" a duel against a recorded best, "auto" (default) live first, becoming async when nobody comes within timeout seconds (5–120, default 45). The only mode today is "duel" match_result
match_send { ref: string, data: any } A game message in a live match. JSON-serialized ≤ 4 KB, ≤ 8 per second — anything over is dropped with a ratelimited event. The host relays it to the opponent without reading it none (the opponent's match_event message)
match_report { ref: string, score: number, outcome?: "win" | "lose" | "draw" } Report the result. Once per match. outcome is for live matches only; omit it and the server compares the two scores match_outcome
match_leave { ref: string } Cancel the wait or leave. An async duel or a live match not yet started is a plain cancel; a live match in play is a forfeit (loss) none

4.2 Host → Game

type payload When it occurs
session See §5 Upon receiving ready. Sent again on sign-in state changes and token refreshes
points { event, status, delta?, balance? } — status: "awarded" | "duplicate" | "capped" | "ignored" | "signin_required" After track is received, once the server's determination is in
score_ack {} Upon receiving score
ad_result { placement, status, reward?, balance?, reason? } — status: "rewarded" | "dismissed" | "unavailable" | "failed" When the final outcome of request_ad is settled (including the player's choice)
continue_result { status, cost?, balance?, reason? } — status: "paid" | "rewarded" | "declined" | "unavailable" When the final outcome of request_continue is settled
products { items: [{ sku, name_ko, name_en, price_points, kind }] } — kind: "consumable" | "durable" Upon receiving get_products
purchase_result { status, sku?, balance?, reason? } — status: "purchased" | "already_owned" | "declined" | "unavailable" | "failed" When the final outcome of request_purchase is settled (including the player's confirmation)
match_result { ref, status: "searching" } · { ref, status: "matched", match: Match, reused?: boolean, fallback?: "async" } · { ref, status: "unavailable", reason } — reason: "signin_required" | "live_unavailable" | "capped" | "suspended" | "rate_limited" | "not_found" | "failed" | "unavailable" On match_find. searching means the request is in the live queue and the match will start with match_event ready. matched means an async duel is ready at once. fallback: "async" means a live match was asked for but live matches are off on this deployment
match_event { ref, event, data } — event: "ready" | "message" | "opponent_left" | "closed" | "ratelimited" During the match. ready's data { id, kind, seed, you, opponent } is where the match starts (kind: "async" when the wait expired into a ghost duel). message is what the opponent sent, opponent_left is { reason: "left" | "disconnected" }, closed is { reason }
match_outcome { ref, status, outcome?, ratingDelta?, opponentScore?, verified?, rating? } — status: "finished" | "disputed" | "already_reported" | "expired" | "not_started" | "not_found" | "rejected" | "failed", outcome: "win" | "lose" | "draw" | null After match_report, once the server's verdict is settled. For a live match that is after the opponent has reported (or forfeited) too. disputed means the two reports disagreed and the match closed without a result. In an async duel verified: false means there is an outcome but the rating did not move

ad_result, continue_result, and purchase_result each arrive exactly once per request (MUST). Ordering follows request order. Even when the player chooses an ad inside the sheet and watches it, the game receives only a single continue_result — no ad_result is sent.

5. TypeScript Type Definitions (Reference Implementation)

/** postMessage envelope */
type Envelope<T extends string, P> = { __tokenapps: 1; v: 1; type: T; payload: P };

/** Host → game: session */
type SessionPayload = {
  signedIn: boolean;
  locale: "ko" | "en";
  /** The following four exist only when signed in and token issuance succeeded. Treat their absence as guest. */
  player?: { id: string; handle: string | null; displayName: string | null };
  token?: string;      // Bearer. For /api/game/* only
  expiresIn?: number;  // seconds
  api?: string;        // API origin, e.g. "https://tokenapps.io"
};

/** Host → game: points */
type PointsPayload = {
  event: string;
  status: "awarded" | "duplicate" | "capped" | "ignored" | "signin_required";
  delta?: number;
  balance?: number;
};

/** Host → game: ad_result */
type AdResultPayload = {
  placement: string;
  status: "rewarded" | "dismissed" | "unavailable" | "failed";
  reward?: number;   // points awarded when rewarded
  balance?: number;  // updated balance when rewarded
  reason?: string;   // reason for unavailable/failed (table in §7.2 below) — display-only, do not branch on it
};

/** window.TokenApps */
interface TokenAppsSdk {
  ready(): void;
  track(event: string): void;
  submitScore(score: number): void;
  openPointsPurchase(): void;
  requestRewardedAd(placement: string): Promise<AdResultPayload>;
  requestContinue(): Promise<{
    status: "paid" | "rewarded" | "declined" | "unavailable";
    cost?: number;
    balance?: number;
    reason?: string;
  }>;
  getProducts(): Promise<
    { sku: string; name_ko: string; name_en: string | null;
      price_points: number; kind: "consumable" | "durable" }[]
  >;
  requestPurchase(sku: string): Promise<{
    status: "purchased" | "already_owned" | "declined" | "unavailable" | "failed";
    sku?: string;
    balance?: number;
    reason?: string;
  }>;
  getInventory(): Promise<
    { sku: string; kind: string; count: number; firstAt: string }[]
  >;
  getSession(): SessionPayload | null;
  getUser(): SessionPayload["player"] | null;
  canSave(): boolean;
  save(data: unknown): Promise<{ ok: true; updatedAt: string }>;
  load(): Promise<unknown | null>;
  on(type: "session" | "points" | "score_ack" | "ad_result" | "continue_result"
        | "products" | "purchase_result",
     fn: (payload: unknown) => void): void;
}

Rejections from save()/load(): Error("guest") = guest, Error("expired") = token expired (the SDK has already sent a re-handshake — retry after the next session message), Error("save_failed_<status>") = any other HTTP error.

/** What match.find() resolves: a handle from the moment the request is taken; fields fill at `ready`. */
export type TokenAppsMatch = {
  id: string | null;                    // null until ready
  kind: "async" | "sync" | null;        // null until ready. sync = a live opponent, async = a record
  mode: string;
  /** Seed both sides build the same board from — initialise the game's randomness from this and nothing else. Null until ready. */
  seed: number | null;
  you: { slot: number; handle: string | null; rating: number } | null;
  /** An async opponent is a record: score is the target. Null when nobody has scored here yet. */
  opponent: { slot: number; handle: string | null; rating: number; score?: number } | null;
  expiresAt: string | null;
  state: "searching" | "ready" | "closed";
  /**
   * searching { elapsed }   every second while waiting
   * ready { id, kind, seed, you, opponent }   the match starts — on a later tick than find() resolving
   * message (the opponent's send value) · opponent_left { reason } · result (report's verdict)
   * closed { reason } · ratelimited { dropped }
   */
  on(event: "searching" | "ready" | "message" | "opponent_left" | "result" | "closed" | "ratelimited",
     fn: (payload: unknown) => void): () => void;
  send(data: unknown): void;            // live only — JSON ≤ 4 KB, ≤ 8 per second
  report(result: { score: number; outcome?: "win" | "lose" | "draw" }): Promise<MatchOutcomePayload>;
  leave(): void;                        // a live match in play: forfeit (loss)
};

export type MatchOutcomePayload = {
  status: "finished" | "disputed" | "already_reported" | "expired" | "not_started"
        | "not_found" | "rejected" | "failed" | "closed";
  outcome?: "win" | "lose" | "draw" | null;   // null when disputed
  ratingDelta?: number;
  opponentScore?: number | null;
  verified?: boolean;
  rating?: number;
};

6. REST Specification

The save API allows CORS from any origin (Bearer-only, no cookies), so it is callable even from an opaque origin. The ads API is called by the host page — the game handles ads exclusively through the messages in §4.

6.1 GET /api/game/token — Token Issuance (host only)

Cookie-authenticated, no CORS. The game cannot call this endpoint — tokens arrive only via the session message. Query ?slug=<app slug>.

Status Response
200 { token, expiresIn, player: { id, handle, displayName } }
401 { error: "unauthorized" } — not signed in
404 { error: "not_found" } — no playable app with that slug
503 { error: "not_configured" } — demo mode (the game runs as a guest)

6.2 GET · PUT /api/game/save — Save Slot

Authorization: Bearer {token} required. One slot per game per user.

Method Request Success Errors
GET 200 { data: <value | null>, updatedAt } 401 invalid_token
PUT { "data": <any JSON> } (envelope required) 200 { ok: true, updatedAt } 401 invalid_token · 413 too_large (serialized size over 64KB) · 400 data_required

Upon receiving a 401 the SDK automatically retransmits ready. The game SHOULD wait for the new session and then retry.

6.3 POST /api/ads/offer — Ad Offer Issuance (host only)

Request: { slug, placement }. Cookie-authenticated.

Status Response
200 { available: true, offerId, watchSeconds, reward, creative } or { available: false, reason }
400 { error: "slug_and_placement_required" }
404 { error: "not_found" }
503 { error: "not_configured" }

Ineligibility is not an error; it is 200 + available: false. reason: signin_required · placement_not_allowed · not_monetizable (Basic-tier app) · too_soon (global minimum interval) · daily_cap (per-user daily limit) · app_daily_cap · point_cap (daily TAP ceiling reached — zero-reward views are blocked at the source).

6.4 POST /api/ads/complete — Offer Claim (host only)

Request: { offerId }. Cookie-authenticated. The server verifies the offer's signature, owner, expiry, and watch time against the server clock. The client-side countdown is not evidence.

Status Response
200 { status: "awarded", delta, balance } · { status: "duplicate", balance } (reused offer — no award) · { status: "capped", … }
400 { error: "offer_required" }
401 { error: "unauthorized" }
422 { error: "invalid_offer" } (forged, expired, or another user's offer) · { error: "too_early" } (watch time not met)

When an ad network is integrated in the future, an S2S callback takes this endpoint's place, and the idempotency-key discipline (ad:{uid}:{nonce}) is retained as-is.

6.5 POST /api/apps/{slug}/continue — TAP-based Continue (host only)

Request: { requestId: UUID } (issued by the host — one sheet interaction = one spend). Cookie-authenticated. The unit price is a server constant (CONTINUE_COST_POINTS) and cannot be altered by the client. The deduction is performed only by the spend_points SQL function (per-user advisory lock + balance check + idempotency key) — deducting below the balance and racing double-spends are structurally impossible.

Status Response
200 { status: "spent", cost, balance } · { status: "insufficient", balance } · { status: "duplicate", balance }
400 { error: "request_id_required" }
401 { error: "unauthorized" }
403 { error: "not_monetizable" } — Basic-tier app (fully de-monetized, TAP sinks included)
404 { error: "not_found" }

6.6 GET /api/apps/{slug}/products — Product List (host only)

The data is public (a price list), but the host page fetches it on the game's behalf and relays it via the products message — there is no CORS surface on the game side. The exposed fields are exactly the products payload in §4.2 (no internal ids). This list is the sole source of truth for prices. Whatever the game's UI says, billing uses these values.

6.7 POST /api/apps/{slug}/purchase — Goods Purchase (host only)

Request: { sku, requestId: UUID } (host-issued). Cookie-authenticated — unreachable from the sandboxed iframe in the first place, and called only after the player presses confirm on the platform sheet. Settlement runs through a single SQL function, purchase_game_item: per-user advisory lock → idempotency key → duplicate-ownership rejection for durables → balance check → ledger deduction + inventory record, all in one transaction. No path exists where TAP leave without a record (or the reverse).

Status Response
200 { status: "purchased", sku, kind, balance } · already_owned · insufficient · duplicate (retransmission — already charged once) · not_found (unknown/inactive sku)
400 { error: "bad_request" }
401 { error: "unauthorized" }
403 { error: "not_monetizable" } — Basic-tier app
404 { error: "not_found" } — app not found

6.8 GET /api/game/inventory — Owned Goods (Bearer)

Authorization: Bearer {token}. Same access model as /api/game/save (CORS open to all origins, the token is the sole credential, scoped to the (user, game) pair).

Status Response
200 { items: [{ sku, kind, count, firstAt }] } — durables are owned once; consumables carry the cumulative purchase count
401 { error: "invalid_token" }

6.10 POST /api/match/find — Start a Duel (host only)

Request { app: string, mode?: "duel", kind?: "auto" | "async" | "sync", timeout?: number }, authenticated by the player's session cookie. Guests 401, unknown game 404.

  • kind: "async" — at once, 200 { status: "matched", match, reused }. An unexpired async match in the same game is returned instead of a new one (reused: true). More than 300 per day: 429 { status: "unavailable", reason: "capped" }.
  • kind: "sync" / "auto" — joins the live queue. Paired at once: 200 { status: "matched", queueId, match } (match.kind is "sync", match.status is "pending"); otherwise 202 { status: "queued", queueId, expiresAt }. After timeout seconds (5–120, default 45) the server turns the wait into an async duel. One ticket per person at a time — calling again in the same game returns the same ticket, and a player already seated in a live match gets that match back. More than 10 per minute: 429 rate_limited; an account suspended for disputes: 403 suspended.
  • When live matches are off on this deployment (no Realtime signing key), "auto" answers with an async duel (fallback: "async") and "sync" answers 503 { status: "unavailable", reason: "live_unavailable" }.

The opponent in an async duel is the closest-rated other player's best record in this game, frozen at the moment the match is created. An async match must be reported within 6 hours. Live pairing starts within a rating gap of 100 in the same game and mode, widening by 50 for every 10 seconds waited.

6.11 POST /api/match/{id}/result — Report a Duel (host only)

Request { score: number, outcome?: "win" | "lose" | "draw" }. Response { status, result? } where result is { outcome, ratingDelta, opponentScore, verified, rating }. Once per match — a second report returns already_reported with the first result.

  • Async duel: the server sets verified: true and moves the rating (Elo, K = 24) only when the leaderboard value this player recorded via the score message since the match began is at least the reported score. The opponent only lent a record, so their rating does not move.
  • Live match: the first report answers { status: "pending" }. When the second arrives the server compares the two. If each report's outcome (or, when omitted, the one read from the two scores) agrees with the other — win/lose or draw/draw — the match is finished and both ratings move (Elo, K = 32). If not, it closes disputed and nothing moves. Three disputed in a row with the same opponent suspends both players from live matches for a day. verified is recorded but does not gate a live result — the two players' agreement is the check.
  • Leaving mid-match (leave, or no heartbeat for 20 s without a report) settles the match for the side that stayed, with both ratings moving at half weight (K = 16). If both sides are gone it closes abandoned with no change. A side still unreported 30 minutes after the start counts as gone.

6.12 POST /api/match/{id}/leave · GET /api/match/{id} (host only)

leave cancels an async duel or a live match not yet started (no rating movement) and is a forfeit in a live match in play (§6.11). GET is the polling path for the match state and result. A live match's seed stays null until both sides are ready and it is active. Both answer 404 to anyone who is not in the match.

6.13 Live Match Routes (host only)

Live matches are run by the player shell (the page embedding the game), which connects to Supabase Realtime directly. The game never sees that connection, its token or any id — it only exchanges the postMessage traffic of §4.

Route Use
POST /api/match/token Realtime credentials { url, key, jwt, exp, topic }. jwt is a 15-minute user token, topic is user:{id}. The shell fetches a new one 5 minutes before expiry. 503 when live matches are off
GET /api/match/queue/{id} Ticket state { ticket: { status, matchId, createdAt, expiresAt } } — status: "waiting" | "matched" | "cancelled" | "fallback". The polling path for a shell that missed the user:{id} notice
DELETE /api/match/queue/{id} Give up waiting. If paired in the meantime: 409 { status, matchId } — the caller then leaves that match
POST /api/match/{id}/ready Sent after joining the match channel. The second ready starts the match: { status: "active", seed, startedAt }. Both must be ready within 30 s of pairing or the match is abandoned
POST /api/match/{id}/beat The heartbeat, every 5 s while in play. The answer { status } doubles as the status poll

Both channels are private; RLS decides who may join.

  • user:{id} — readable by that user only. The database sends matched { queueId, matchId } and fallback { queueId, matchId } (the async duel made when a wait expired). Clients cannot write to it.
  • match:{id} — readable and writable by the two seated players only, while the match is pending or active. Presence keys are slot-1 / slot-2, game messages are broadcast msg { d }, and status changes are status { matchId, status, seed?, players[] } sent by the database.

6.14 Duel (Matchmaking) Conformance Requirements

  • Initialise the game's randomness from ready's seed and nothing else (MUST). The same seed must produce the same board. Build the board so that screen size and refresh rate do not change its difficulty (SHOULD).
  • Do not start the round before ready (MUST). Branch on kind for live vs async.
  • Never stake TAP on a duel (MUST). A result moves a rating and a record only; earning stays the session-completion rule of §8. Rewarding scores or wins is what the domestic 게임산업법 prize rule prohibits.
  • Submit the same score with submitScore() before report() (SHOULD) — that is what makes an async report verified.
  • Keep send() within 8 per second and 4 KB (MUST). Anything over is dropped.
  • On opponent_left, wrap up the round and report() (SHOULD).
  • The game must remain playable solo when find() is refused (MUST). unavailable is a normal answer.

6.9 Goods Sale Conformance Requirements

  • Products are pre-registered on the platform (via the operations console). The game refers to SKUs only, and no message carries an amount field — the specification provides no way to assert a price at all.
  • The game MUST grant goods only on "purchased" or "already_owned". It MUST NOT grant based on its own bookkeeping or local flags.
  • On restart, owned goods MUST be restored via getInventory(). Local state is only a cache.
  • After declined or unavailable, the game MUST proceed normally — a purchase is an option, not a wall.
  • Verification when the game has its own server (the same role as server-side receipt verification on ONE Store or Google Play): do not trust the client's purchase_result as-is; instead, have the client send the token it received in the session up to the game server, have the game server call GET {api}/api/game/inventory with that token to confirm ownership, and grant only then (SHOULD). Because the token is valid only for that (user, game) pair, the game server gains exactly that much authority and no more. A dedicated S2S receipt API is reserved for a future increment.

7. Rewarded Ad Conformance Requirements

7.1 Game Side (review criteria)

  • The game MAY request ads. Only request TAP (placements) that the server policy allows for the genre are accepted — currently for games: revive, bonus_points.
  • On unavailable, dismissed, and failed alike, the game MUST maintain a path on which play can proceed. A design where "you must watch an ad to continue" is rejected in review.
  • The game MUST branch only on the status of ad_result and SHOULD NOT branch on reason (reason is for diagnostics and display, and new values may be added).
  • Only rewards the server has written to the ledger are valid. The game is free to layer its own currency on top, but MUST NOT counterfeit platform TAP with its own display.

7.2 Platform Side (what the implementation guarantees)

  • Viewing always passes through an explicit player choice screen. Ad cards carry an "Ad" label.
  • Offers are single-use (idempotency key), expire after 120 seconds, and are bound to the issuing user.
  • Frequency limits: a global 180-second interval · 5 per user per day · 2 per (user, app) per day · a pre-check against the daily TAP ceiling. The numbers may be adjusted as a business decision (constants that go through code review, lib/ads.ts).

8. Play Measurement and the Award Gate (No Game Involvement)

  • The host (GamePlayer) measures play time with a 15-second heartbeat. The server credits at most 75 seconds per heartbeat, so the measurement can never exceed wall-clock time. Time while the tab is hidden is not credited.
  • The daily award for track("session_complete") passes only when host-measured play that day is ≥ 45 seconds. Below that, points.status = "ignored" — the award is idempotent, so retransmitting after actual play grants it once at that point.
  • The game has nothing to do in this section. The SDK contract does not change either. This section exists to answer the question "why is session_complete ignored when fired right after load?"

9. Guest (Signed-out) Specification

Platform update (2026-09-17): automatic farming settles after 60 seconds of host-measured play without an SDK event. It shares the SDK completion path's daily award key: 15 TAP per account/game/UTC day, subject to the shared 150 TAP daily earning cap. Both paths cannot pay twice. See developer handoff and Mobile & PC requirements.

  • If session.player is absent, the session is a guest session. canSave() === false, and save()/load() reject with Error("guest").
  • A guest's track() receives points.status = "signin_required". The event itself is still recorded for measurement.
  • A guest's request_ad receives ad_result.status = "unavailable" (reason: "signin_required").
  • T4 restated: the game MAY explain these three reductions, but it must not block a guest's play.