# Appside > Build a web store that feels like your app. Appside (appside.build) is a web store and checkout platform for mobile games and apps, founded in 2026 by Oded Kovach. Not affiliated with Appcharge. Pricing: 3.9% per successful payment, no setup fee, no subscription. Common misspellings: app side, appsides, appside.io. The canonical domain is appside.build. Source: https://appside.build/docs/ --- title: Appside developer docs nav: Overview order: 1 --- # Sell from your game's own web store Appside gives your game a web store that looks and feels like your app, a checkout, and delivery of purchased items back into your game. Players move from the app to the store **already signed in**, pay, and their items land in the game in seconds. > **Status:** test mode. Every flow on these pages works end to end against the Appside sandbox. Live payments are on the roadmap. Each SDK page says whether it is **built and tested** or **preview source**. ## Three steps, on every platform | | What you build | Where | |---|---|---| | 1 | **Sign a player token**: a 5-minute ES256 JWT that says who the player is | your game backend | | 2 | **Open the store or a pack**: `Appside.openStore(token)` or `Appside.purchase("voyager", token)` | your app or game client | | 3 | **Grant on the webhook**: verify `order.paid` and credit the player once per `order_id` | your game backend | There is no server-to-server checkout session to create and no separate store-login system to run. The price always comes from the catalogue you published in Studio, so a client can't tamper with it. ```mermaid-ish Game client ──openStore(token)──▶ Appside store ──pay──▶ Appside ▲ │ └── returnURL (deep link, UI hint only) │ order.paid (signed webhook) ▼ Your game backend ──grants items ``` ## Start here - [Quickstart: first pack sold in 15 minutes](/docs/quickstart/) - [Player tokens](/docs/player-tokens/) - [Webhooks: order.paid and order.refunded](/docs/webhooks/) - [Return to the game: deep links and universal links](/docs/deep-links/) - [Testing: cards, 3-D Secure, outages](/docs/testing/) - [SDKs and their status](/docs/sdks/) - [Coming from Appcharge Payment Links](/docs/migrate-from-appcharge/) - [MCP: connect your coding agent](/docs/mcp/) Source: https://appside.build/docs/quickstart/ --- title: Quickstart nav: Quickstart order: 2 --- # Quickstart: sell your first pack in 15 minutes You need an Appside Studio workspace in test mode with one published store (Studio → **Preview & publish**). ## 1. Connect your backend (Studio → Integration) 1. Generate an ES256 key pair on your backend and paste the **public** JWK into **Public JWK/JWKS**. Never upload the private key. 2. Set **Issuer** (for example your game id: `orbit`). 3. Set **Fulfillment URL** (for example `https://api.yourgame.com/appside/webhook`) and a **webhook secret** of 32+ characters. 4. Press **Send test event**: health should show *acknowledged*. ## 2. Sign a player token on your backend :::tabs ```js Node import { Appside } from 'appside/node'; const appside = new Appside({ storeId: 'orbit', issuer: 'orbit', kid: 'game-key-1', privateKey: process.env.APPSIDE_PRIVATE_KEY_PEM, webhookSecret: process.env.APPSIDE_WEBHOOK_SECRET, serverKey: process.env.APPSIDE_SERVER_KEY, baseURL: 'https://app.appside.build' }); app.post('/appside/token', requireLogin, (req, res) => res.json({ token: appside.playerToken(req.user.id) })); // 5-minute, single-use ES256 token ``` ```python Python from appside import Appside appside = Appside('orbit', issuer='orbit', kid='game-key-1', privateKey=PRIVATE_KEY_PEM, webhookSecret=WEBHOOK_SECRET, serverKey=SERVER_KEY, baseURL='https://app.appside.build') @app.post("/appside/token") def token(user=Depends(current_user)): return {"token": appside.playerToken(user.id)} ``` ::: Only call it from an **authenticated** endpoint: the token says "this signed-in player is buying". ## 3. Open the store from the game :::tabs ```js Web import { Appside } from 'appside/web'; const appside = new Appside({ baseURL: 'https://app.appside.build' }); appside.configure('orbit', { environment: 'test', returnURL: 'https://yourgame.com/appside/return' }); appside.onPurchaseCompleted = result => refreshWalletFromBackend(); // UI hint only const fresh = () => fetch('/appside/token', { method: 'POST' }).then(r => r.json()).then(j => j.token); await appside.openStore(await fresh()); // whole store await appside.purchase('voyager', await fresh()); // one pack, straight to checkout // on your return page: const result = appside.handle(location.href); // { status, orderId, state, verified: false } ``` ```swift iOS import AppsideSDK let sdk = Appside(baseURL: URL(string: "https://app.appside.build")!, openURL: { url in browser.open(url) }) // e.g. SFSafariViewController try sdk.configure("orbit", environment: "test", returnURL: URL(string: "orbitquest://return")!) try await sdk.openStore(playerToken) try await sdk.purchase("voyager", playerToken: freshPlayerToken) // in .onOpenURL / scene(_:openURLContexts:) let result = try sdk.handle(incomingURL) // UI hint only ``` ```kotlin Android val appside = Appside(activity, "https://app.appside.build") appside.configure("orbit", "test", "orbitquest://return") appside.openStore(playerToken) // Custom Tabs; returns CompletableFuture appside.purchase("voyager", freshPlayerToken) // in onNewIntent / onCreate of the activity with the return intent-filter: val result = appside.handle(intent.data.toString()) ``` ```csharp Unity // Attach AppsideSDK.Appside to a GameObject that survives scene loads, set baseURL in the inspector. appside.configure("orbit", "test", "orbitquest://return"); appside.onPurchaseCompleted += result => ShowToast(result.orderId); appside.openStore(playerToken); // iOS, Android, WebGL; deep links forwarded automatically appside.purchase("voyager", freshPlayerToken); ``` ```js React Native import { Linking } from 'react-native'; import { Appside } from '@appside/react-native'; const appside = new Appside({ Linking, baseURL: 'https://app.appside.build' }); appside.configure('orbit', { environment: 'test', returnURL: 'orbitquest://return' }); await appside.openStore(playerToken); ``` ```dart Flutter final appside = Appside(baseURL: 'https://app.appside.build'); appside.configure('orbit', environment: 'test', returnURL: 'orbitquest://return'); await appside.openStore(playerToken); await appside.purchase('voyager', freshPlayerToken); ``` ::: `purchase(sku)` needs no server-to-server checkout session: the signed token plus the SKU is enough, and the price comes from your published catalogue. ## 4. Grant on the webhook (server) :::tabs ```js Node app.post('/appside/webhook', express.raw({ type: '*/*' }), async (req, res) => { if (!appside.verifyWebhook(req.body, req.headers)) return res.sendStatus(401); const event = JSON.parse(req.body); if (event.type === 'order.paid' && !event.probe) await grantOnce(event.order_id, event.player_id, event.order); if (event.type === 'order.refunded') await revokeOnce(event.order_id, event.player_id); res.sendStatus(204); // any 2xx = acknowledged; anything else is retried }); ``` ```python Python @app.post("/appside/webhook") async def webhook(request: Request): raw = await request.body() if not appside.verifyWebhook(raw, request.headers): return Response(status_code=401) event = json.loads(raw) if event["type"] == "order.paid" and not event["probe"]: grant_once(event["order_id"], event["player_id"], event["order"]) return Response(status_code=204) ``` ::: `grantOnce` must be idempotent on `order_id`: Appside retries until it gets a 2xx (up to 72 h), so you may see the same event twice. To double-check an order from your server: `await appside.getOrder(orderId)` (uses your server key from Studio → Integration). ## 5. Test it Buy a pack with card **4242 4242 4242 4242**, any future expiry, any CVC. The webhook arrives within a second and your player has the coins. See [Testing](/docs/testing/) for decline, 3-D Secure and outage cases. Source: https://appside.build/docs/player-tokens/ --- title: Player tokens nav: Player tokens order: 3 --- # Player tokens A player token is how your game tells Appside who is buying, **without** a login screen and without Appside calling your server to authenticate. It's a compact JWS (JWT) signed by your backend with ES256 (P-256). ## Claims (all required unless marked) | Claim | Value | |---|---| | `alg` (header) | `ES256` | | `kid` (header) | id of a public key you uploaded in Studio → Integration | | `iss` | your issuer string, exactly as configured | | `aud` | `appside:store:` | | `tenant` | your store id | | `player_id` | your stable player id (≤ 200 chars) | | `nonce` | 16–128 chars `[A-Za-z0-9_-]`, unique per token | | `exp` | ≤ 5 minutes after now | | `iat`, `nbf` | optional | ## Rules Appside enforces - **Single use:** the nonce is consumed atomically. Opening the same token twice fails with *Player assertion already used*. - **Short-lived:** anything over 5 minutes is rejected. - **Your keys only:** Appside accepts public JWKs only. Uploading a private key is refused. - **Rotation:** upload the new public key next to the old one, switch your backend to the new `kid`, wait 5 minutes, then remove the old key. ## Never in the client Signing keys stay on your backend. The app asks your backend for a fresh token each time it opens the store. Source: https://appside.build/docs/webhooks/ --- title: Webhooks nav: Webhooks order: 4 --- # Webhooks: `order.paid` and `order.refunded` Appside delivers every paid and refunded order to your **Fulfillment URL** as a signed HTTPS POST. This is the only place you should grant or revoke items. ## Payload ```json { "type": "order.paid", "event_id": "TEST-6f1c…", "mode": "test", "tenant": "orbit", "order_id": "TEST-6f1c…", "player_id": "player-123", "probe": false, "order": { "id": "TEST-6f1c…", "sku": "voyager", "credits": 1400, "price": 799, "currency": "USD", "version": 3 } } ``` - `order.refunded` carries the same `order_id`; its `event_id` is `:refund`. - `probe: true` is the Studio **Send test event**: acknowledge it, grant nothing. ## Headers | Header | Meaning | |---|---| | `x-appside-timestamp` | Unix seconds when signed | | `x-appside-signature` | hex HMAC-SHA256 of `"." + rawBody` with your webhook secret | | `idempotency-key` | the `event_id`; dedupe on it | ## Verify the signature Reject if the timestamp is more than 5 minutes off, then compare in constant time. :::tabs ```js Node import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(raw, h, secret) { const ts = h['x-appside-timestamp'], sig = h['x-appside-signature'] || ''; if (!/^\d{10}$/.test(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; const want = createHmac('sha256', secret).update(`${ts}.`).update(raw).digest(); const got = Buffer.from(sig, 'hex'); return got.length === want.length && timingSafeEqual(got, want); } ``` ```python Python import hmac, hashlib, time def verify(raw: bytes, h, secret: str) -> bool: ts, sig = h.get("x-appside-timestamp", ""), h.get("x-appside-signature", "") if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False want = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest() return hmac.compare_digest(want, sig) ``` ```go Go func Verify(raw []byte, ts, sig, secret string) bool { t, err := strconv.ParseInt(ts, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { return false } m := hmac.New(sha256.New, []byte(secret)); m.Write([]byte(ts + ".")); m.Write(raw) return hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(sig)) } ``` ```csharp C# static bool Verify(byte[] raw, string ts, string sig, string secret) { if (!long.TryParse(ts, out var t) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false; using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var want = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(ts + ".").Concat(raw).ToArray())).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(want), Encoding.ASCII.GetBytes(sig)); } ``` ::: ## Delivery guarantees - The order and its webhook are written in **one transaction**: a paid order is never left without a delivery. - Retries with exponential backoff (1 s, capped at 1 h) for **72 hours**. Only `2xx` counts as delivered, and redirects are not followed. - Your endpoint can be down and come back: deliveries resume, and your `order_id` dedupe makes them land exactly once in your game. - Studio → **Integration → Health** shows the last acknowledgement, failures in the last 24 h, and dead letters. Source: https://appside.build/docs/deep-links/ --- title: Return to the game nav: Deep links order: 5 --- # Return to the game After checkout, Appside sends the player back to your app through your **return URL**. The SDK turns that URL into a `PurchaseResult` you can show in the UI. > The return is a **UI hint**. Grant items only from the [webhook](/docs/webhooks/) (or `getOrder` on your server). A player can close the browser before returning, and the webhook still arrives. ## Choose a return URL - **Universal Link (iOS) / App Link (Android), recommended:** `https://yourgame.com/appside/return`. It works even when the app isn't installed. Studio → Integration generates the `apple-app-site-association` and `assetlinks.json` entries to host on your domain. - **Custom scheme:** `orbitquest://appside`. Simplest to set up, but other apps could register the same scheme. ## Handle it - iOS: `Appside.handle(url)` in `scene(_:openURLContexts:)` or SwiftUI `.onOpenURL`. - Android: `Appside.handle(intent.data)` in the activity with the matching ``. - Unity: the SDK registers the handler. Subscribe to `Appside.OnPurchaseCompleted`. - Web: the store returns to `returnURL` with `?appside_order=…`, and `Appside.handle(location.href)` reads it. ## Result fields `status` (`completed` · `canceled` · `failed`), `orderId`, `sku`, `credits`, `error` (for failures). Source: https://appside.build/docs/testing/ --- title: Testing nav: Testing order: 6 --- # Testing Everything runs in **test mode**: no real money moves and your backend must keep test orders separate from production entitlements. ## Test cards | Card | Result | |---|---| | `4242 4242 4242 4242` | Approved | | `4000 0000 0000 0002` | Declined: balance unchanged | | `4000 0027 6000 3184` | 3-D Secure challenge, then approved after **Authenticate** | Any future expiry and any 3-digit CVC. Real card numbers are rejected on the client and the server, and full card numbers are never stored. **Apple Pay / Google Pay:** simulated buttons in test mode. ## Checks worth automating 1. A purchase credits your player once (watch your `order_id` dedupe). 2. A declined card leaves the balance unchanged. 3. A replayed player token is refused. 4. **Outage:** stop your webhook endpoint, buy, start it again. The coins arrive after it comes back, exactly once. 5. **Refund** from Studio → Orders: your backend gets `order.refunded`, so revoke (never below zero). Studio → Integration → **Send test event** sends a `probe` webhook that grants nothing. Source: https://appside.build/docs/sdks/ --- title: SDKs nav: SDKs order: 7 --- # SDKs and their status The same methods and events on every platform: `configure`, `openStore`, `purchase(sku)`, `handle(url)`, `onPurchaseCompleted`, `onPurchaseCanceled`, `onPurchaseFailed`, `onStoreClosed`. | SDK | Location | Verified on 2026-10-24 (updated after the toolchain install) | |---|---|---| | Web / JS | `sdks/web` (`appside/web`) | **Unit tests + real-browser journey** (Orbit Quest uses it: openStore, purchase, return, 40/40 E2E) | | Node server | `sdks/node` (`appside/node`) | Unit tests; `getOrder` matched the signed webhook in the browser E2E | | Python server | `sdks/python` | Unit test (ES256 signature, webhook tamper/age rejection, getOrder) | | iOS (Swift Package) | `sdks/ios` | Core compiled with `swiftc` and smoke-tested (launch, purchase, result, replay and live rejection). **Not yet**: `swift test` / iOS UI build (needs Xcode) | | React Native | `sdks/react-native` | JS bridge tests. Native host: source only | | Android (Kotlin) | `sdks/android` | **Builds with Gradle (JDK 17, Android SDK 35)**, 12 JUnit tests pass, release AAR produced. Not yet: on-device integration test | | Unity (UPM) | `sdks/unity` | **Source only, not compiled** (no Unity yet) | | Flutter | `sdks/flutter` | **`flutter analyze` clean, 12 tests pass** (Flutter 3.47). Not yet: native host build on a device | `PurchaseResult` is `{ status, orderId, state, verified: false }` everywhere: a UI hint. Grant items only from the [webhook](/docs/webhooks/) or `getOrder`. This table is updated from each SDK's own tests on every release. "Preview source" means the code is written but hasn't been compiled and tested on that platform yet. Source: https://appside.build/docs/migrate-from-appcharge/ --- title: Coming from Appcharge nav: From Appcharge order: 8 --- # Coming from Appcharge Payment Links If you already integrated Appcharge Payment Links, most of what you built maps directly, and several steps disappear. | Appcharge | Appside | |---|---| | Server calls **Create Checkout Session**, passes `parsedUrl` + `purchaseId` to the client exactly as received | Not needed. Your server signs a **player token**, and the client calls `purchase(sku, token)` | | Separate web-store login: **Authenticate Player Callback**, SSO / Player-ID / OTP deeplink / Game Redirect | The same player token logs the player into the store. No callback to your server | | SDK callbacks `onPurchaseSuccess(OrderResponseModel)` / `onPurchaseFailed` / `onPurchaseCanceled` | `onPurchaseCompleted(order)` / `onPurchaseFailed` / `onPurchaseCanceled` / `onStoreClosed` | | **Grant Award Callback** to your server | `order.paid` webhook, signed, retried 72 h, idempotency key | | `signature: t=…,v1=…` HMAC-SHA256 over `t.payload` | `x-appside-timestamp` + `x-appside-signature`, HMAC-SHA256 over `ts.rawBody` | | Validate `purchaseId` on your server | Optional `getOrder(orderId)` with your server key. The webhook is already the source of truth | | Universal/Deep link setup | The same, and Studio generates the AASA / assetlinks files | | iOS, Android, Unity SDKs | The same plus Web, React Native, Flutter, and server SDKs (see [status](/docs/sdks/)) | Migration guide in short: keep your item-granting code, point it at our webhook, replace the checkout-session call with a player-token endpoint, and swap the SDK call. Source: https://appside.build/docs/mcp/ --- title: Appside MCP server nav: MCP order: 9 --- # Appside MCP: game web store docs for your coding agent Appside's public MCP server lets compatible coding agents search developer documentation, retrieve SDK examples and read published pricing. Its three tools are read-only: they do not create stores, publish sites or process payments. ## Connect your coding agent Use **https://appside.build/mcp** with a client that supports remote Streamable HTTP. No account, API key or payment credentials are required. For clients supporting this URL configuration: ```json {"mcpServers":{"appside":{"url":"https://appside.build/mcp"}}} ``` Configuration fields vary by client. Choose its remote HTTP connector, rather than a local executable or SSE-only connection. See the [MCP remote connection guide](https://modelcontextprotocol.io/docs/develop/connect-remote-servers). ## Available tools | Tool | Arguments | Result | |---|---|---| | `search_docs` | `query`: 1–200 characters | Up to five documentation excerpts with source links | | `get_sdk_snippet` | `platform`: one of the names below | SDK README examples and documented verification status | | `get_pricing` | An empty object | Published fee, fee basis and source URL | Supported platform names: `web`, `ios`, `android`, `unity`, `react-native`, `flutter`, `node`, `python`. Available examples do not mean every platform has been tested on a device: check the returned status and [SDK verification details](/docs/sdks/). The server also exposes **https://appside.build/llms.txt** as a read-only MCP resource. For clients without MCP, use [the documentation index](/llms.txt), [full developer text](/llms-full.txt) or this page's **View as Markdown** link. ## Try a useful integration prompt Ask your agent: “Search Appside's docs for signed webhooks, get the Node SDK example, and explain how my backend grants each order only once. Include source links and identify any test-mode limitations.” Expected sequence: `search_docs` with `{"query":"signed webhooks"}`, followed by `get_sdk_snippet` with `{"platform":"node"}`. Read the linked [webhook guide](/docs/webhooks/) before integrating. A return-to-game link or client callback is a UI hint; grant items only after verified backend delivery. ## Verify the connection The endpoint speaks protocol **2025-03-26**. This initialization request can be run without credentials: ```sh curl https://appside.build/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"docs-check","version":"1.0.0"}}}' ``` A public endpoint check on **2026-10-24 UTC** returned server name `appside-mcp`, version `0.1.0`, all three tools above, and a successful `get_pricing` response. You can reproduce the pricing call: ```sh curl https://appside.build/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_pricing","arguments":{}}}' ``` The text content contains `feePercent: 3.9`, `basis: "per successful payment"`, and zero setup, subscription and minimum fees, with a source link. This is published pricing information, not a payment-processing tool or confirmation that a merchant is approved for live payments. See [testing and payment status](/docs/testing/). ## Limits and troubleshooting - Stateless JSON POST responses; notification POSTs return an empty 202. GET returns 405: there is no optional SSE stream, session state or server-initiated messaging. - Set both headers shown above. Missing JSON content type returns 415; missing accepted response types returns 406. - Limits: 64 KiB messages, 20-message batches, 10s body deadline, 60 requests/minute per socket IP and 600/minute globally. Behind the edge proxy, clients may share a socket bucket. - A 429 response includes `Retry-After: 60`. Wait before retrying instead of polling repeatedly. - There is no cross-origin browser CORS access. If an Origin header is present, it must match an allowed HTTPS site origin. Connect from your MCP client's server-side transport. ## Discovery and next steps Find the public [Appside Developer Docs listing on Glama](https://glama.ai/mcp/connectors/build.appside/appside-developer-docs). The listing is live; the ownership claim is pending. The official MCP registry entry and npm package are not published, so use the remote endpoint above. Start with the [first-purchase quickstart](/docs/quickstart/), connect [player identity](/docs/player-tokens/), then implement [signed fulfillment](/docs/webhooks/). Publishers with an Appside account create and manage the store in the [Appside builder](https://app.appside.build/build); to see the app-to-store journey first, open the [live demo](https://demo.appside.build/demo).