Steam Payments

If your game is on Steam, players can top up your branded in-game currency without ever leaving the Steam client. They pay with their Steam Wallet (or any payment method already linked to their Steam account) and approve the charge with Steam itself: in Steam's in-game purchase overlay, or (where the overlay is not available) on Steam's own hosted checkout page in the player's browser. Either way the payment happens in the storefront the player already trusts, and INVO credits their balance the instant the charge is captured.

Server SDKs cover this whole flow. @invonetwork/web-sdk ≥ 3.5.0 (steamPacks / steamInitPurchase / steamFinalizePurchase) and invonetwork for Python ≥ 3.5.0 (steam_packs / steam_init_purchase / steam_finalize_purchase) implement every request on this page, including browser checkout, the idempotency rules, and error helpers for the poller taxonomy below. The raw REST shapes remain documented here for everyone else.

Steam-side setup you do once, per title

Steam pays whoever owns the Steam application, so your title sells currency through your own Steam app and the money lands in your Steamworks account. You register that app with INVO once; INVO issues the player's currency the moment Steam captures the charge, then settles with you separately against a payment method you keep on file.

This changed in September 2026. Earlier versions of this page said currency purchases ran through a single INVO-owned Steam application and there was nothing to register on your side. That is no longer how it works, and following the old instructions will leave your rail closed.

You need
a Steamworks partner account with in-game purchases enabled for the app
You need
a publisher Web API key whose publisher group includes that app
You need
a payment method on file in the INVO dashboard

Why INVO needs a payment method on file

On every other rail, the player pays INVO and INVO issues the currency out of money it is holding. On Steam it is the other way round: Steam pays you, and INVO issues the currency anyway. It does so immediately, so the player gets what they bought. INVO is then owed the money behind it.

So INVO settles with you afterwards. Charges are batched, not per transaction, and every one of them is itemised in your dashboard against the purchases that produced it.

A $100 purchase in your game

Player pays Steam$100.00
Steam keeps its 30%$30.00
Steam pays you$70.00
INVO issues to the player700 units
INVO settles with you for$70.00 + processing

You are passing through money you received on INVO's behalf. It is not a commission or a platform fee. Your own revenue share on what players spend is unchanged. A bank account costs materially less to settle than a card and is worth using if you can.

Setting it up

  1. In Steamworks: make sure your app is on your partner account and in-game purchases (microtransactions) are enabled for it.
  2. In Steamworks: create a publisher Web API key, a publisher-group key, not a personal user key. The group must include the app. A personal key will fail verification.
  3. In the INVO dashboard: accept the billing authorisation and add a payment method.
  4. In the INVO dashboard: under the title's Steam settings, enter the app id and the publisher Web API key. INVO calls Steam to confirm the key controls that app before saving it, so you find out immediately if the pair is wrong.
  5. Ask INVO to enable the Steam rail for the title.

Until every step is done, purchases are refused before the player is charged, with STEAM_NOT_CONFIGURED or PARTNER_BILLING_NOT_SET_UP. That is deliberate: the alternative is taking a player's money for currency that cannot be issued.

How it works

Steam splits a micro-transaction into two phases. First the player authorizes the charge in the Steam client. Then the merchant captures it. INVO drives both phases and credits the player's branded currency the moment the charge is captured, the same credit-on-capture model as card purchases.

Three parties are involved: your game (client + backend), INVO, and Steam. Your game backend makes two server-to-server calls to INVO; your game client handles one Steam SDK callback in between. The handshake:

1

Initialize

Your backend calls /steam/init-purchase. INVO creates the order and opens a transaction on Steam. Steam then shows the purchase overlay to the player.

2

Authorize

The player approves the charge in Steam. Your game client receives the MicroTxnAuthorizationResponse_t Steam SDK callback.

3

Finalize

Your backend calls /steam/finalize-purchase. INVO verifies the authorization with Steam, captures the charge, and credits the player.

Why two server calls? Your X-Game-Secret-Key authenticates the INVO API and must never ship inside a game client. So the game client never calls INVO directly for payments. It calls your backend, and your backend calls INVO. The only thing the client touches is the Steam SDK callback.

Approving transfers & sends on Steam? That is a separate flow from purchases. A native Steam client can't run an in-client passkey (even though the OS supports it, the embedded client can't invoke the platform authenticator), so for transfer / send step-up use the QR device-approval flow: the player scans a code and approves on their phone. See Device Approval (Consoles & TVs).

Step 1: Initialize the purchase (backend)

When the player picks a currency pack, your backend calls INVO. You pass the player's 64-bit steamid (your game client already has it from the Steamworks SDK and forwards it to your backend) and the pack_id the player chose. Never an amount: INVO owns the price.

POST https://invo.network/api/currency-purchases/steam/init-purchase
Headers:
  X-Game-Secret-Key: ivsdk_<your_sdk_key>
  Content-Type: application/json

Body:
{
  "player_email":       "player@example.com",
  "steamid":            "76561198000000000",
  "pack_id":            "steam_medium",
  "purchase_reference": "<uuid_v4>",
  "player_name":        "PlayerOne",
  "usersession":        "client",             // or "web"; see Browser checkout below
  "player_ip":          "203.0.113.7",        // REQUIRED when usersession is "web"
  "metadata":           { "my_player_id": "p_123" }   // echoed on the webhook
}

Response 200:
{
  "status":             "pending_authorization",
  "order_id":           "ord_a1b2c3...",
  "steam_transid":      "350000123456789",
  "steam_checkout_url": null,                 // set ONLY for usersession "web"
  "pack_id":            "steam_medium",
  "charged_usd":        "9.99",
  "currency_amount":    "69",
  "new_balance":        null
}
  • player_email (required): identifies the INVO player whose branded-currency balance is credited. A player row is created on first purchase if it doesn't exist.
  • steamid (required): the 64-bit Steam ID of the account paying. Steam shows the authorization overlay to this account. Must be numeric.
  • pack_id (required): which INVO pack the player is buying. You do not send a price. INVO sets Steam pricing for every title on the network; fetch the packs from GET /steam/packs and pass the pack_id back. An unknown id returns 400 UNKNOWN_STEAM_PACK with the valid ids.
  • purchase_reference (required): your idempotency key (UUID v4 recommended, max 255 chars). Replaying the same value returns the existing order (409, duplicate) instead of creating a second charge, so it is safe to retry on a network failure. Omitting it returns 400 MISSING_PURCHASE_REFERENCE; sending one longer than 255 chars returns 400 INVALID_PURCHASE_REFERENCE. The field client_request_id is accepted as an alias for backward compatibility.
  • player_name (optional): display name; falls back to the local-part of the email if omitted on first purchase.
  • usersession (optional, default client): how the player will authorize. client: Steam's in-game overlay; your client receives the Steamworks callback. web: Steam returns steam_checkout_url, its own hosted approval page, for platforms where the overlay does not render (some embedded browser builds). See Browser checkout.
  • player_ip (required when usersession is web): the player's IP address as your server sees it, never your server's own. Steam runs fraud and geolocation checks against it. Omitting it returns 400 MISSING_PLAYER_IP.
  • metadata (optional, JSON object ≤ 8 KB): echoed at data.metadata on the purchase.completed webhook. On the Steam rail INVO merges four context keys of its own into the echoed object (steam_wallet_currency, steam_country, steam_account_status, vat_rate_pct). Avoid those names in yours, and don't assume the object contains only your keys. This is the only correlation channel: purchase_reference is an idempotency key and is deliberately not in the webhook payload, so put your own player / order ids here if your webhook handler needs to attribute the credit.
  • charged_usd (response): what Steam bills the player. The same for every player; it is the pack's price.
  • currency_amount (response): the branded currency this player receives. This varies by country. See below.
  • order_id (response): keep this. Your game client passes it back to your backend for Step 3.

As soon as init-purchase succeeds, Steam presents its purchase dialog to the player inside the Steam overlay. You do not trigger any UI yourself.

INVO sets the packs, and what they contain varies by country

Steam packs are INVO's, not yours. You do not choose price points and you never send an amount. Fetch the catalogue, show it, and pass back a pack_id.

GET /steam/packs?steamid=76561198000000000
X-Game-Secret-Key: ivsdk_<your_sdk_key>     // server-side only; packs authenticates too

{
  "status": "ok",
  "country": "FR",
  "vat_rate_pct": "20.0",
  "packs": [
    { "pack_id": "steam_small",  "label": "Small",  "price_usd": "4.99",  "currency_amount": "29"  },
    { "pack_id": "steam_medium", "label": "Medium", "price_usd": "9.99",  "currency_amount": "58"  },
    { "pack_id": "steam_large",  "label": "Large",  "price_usd": "19.99", "currency_amount": "116" }
    // ...excerpt; the live catalogue has more packs; render what it returns
  ]
}

The price is the same everywhere. The currency inside is not. Steam prices are VAT-inclusive wherever VAT is collected, so more of a fixed price goes to tax in a high-VAT country and less is left to buy currency with. The same $9.99 pack yields 69 in the United States and 58 in France. That is expected, not a fault.

Always pass steamid when fetching the catalogue. Without it the amounts are quoted with no VAT deducted (the most any pack yields), so a store that omits it promises more currency than the purchase will deliver.

Do not cache one catalogue for all players, and do not hard-code amounts. Both the pack prices and the VAT rates are configuration and can change without an SDK release.

Step 2: The player authorizes (game client)

Steam delivers the player's decision to your game client through the Steamworks SDK callback MicroTxnAuthorizationResponse_t. The callback carries m_bAuthorized (1 if the player approved), m_unAppID, and m_ulOrderID. That last one is Steam's numeric order id, which the init response does not return, so don't plan to correlate on it: track your own pending purchase (the order_id you got from init) and finalize that. When the player approves, tell your backend to run Step 3. INVO never sees this callback; it is strictly between Steam and your game client.

Unity (C#, Steamworks.NET)

using Steamworks;
using UnityEngine;

public class InvoSteamPurchase : MonoBehaviour
{
    private Callback<MicroTxnAuthorizationResponse_t> _authCallback;
    private string _pendingOrderId;

    void Start()
    {
        // Register the Steam authorization callback once.
        _authCallback = Callback<MicroTxnAuthorizationResponse_t>.Create(OnSteamAuthorized);
    }

    // Called when the player taps "Buy" on a currency pack. The pack list
    // came from YOUR backend, which fetched GET /steam/packs for this player.
    public async void BuyCurrency(string packId)
    {
        ulong steamId = SteamUser.GetSteamID().m_SteamID;

        // Ask YOUR backend to start the purchase. Your backend calls INVO's
        // /steam/init-purchase with your X-Game-Secret-Key (never in the
        // client). You send a pack_id, never a price; INVO owns the price.
        var result = await MyGameBackend.StartSteamPurchase(packId, steamId);
        _pendingOrderId = result.OrderId;   // INVO's "ORD_..." id

        // Steam now shows the purchase overlay to the player automatically.
    }

    // Steam fires this when the player approves or declines.
    private void OnSteamAuthorized(MicroTxnAuthorizationResponse_t cb)
    {
        if (cb.m_bAuthorized == 1)
        {
            // Approved; ask your backend to finalize (it calls INVO).
            MyGameBackend.FinalizeSteamPurchase(_pendingOrderId);
        }
        else
        {
            // Declined or cancelled; nothing was charged.
        }
    }

    void OnDestroy() => _authCallback?.Dispose();
}

Unreal Engine (C++, Steamworks SDK)

#include "steam/steam_api.h"

class FInvoSteamPurchase
{
public:
    // Called when the player taps "Buy" on a currency pack.
    void BuyCurrency(const FString& PackId)
    {
        const uint64 SteamId = SteamUser()->GetSteamID().ConvertToUint64();

        // Ask YOUR backend to start the purchase. Your backend calls INVO's
        // /steam/init-purchase with your X-Game-Secret-Key (never in the client).
        MyGameBackend->StartSteamPurchase(PackId, SteamId,
            [this](const FString& OrderId)
            {
                PendingOrderId = OrderId;   // INVO's "ORD_..." id
                // Steam shows the purchase overlay to the player automatically.
            });
    }

private:
    FString PendingOrderId;

    // Steam fires this when the player approves or declines.
    STEAM_CALLBACK(FInvoSteamPurchase, OnSteamAuthorized,
                   MicroTxnAuthorizationResponse_t);
};

void FInvoSteamPurchase::OnSteamAuthorized(MicroTxnAuthorizationResponse_t* cb)
{
    if (cb->m_bAuthorized)
    {
        // Approved; ask your backend to finalize (it calls INVO).
        MyGameBackend->FinalizeSteamPurchase(PendingOrderId);
    }
    // else: declined or cancelled; nothing was charged.
}

The callback only reports the player's intent. It is not proof of payment. INVO independently re-verifies the authorization with Steam in Step 3 before any money moves or any currency is credited.

Browser checkout: when the overlay cannot render

The default flow assumes Steam's in-game overlay can appear. On some builds it cannot (embedded-browser shells are the common case), and a purchase started there would simply never complete. For those platforms, pass "usersession": "web" (with player_ip) on init-purchase. Steam then returns steam_checkout_url: Steam's own hosted approval page, which you open in the player's system browser. The player approves there instead of in the overlay; everything else (finalize, credit, webhook) is identical.

  • Open it as a top-level tab or window, never an iframe. The page cannot be framed; an embedded view fails in ways that look like a Steam outage.
  • player_ip must be the player's address. Sending your server's IP geolocates every purchase to your datacenter: wrong fraud signals, wrong tax country.
  • There is no return-URL callback. Completion is signalled the same ways as the overlay flow: your client's Steamworks callback where the Steam client is running, or your own finalize polling. A finalize answering not_authorized with Steam still reporting Init just means the player has not approved yet.

Sandbox limitation, Steam-side: Steam's sandbox issues browser-checkout URLs that its production checkout host will not serve, so the web approval click itself can only be exercised against production. Everything up to it (init, the returned URL, the order lifecycle) works in sandbox, and everything after approval is the same code path the overlay flow exercises, which sandbox tests fully. Build with web, test the loop with client, and treat the first production web approval as a go-live checklist item.

Step 3: Finalize the purchase (backend)

After your client reports an authorized callback, your backend calls INVO with the order_id from Step 1. INVO verifies the authorization with Steam server-side, captures the charge, and credits the player's branded currency in one atomic step.

POST https://invo.network/api/currency-purchases/steam/finalize-purchase
Headers:
  X-Game-Secret-Key: ivsdk_<your_sdk_key>
  Content-Type: application/json

Body:
{ "order_id": "ord_a1b2c3..." }

Response 200:
{
  "status":            "success",
  "transaction_id":    "txn_...",
  "order_id":          "ord_a1b2c3...",
  "new_balance":       "100",
  "already_processed": false,
  "credited":          true
}

finalize-purchase is idempotent. A retry, or a duplicate call, returns already_processed: true and never credits twice. If the player has not finished authorizing yet, it returns 409 with status: "not_authorized". Wait for the Steam callback and call again.

credited and reason_code (additive). status: "success" means Steam captured the payment. It is unchanged, and on its own it does not prove the currency was added. The response also carries:

  • • credited: true: the currency is in the player's balance (added by this call or an earlier one).
  • • credited: false with a CREDIT_REFUSED_* reason_code: the money was taken and no currency was added. INVO's team is alerted. Do not ask the player to pay again.
  • • credited: false with reason_code REFUNDED or CHARGEBACK_LOST: the payment was returned to the player and any unspent currency it added was taken back.
  • • credited: false with reason_code: null: the credit is still being finished. Poll GET /api/currency-purchases/order-details until the order is completed.

Show "purchase complete" only when credited is true. If the field is absent, fall back to the order's status.

Responses & error handling

HTTPstatusWhat it means / what to do
200successPayment captured. When credited is true the currency is in: read new_balance. When it is false, see above.
200pending_authorizationinit-purchase succeeded; the player must now authorize in Steam.
409not_authorizedRead steam_status before deciding. Init: the player has not approved yet. This is the normal wait state; retry after the callback or on your poll interval. Any other status (Cancelled, Failed, Refunded, Chargedback, anything that is not Init) is a settled outcome: stop polling; no later call can succeed. A poller that treats every 409 as "not yet" polls a dead order forever.
409error (error_code: STEAM_APP_CHANGED)The title's Steam registration changed between init and finalize. The body's status is error; branch on error_code. Terminal for this order; contact INVO if it recurs.
409duplicateThis purchase_reference already has an order. Use the returned order_id.
400"Order is not finalizable"The order already settled its fate: failed, expired or refunded (INVO's reconciler marks abandoned orders failed on its own). Terminal: stop polling. This is what a poller sees after an order dies while it was waiting.
404"Steam order not found"No such order for this game: a wrong or foreign order_id. Terminal; check your correlation.
502steam_errorSteam rejected or could not process the call. Safe to retry.
503service_unavailableSteam is temporarily unreachable (circuit breaker open). Retry shortly. Distinct: a 503 whose message is "Steam purchases are not enabled" means the rail is switched off. Retrying won't change it; ask INVO.

Refusals before any charge

These fire on init-purchase, deliberately before the player pays. The alternative is taking money for currency that cannot be issued. None of them are the player's fault, and only some are yours:

error_codeHTTPMeaning / what to do
PARTNER_CREDIT_UNAVAILABLE409The studio's settlement balance cannot cover this purchase right now. Fixed by a top-up in the INVO dashboard, not by the player. Show a plain "try again later"; do not hot-poll init. Headroom changes on a human action (a top-up, a settlement clearing), not on a clock. A manual retry must reuse the same purchase_reference.
PARTNER_RAIL_SUSPENDED409Settlement is suspended (repeated failed collections, or an administrative hold). Terminal from the game's side until resolved in the INVO dashboard.
PARTNER_BILLING_NOT_SET_UP409The studio's billing setup (authorisation, payment method) is incomplete.
STEAM_NOT_CONFIGURED503No verified Steam app registration for this title yet.
STEAM_RAIL_NOT_ENTITLED403The Steam rail is not enabled for this title. Ask INVO.
UNKNOWN_STEAM_PACK400The pack_id is not in the catalogue; the body lists valid_pack_ids. Re-fetch /steam/packs. The catalogue changes without a release on your side.
MISSING_PLAYER_IP400A web session without player_ip. Always an integration bug.
STEAM_ACCOUNT_LOCKED409Steam reports the paying account locked; it cannot complete a purchase.
STEAM_PACK_UNAVAILABLE409This pack yields zero currency for the player's region after VAT. Offer a different pack.
INVALID_USERSESSION / INVALID_METADATA400Malformed request field: integration bugs.
spending_limit_exceeded429The player hit a spending limit. Not retryable now; the message says when.

Even if your backend never gets to call finalize-purchase (a crash, a dropped connection), INVO's reconciliation poller independently detects the authorized Steam transaction and credits the player. A missed finalize call delays the credit; it never loses it.

Refunds

Steam refunds are reconciled automatically. You do not handle them. When a Steam purchase is refunded, INVO debits (claws back) the branded currency it originally credited, exactly as it does for a card refund, and emits the purchase.refunded webhook to your tenant.

A refund takes back only the coins the player still has from that purchase; coins already spent are not taken. A Steam chargeback takes back up to the purchase's coins from the player's available balance. Neither takes a balance below zero.

  • • Refunds arrive as purchase.refunded with rail: "steam" and steam_transid. A partial refund carries fully_refunded: false and usd_refunded, the total refunded on that purchase so far.
  • • Chargebacks arrive as purchase.disputed, not purchase.refunded, with the same keys as a lost card dispute: dispute_status: "lost", currency_debited, new_balance, and payment_intent_id, charge_id and dispute_id set to null, plus rail: "steam" and steam_transid.
  • • If you revoke access or items on purchase.refunded, handle purchase.disputed the same way. A Steam chargeback no longer arrives as purchase.refunded.

Sandbox testing

In the INVO sandbox environment the Steam rail runs against Steam's ISteamMicroTxnSandbox interface, the identical init / authorize / finalize handshake against Steam's test system, with no real money charged. The request bodies and responses are exactly the same as production, and the endpoint paths are the same relative to the environment base (sandbox routes live under the /sandbox prefix), so the integration you build in sandbox ships unchanged. The one exception is the browser-checkout click. See the sandbox note under Browser checkout.

Run the full three-step flow there, including a declined authorization and a refund, before going live. See Sandbox Testing.

Good to know

Server-side only

Both /steam/* endpoints require your X-Game-Secret-Key and must be called from your backend. The game client only ever handles the Steam SDK callback.

Credit on capture

The player's branded currency is credited the instant finalize-purchase captures the charge, with no waiting on Steam's multi-day settlement cycle.

Reconciliation safety net

If your backend ever misses the finalize call, INVO's poller detects the authorized transaction and credits the player anyway. Refunds are caught the same way.

One balance, every rail

Steam purchases credit the same branded-currency balance as card purchases and cross-game transfers. To your players, the payment rail is invisible.

Idempotent by design

purchase_reference on init and the order_id on finalize make every call safe to retry: no double charges, no double credits.

Authorization re-verified

A client callback can be spoofed; INVO doesn't trust it. Every finalize independently re-checks the authorization with Steam before capturing.