Server verification
Every coin that goes in gets an insertionId. Your backend can ask the arcade
whether an id is real, which game it belongs to and who paid. Check it before
you store anything valuable yourself: an unlock, a cosmetic, a support refund.
1. Create an API key#
Open /developer/keys and create a key. Keys look like
ga_live_... or ga_test_.... The full key is shown once; the arcade only
stores a SHA-256 hash of it.
2. Verify an insertion#
Call GET /api/v1/insertions/{insertionId} with your key as a bearer token:
GET /api/v1/insertions/ins_8f2c HTTP/1.1
Host: gemsarcadia.com
Authorization: Bearer ga_live_...| Status | Meaning |
|---|---|
200 | { valid: true, insertionId, gameId, sessionId, playerRef, coins, createdAt } |
404 | Unknown id, a test-mode id (test_...), or another studio's insertion. Treat as invalid. |
401 | Missing, malformed or revoked API key. |
Node.js (18+)#
No dependencies: it uses the built-in fetch.
const GA_BASE_URL = process.env.GA_BASE_URL ?? "https://gemsarcadia.com";
/**
* Returns the verified insertion ({ valid: true, gameId, coins, playerRef,
* ... }) or null when it does not exist or belongs to another studio.
* Throws on a bad API key or a server error, so you can retry later.
*/
export async function verifyInsertion(insertionId, {
apiKey = process.env.GA_API_KEY,
baseUrl = GA_BASE_URL,
fetchImpl = fetch,
} = {}) {
if (!apiKey) throw new Error("Set GA_API_KEY (ga_live_... or ga_test_...).");
const url = `${baseUrl}/api/v1/insertions/${encodeURIComponent(insertionId)}`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (res.status === 404) return null; // unknown, test-mode, or not yours
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`Gems Arcadia API ${res.status}: ${body.error ?? "ERROR"}`);
}
return res.json();
}Grant each insertion once. Check the game id, then atomically record the insertion id and apply your entitlement in one database transaction protected by a unique constraint. That makes simultaneous retries safe and prevents a crash from recording a receipt without granting its reward:
/**
* Grants a purchase at most once per insertion. `store.applyOnce` must use a
* unique insertion-id constraint and one database transaction to record the
* id and apply the entitlement together. It returns true only for the first
* successful grant.
*/
export async function grantOnce(insertionId, { gameId, store, ...options }) {
const insertion = await verifyInsertion(insertionId, options);
if (!insertion || insertion.gameId !== gameId) {
return { granted: false, reason: "INVALID" };
}
const applied = await store.applyOnce(insertion.insertionId, {
gameId: insertion.gameId,
playerRef: insertion.playerRef,
coins: insertion.coins,
});
if (!applied) {
return { granted: false, reason: "ALREADY_GRANTED" };
}
return { granted: true, playerRef: insertion.playerRef, coins: insertion.coins };
}Python (3.8+)#
Standard library only.
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
GA_BASE_URL = os.environ.get("GA_BASE_URL", "https://gemsarcadia.com")
def verify_insertion(insertion_id, api_key=None, base_url=GA_BASE_URL):
"""Return the verified insertion dict, or None if it is unknown,
test-mode or belongs to another studio. Raises on other errors."""
api_key = api_key or os.environ.get("GA_API_KEY")
if not api_key:
raise RuntimeError("Set GA_API_KEY (ga_live_... or ga_test_...).")
url = "%s/api/v1/insertions/%s" % (
base_url,
urllib.parse.quote(insertion_id, safe=""),
)
request = urllib.request.Request(
url, headers={"Authorization": "Bearer " + api_key}
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raiseGA_API_KEY=ga_test_... python verify_insertion.py ins_8f2c3. Look up a whole session#
GET /api/v1/sessions/{id} returns the session's status, the player ref, the
total coins and every insertion. Useful for support tickets and audits. The
fields are listed in the REST API reference.
Where do insertion ids come from?#
Games run with a Content-Security-Policy that limits network access to the
game's own files, so a game can't post to your server from inside the arcade.
Ids reach you another way, typically as a receipt code your game shows
after a coin goes in (the sample game shows RECEIPT <insertionId>) that
players quote in support requests. Once you have one insertion,
sessionId leads you to the rest of the session.
playerRef is pseudonymous: the same player always has the same ref in your
games and a different one in other studios' games. It contains no email or
account id.
Trusted high scores#
Scores sent through GemsArcadia.submitScore(score) come from the player's
browser and are inherently untrusted. The arcade validates the session, paid
insertion and configured maximum score, but a player can still modify the game
or its runtime. Treat those scores as community leaderboard entries only.
For a trusted leaderboard, verify the run on your own backend, then submit the
result with the paid insertionId and a live API key:
POST /api/v1/insertions/ins_8f2c/score HTTP/1.1
Host: gemsarcadia.com
Authorization: Bearer ga_live_...
Content-Type: application/json
{"score": 9001, "idempotencyKey": "run-2026-0042"}The endpoint confirms the insertion belongs to one of your games, rejects a second score for the same insertion, and is safe to retry with the same key. Never put the live key in a game build. The arcade's public board combines client and server reports, so developers who need a strictly trusted board should keep and display their verified results on their own backend.