Game Developer Integration Guide
Currency Purchase Integration
End-to-end guide for adding INVO Network real-money top-ups to your game. The recommended pattern is signed checkout sessions: your backend mints a short-lived URL, you open it in an iframe or WebView, and the player completes the purchase on a hosted page. Card data never touches your servers.
Don't put your SDK key in a URL
Your SDK key is server-side credentials. URL parameters end up in browser history, server access logs, referrer headers, and crash reports. Always mint a session on your backend and open the returned checkout_url.
The checkout session is a short-lived (15 minutes), single-use signed token. Replaying it after success returns a 409 — there's no value to an attacker who intercepts it.
Overview
Flow
/api/checkout/sessions with X-Game-Secret-Keycheckout_url to the clientcheckout_url in iframe / WebViewSAQ-A scope
Card data stays on the INVO-hosted page. Your PCI scope is the lightest tier.
Two backend calls
Mint a session, listen for the webhook. That's it.
Mobile-friendly
Same URL works in WebView on iOS and Android.
Prerequisites
Before you start:
- Developer account. Sign up at console.invo.network (or dev.console.invo.network for sandbox).
- Game registered. Use the wizard in the developer console.
- SDK key.
ivsdk_<random>, stored in your server secrets manager. - Server endpoint your game can call to mint sessions on demand.
- Webhook receiver URL (recommended) — registered with Invo so we can deliver authoritative purchase events.
Server-side only. Your SDK key authorises every API call as your game. Never bundle it into a Unity/Unreal/mobile build or browser code.
Step 1 — Mint a session (backend)
When the player clicks Buy, your game tells your backend "this user wants to buy $X of currency." Your backend posts to INVO and returns the checkout_url to the client.
Node.js (Express) example
// Your backend route — called by the game client
app.post('/store/start-checkout', requireAuth, async (req, res) => {
const { usdAmount } = req.body;
const userEmail = req.user.email;
const r = await fetch('https://invo.network/api/checkout/sessions', {
method: 'POST',
headers: {
'X-Game-Secret-Key': process.env.INVO_SDK_KEY, // ivsdk_<random>
'Content-Type': 'application/json'
},
body: JSON.stringify({
player_email: userEmail,
usd_amount: String(usdAmount),
success_url: 'https://yourgame.com/store/success',
cancel_url: 'https://yourgame.com/store/cancel',
metadata: { user_id: req.user.id, source: 'web_store' }
})
});
if (!r.ok) {
return res.status(502).json({ error: 'checkout session failed' });
}
const { checkout_url, session_id, expires_at } = await r.json();
res.json({ checkout_url, session_id, expires_at });
});The response carries a 15-minute single-use signed URL. Hand it to the client to open in iframe or WebView.
Step 2 — Open the URL (web)
For browser games (HTML5 / WebGL), pop the returned URL into an iframe overlay.
Browser JavaScript
async function buyCurrency(usdAmount) {
// 1. Ask YOUR backend for a session
const r = await fetch('/store/start-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ usdAmount }),
credentials: 'include'
});
const { checkout_url } = await r.json();
// 2. Open it in an iframe overlay
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:99999;display:flex;align-items:center;justify-content:center';
const iframe = document.createElement('iframe');
iframe.src = checkout_url;
iframe.allow = 'payment';
iframe.style.cssText = 'width:90%;max-width:480px;height:90vh;max-height:720px;border:0;border-radius:16px;box-shadow:0 25px 50px -12px rgba(0,0,0,.5)';
overlay.appendChild(iframe);
document.body.appendChild(overlay);
// 3. Listen for the UX postMessage hint
window.addEventListener('message', (event) => {
if (event.data?.type === 'INVO_CHECKOUT_COMPLETE') {
const { status, currency_received, new_balance } = event.data.data;
overlay.remove();
if (status === 'success') {
// Optimistic UI refresh; the authoritative source is the webhook below
updateBalanceUI(new_balance);
} else if (status === 'cancelled') {
// Player cancelled — no charge
}
}
});
}postMessage is a UX hint, not authoritative. It's not signed. Use it to refresh the UI optimistically. For the ledger-of-record source, listen for the purchase.completed webhook (Step 4) or re-read the player's balance from the API.
UnityUnity Integration
WebGL build
For browser builds, drop the JS-side iframe code above into a .jslib plugin and call it from C# via SendMessage.
// Assets/Plugins/InvoCheckout.jslib
mergeInto(LibraryManager.library, {
OpenInvoCheckout: function(checkoutUrlPtr) {
var checkoutUrl = UTF8ToString(checkoutUrlPtr);
var overlay = document.createElement('div');
overlay.id = 'invo-checkout-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:99999;display:flex;align-items:center;justify-content:center';
var iframe = document.createElement('iframe');
iframe.src = checkoutUrl;
iframe.allow = 'payment';
iframe.style.cssText = 'width:90%;max-width:480px;height:90vh;max-height:720px;border:0;border-radius:16px';
overlay.appendChild(iframe);
document.body.appendChild(overlay);
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'INVO_CHECKOUT_COMPLETE') {
var data = event.data.data || {};
var el = document.getElementById('invo-checkout-overlay');
if (el) el.remove();
if (data.status === 'success') {
SendMessage('InvoPaymentManager', 'OnPurchaseSuccess', JSON.stringify(data));
} else {
SendMessage('InvoPaymentManager', 'OnPurchaseFailed', JSON.stringify(data));
}
}
});
}
});// InvoPaymentManager.cs
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Networking;
public class InvoPaymentManager : MonoBehaviour
{
[DllImport("__Internal")]
private static extern void OpenInvoCheckout(string checkoutUrl);
public async void BuyCurrency(decimal usdAmount)
{
// 1. Ask YOUR backend to mint a session
string sessionUrl = await RequestCheckoutFromBackend(usdAmount);
if (string.IsNullOrEmpty(sessionUrl)) return;
// 2. Open it via the JSLIB
OpenInvoCheckout(sessionUrl);
}
public void OnPurchaseSuccess(string json) {
var data = JsonUtility.FromJson<PurchaseResult>(json);
// Optimistic UI refresh; the authoritative source is your webhook handler
Debug.Log(quot;Got {data.currency_received} (new balance {data.new_balance})");
}
public void OnPurchaseFailed(string json) {
Debug.LogWarning("Checkout failed: " + json);
}
[System.Serializable] private class PurchaseResult {
public string status;
public string transaction_id;
public string currency_received;
public string new_balance;
}
// YOUR backend mints the session — never call /api/checkout/sessions from
// a Unity build directly, because that would require shipping the SDK key.
private async System.Threading.Tasks.Task<string> RequestCheckoutFromBackend(decimal usd) {
var req = new UnityWebRequest("https://yourgame.com/store/start-checkout", "POST");
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes("{"usdAmount":" + usd + "}"));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
await req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) return null;
var resp = JsonUtility.FromJson<SessionResponse>(req.downloadHandler.text);
return resp.checkout_url;
}
[System.Serializable] private class SessionResponse {
public string checkout_url;
public string session_id;
public string expires_at;
}
}Mobile builds (iOS / Android)
For Unity mobile, ask your backend for the checkout_url, then open it in UnityWebView or a platform-native WebView (see iOS / Android sections below). The flow is identical to web — the same hosted page handles everything.
UnrealUnreal Engine Integration
On the server side, your dedicated server (or backend service) calls POST /api/checkout/sessions. On the client, open the returned checkout_url via the platform's web browser widget. Below: a minimal C++ helper that asks your backend for a session.
// MyGameStore.cpp — runs on your dedicated server / authority
#include "Http.h"
#include "Json.h"
void UMyGameStore::RequestCheckoutSession(const FString& UserEmail, double UsdAmount,
TFunction<void(FString)> OnUrl)
{
TSharedPtr<FJsonObject> Body = MakeShareable(new FJsonObject);
Body->SetStringField("player_email", UserEmail);
Body->SetStringField("usd_amount", FString::Printf(TEXT("%.2f"), UsdAmount));
Body->SetStringField("success_url", "https://yourgame.com/store/success");
Body->SetStringField("cancel_url", "https://yourgame.com/store/cancel");
FString BodyStr;
auto Writer = TJsonWriterFactory<>::Create(&BodyStr);
FJsonSerializer::Serialize(Body.ToSharedRef(), Writer);
auto Request = FHttpModule::Get().CreateRequest();
Request->SetURL(TEXT("https://invo.network/api/checkout/sessions"));
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("X-Game-Secret-Key"), GetEnvSecretKey()); // server-side env var
Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Request->SetContentAsString(BodyStr);
Request->OnProcessRequestComplete().BindLambda(
[OnUrl](FHttpRequestPtr, FHttpResponsePtr Response, bool bOk) {
if (!bOk || !Response.IsValid()) { OnUrl(FString()); return; }
TSharedPtr<FJsonObject> Json;
auto Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString());
if (FJsonSerializer::Deserialize(Reader, Json)) {
OnUrl(Json->GetStringField("checkout_url"));
} else {
OnUrl(FString());
}
});
Request->ProcessRequest();
}Pass the resulting URL to your client over a trusted RPC. The client opens it in a WebBrowser widget; the player completes payment; your server-to-server webhook handler updates state authoritatively.
iOSiOS Integration (WKWebView)
Use a WKWebView for hosted checkout. Apple App Store policy: digital goods generally require In-App Purchase — review with your counsel before shipping a WebView checkout in an iOS App Store build.
// SwiftUI / UIKit
import WebKit
class CheckoutVC: UIViewController, WKScriptMessageHandler {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
// postMessage from the hosted page comes through this handler
config.userContentController.add(self, name: "INVO_CHECKOUT")
webView = WKWebView(frame: view.bounds, configuration: config)
view.addSubview(webView)
Task {
// Ask YOUR backend for a session URL
let url = try await fetchCheckoutUrlFromBackend()
self.webView.load(URLRequest(url: url))
}
}
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
// The hosted page does:
// window.webkit?.messageHandlers?.INVO_CHECKOUT?.postMessage({...})
guard let body = message.body as? [String: Any],
let type = body["type"] as? String,
type == "INVO_CHECKOUT_COMPLETE",
let data = body["data"] as? [String: Any] else { return }
if let status = data["status"] as? String, status == "success" {
// Optimistic UI refresh. Authoritative source: your webhook handler.
self.dismiss(animated: true)
}
}
}AndroidAndroid Integration (WebView)
Use a Chrome Custom Tabs flow or a hosted WebView. Like iOS, Google Play policy may require Play Billing for in-app digital goods — verify with your counsel.
// Kotlin
class CheckoutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val webView = WebView(this).apply {
settings.javaScriptEnabled = true
addJavascriptInterface(InvoBridge { result ->
runOnUiThread {
// result is the parsed JSON object posted by the hosted page
finish()
}
}, "InvoBridge")
}
setContentView(webView)
lifecycleScope.launch {
// Ask YOUR backend for the checkout URL
val url = fetchCheckoutUrlFromBackend()
webView.loadUrl(url)
}
}
}
// JS interface — the hosted page calls window.InvoBridge.onComplete(JSON.stringify(data))
class InvoBridge(private val onResult: (Map<String, Any?>) -> Unit) {
@JavascriptInterface
fun onComplete(json: String) {
val parsed = JSONObject(json)
// Forward to your activity / view-model
}
}Step 4 — Server-to-server webhook (authoritative)
Once the payment confirms, INVO posts a purchase.completed event to your registered webhook URL. This is the authoritative signal — even if the client closed the WebView, lost connectivity, or never sent the postMessage.
POST <your_webhook_url>
Headers:
X-Invo-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
X-Invo-Event-Id: <uuid>
Content-Type: application/json
Body:
{
"event_id": "evt_01HX...",
"event_type": "purchase.completed",
"created_at": "2024-12-09T12:34:56Z",
"tenant_id": "<your game_id>",
"data": {
"transaction_id": "txn_...",
"order_id": "ord_...",
"player_email": "player@example.com",
"identity_id": "f3a1b8c0d4e5...",
"usd_amount": "10.00",
"currency_amount": "100",
"currency_name": "Gold Coins",
"metadata": { "user_id": "u_42", "source": "web_store" }
}
}Verify the HMAC signature with your tenant's webhook signing secret (issued separately during onboarding). Dedupe on X-Invo-Event-Id. Return 2xx as quickly as possible — non-2xx responses trigger retries (30s, 2m, 10m, 1h, 6h, 24h, then dead).
// Node.js / Express webhook handler
import crypto from 'crypto';
const SIGNING_SECRET = process.env.INVO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
app.post('/webhooks/invo', express.raw({ type: 'application/json' }), (req, res) => {
const sigHeader = req.header('X-Invo-Signature') || '';
const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=', 2)));
if (!parts.t || !parts.v1) return res.status(400).end();
if (Math.abs(Math.floor(Date.now()/1000) - parseInt(parts.t, 10)) > TOLERANCE_SECONDS) {
return res.status(400).end();
}
const expected = crypto.createHmac('sha256', SIGNING_SECRET)
.update(`${parts.t}.`).update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) {
return res.status(400).end();
}
const eventId = req.header('X-Invo-Event-Id');
if (await alreadyProcessed(eventId)) {
return res.status(200).json({ received: true, duplicate: true });
}
const event = JSON.parse(req.body.toString('utf8'));
await handleEvent(event); // your business logic
res.status(200).json({ received: true });
});Troubleshooting
- 401 INVALID_GAME_SECRET — your
X-Game-Secret-Keyheader is missing, malformed, or doesn't match the environment (sandbox vs production). - 409 SESSION_ALREADY_CONSUMED — this session URL has already been consumed. Mint a new one.
- 410 SESSION_EXPIRED — the 15-minute TTL elapsed. Mint a new one.
- postMessage never arrives — check the iframe origin's CSP / sandbox attributes. The hosted page posts from
console.invo.network; verify your event listener is mounted before the iframe loads. - Webhook signature mismatch — you must HMAC over the raw request bytes, not a re-serialised JSON. Use a raw body parser.
- Webhook missing — verify your endpoint returns 2xx and is registered in the developer console. We retry on backoff (30s → 24h) before marking dead.