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

1. Player clicks "Buy Currency" in your game
2. Game client tells your backend to mint a session
3. Your backend POSTs /api/checkout/sessions with X-Game-Secret-Key
4. Backend returns a signed checkout_url to the client
5. Client opens checkout_url in iframe / WebView
6. Player completes payment on the hosted page
7. Server-to-server webhook fires — your backend updates state
8. (Optional UX) postMessage hint refreshes UI immediately ✅

SAQ-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-Key header 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.