Currency Purchase

How a player buys your branded currency (coins) with real money, end to end: which call to make, what the player is charged, what gets credited, and what happens on a refund or a chargeback.

Minor accounts: currency top-ups are not gated by INVO's guardian-approval flow. The guardian's approval lives at the parent's payment-method level (their card or bank), not at the INVO API level. See Guardian Approval for the full scope.

Which call for what

You want toUseCard fee
Sell coins for card payment, with no payment UI of your own (recommended)Hosted checkout: POST /api/checkout/sessionsPaid by you, charged against your earnings. The player pays the listed price (plus tax where owed).
Sell coins for card payment where you already collect and tokenise the card yourself (SDK or server)Direct route: POST /api/currency-purchases/quote, then POST /api/currency-purchases/purchase-currencyPaid by you, charged against your earnings. The player pays the listed price (plus tax where owed).
Sell coins in a game distributed on SteamSteam purchasesNone. Steam charges the player.
Sell coins through regional game-store payment methodsHosted checkout with rail: "game" (the game storefront rail)None. The storefront charges the player.
Let a player spend coins they already hold on an itemItem PurchaseNo card involved. INVO takes 5% of the item price.
Sell an item on a platform, paid by card or from the user's balancePlatform CommerceNothing added to the buyer's bill. On a card sale INVO's card fee comes out of your proceeds.

Who is the seller? On the default Open tier, your business is the seller of the coins and is responsible for any sales tax, VAT or GST on them; INVO processes the payment and pays you out. On the Merchant of Record tier, which INVO approves per account, INVO is the seller and handles consumption tax. On Steam and the game storefront rail the storefront is the seller on both tiers. See Tiers.

The card fee

When a player buys coins by card, you pay the card fee, not the player. The player pays the price you list (plus tax where INVO collects it) and gets every coin you listed. The fee is charged against your earnings at the time of the purchase:

  • • Open: 3.5% of the listed price (before tax) plus $0.15.
  • • Merchant of Record: 5.0% of the listed price (before tax) plus $0.15 (see Tiers).
  • • Card issued outside the United States: an extra 1.5% of the listed price (before tax), on both tiers.

Each fee is worked out on the listed price and rounded to the nearest cent. Fraud screening is included in the fee on every card payment. The chargeback guarantee (fraud chargebacks) is included on Merchant of Record, and on Open-tier card payments made before October 21, 2026. Negotiated rates are available on request.

Price you listPlayer pays (before tax)Coins credited (10 per dollar)Your card fee, OpenYour card fee, Merchant of RecordExtra if the card is issued outside the US
$2.99$2.9929.90$0.25$0.30$0.04
$4.99$4.9949.90$0.32$0.40$0.07
$10.00$10.00100$0.50$0.65$0.15
$50.00$50.00500$1.90$2.65$0.75
$100.00$100.001,000$3.65$5.15$1.50
$200.00$200.002,000$7.15$10.15$3.00

Coins are always priced from the listed price at your currency's rate (10 coins per dollar unless INVO has set a different rate for your currency). On Merchant of Record, tax is worked out on the listed price and added to what the player pays where INVO is registered; the card fee never is. No tax is added on the Open tier.

Minimum card purchase: $2.99. From 2026-10-27, a card purchase of coins below $2.99 is refused before anything is charged, with 400 BELOW_CARD_MINIMUM (see the errors below). Until then it is accepted, and the response carries a min_purchase_warning so you can raise your smallest pack in time.

This applies to INVO card payments only: hosted checkout on the card rail and the direct route. Purchases through a third-party storefront or payment rail are unaffected: they keep their current terms, with no INVO card fee, no minimum card purchase and no chargeback guarantee.

What changed for an existing integration

  • • A purchase you do not acknowledge now credits the full coins. $10.00 credits 100.00 coins at the default rate; under the earlier pricing it credited 93.70, because the card fee was deducted from the coins. The card is still charged exactly the usd_amount you send.
  • • An older, fee-inclusive acknowledged total still works. It is accepted and the player is charged the lower, true total. Only a total below the true total is refused (QUOTE_STALE).
  • • AMOUNT_BELOW_FEE is no longer returned. Small purchases are governed by the minimum card purchase instead.

Hosted checkout (recommended)

Your backend mints a short-lived signed session URL. You open it in a WebView, a redirect or an iframe. The player pays on INVO's page (cards, Apple Pay, Google Pay, Link, 3-D Secure, saved cards, international billing addresses), INVO credits the coins, and your backend hears about it by webhook. Card data never crosses your servers. The Developer Integration Guide has platform-specific code for Unity, Unreal, web and mobile. The page shows the INVO logo by default, or your game's own logo once you upload one in the console.

1. Create a checkout session (server-side)

usd_amount is the price you list, and it is what the player pays (plus tax where INVO collects it). Do not add a fee: the card fee is charged to you, not the player.

POST https://invo.network/api/checkout/sessions
Headers:
  X-Game-Secret-Key: ivsdk_<your_sdk_key>
  Content-Type: application/json

Body:
{
  "player_email": "player@example.com",
  "usd_amount": "10.00",
  "rail": "platform",
  "success_url": "https://yourgame.com/store/success",
  "cancel_url":  "https://yourgame.com/store/cancel",
  "metadata": { "your_user_id": "u_42", "coin_pack": "starter" }
}

Response 201:
{
  "session_id":   "<jti>",
  "checkout_url": "https://invo.network/checkout?session=<jwt>",
  "expires_at":   "2026-09-23T12:49:56Z",
  "expires_in_seconds": 900
}

checkout_url carries a 15-minute signed token and is single-use: reusing it after a successful payment returns 409 SESSION_ALREADY_CONSUMED. usd_amount must be above 0 and within the card amount limits, and on the card rail at least the minimum card purchase of $2.99 from 2026-10-27 (400 BELOW_CARD_MINIMUM), and never below the $0.50 card floor at any date (the same code, with min_usd: "0.50" and no enforced_from; /complete and create-intent refuse it the same way). Until 2026-10-27 a smaller card session is created and the 201 also carries min_purchase_warning: { "code": "BELOW_CARD_MINIMUM", "min_usd": "2.99", "enforced_from": "2026-10-27", "message": "..." }. In sandbox the checkout_url host is sandbox.invo.network (the page is at /checkout, no /sandbox prefix); validate that origin in your iframe postMessage listener.

A checkout session sells game currency. metadata is opaque: INVO stores it and echoes it back on the webhooks, and never reads it as an item. The buyer is charged and receives coins. To sell an item, use Platform Commerce (POST /api/platform-commerce/purchase).

When metadata carries item_id or item_name, the 201 also carries a warnings array. The session is still created exactly as before.

"warnings": [{
  "code":    "ITEM_METADATA_ON_CURRENCY_SESSION",
  "message": "This checkout session sells game currency: the buyer receives coins, and metadata item_id is only echoed back. To sell an item, use POST /api/platform-commerce/purchase.",
  "fields":  ["item_id"]
}]

Treat an unknown warning code as informational.

Completion calls the hosted page makes. You do not need these; they are listed for integrations that call them directly.

  • • POST /api/checkout/sessions/complete (legacy) completes a coin session only. An item session answers 400 WRONG_PURCHASE_TYPE; it completes on the payment page. 503 CHECKOUT_STATE_UNAVAILABLE (retryable: true) means the session cannot be completed just now. Nothing was charged; retry in a moment.
  • • POST /api/checkout/sessions/confirm-3ds on an item sale also returns order_status, the order's state after the confirmation.

Payment rail (optional rail). Defaults to platform.

  • • platform: the card rail. Cards plus Apple Pay, Google Pay and Link, with international billing addresses. Paying with a wallet costs the player no more than paying by card. The player pays the listed price; the card fee is charged to you.
  • • game: the game storefront rail, with regional and game-store payment methods presented in-page. The storefront is the seller; no INVO card fee, card minimum or chargeback guarantee applies.
  • • steam: for Steam titles use the dedicated Steam purchase flow. A steam checkout session only renders an in-client hand-off message.

2. Open the URL

  • • WebView or full-page redirect (works everywhere): on success the page redirects to your success_url.
  • • Embedded <iframe>: set iframe.src = checkout_url (any https site may frame the page). When embedded, the page does not redirect your top window; listen for the INVO_CHECKOUT_COMPLETE postMessage. You can ask INVO to restrict framing to your own origins.
// Web (WebGL build / browser)
const iframe = document.createElement('iframe');
iframe.src = checkoutUrl;
iframe.style.cssText = 'width:480px; height:720px; border:0;';
document.body.appendChild(iframe);

// Mobile (Unity, Unreal, native): open checkout_url in your WebView.

What the player sees before paying: Subtotal (your price), Tax and Total, plus the coins they will receive. No processing fee is added. For a $10.00 pack on the Open tier that is $10.00 + $0.00 tax = $10.00, for 100 coins. Your card fee on it is $0.50 (plus $0.15 if the card was issued outside the US).

3. Listen for completion

Browser postMessage (a UX hint only)

window.addEventListener('message', (event) => {
  if (event.data?.type === 'INVO_CHECKOUT_COMPLETE') {
    const { status, new_balance, currency_name, transaction_id } = event.data.data;
    if (status === 'completed') {   // the success value is 'completed', not 'success'
      updatePlayerBalance(new_balance);
    }
  }
});

On success, data.status is "completed" (not "success"). postMessage is not signed. Use it to refresh the balance optimistically. For your records, rely on the webhook or re-read the balance from the API.

Server-to-server webhook (authoritative)

POST <your_webhook_url>
Headers:
  X-Invo-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
  X-Invo-Event-Id:  <uuid>            # unique per delivery (same on retries, new on a replay)
  X-Invo-Idempotency-Key: <stable id> # dedupe on THIS

Body (a $10.00 pack bought on hosted checkout):
{
  "event_id":   "evt_01HX...",
  "event_type": "purchase.completed",
  "created_at": "2026-09-23T12: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",       // on hosted checkout: the amount charged
    "currency_amount":  "100",         // coins credited, priced from the $10.00 list price
    "currency_name":    "Gold Coins",
    "charge_total_usd": "10.00",       // what the card was charged, tax included
    "subtotal_usd":     "10.00",       // that total less tax
    "partner_fee_usd":  "0.50",        // the card fee charged to you
    "international_card_fee_usd": "0.00",  // the extra 1.5% on a card issued outside the US
    "tax_usd":          "0.00",
    "tax_jurisdiction": null,
    "tax_rate_pct":     null,
    "tax_treatment":    "partner_responsible",   // Open tier: you are the seller
    "metadata":         { "your_user_id": "u_42", "coin_pack": "starter" }
  }
}

Grant nothing extra on this event: the coins are already in the player's balance. Reconcile a card statement against charge_total_usd. usd_amount keeps the meaning it has always had on each route (the charged amount on hosted checkout, the amount you sent on the direct route), so read charge_total_usd when you want the charge.partner_fee_usd is the card fee charged to you for this purchase and international_card_fee_usd the extra fee when the card was issued outside the US ("0.00" otherwise). Both are additive; read them rather than recomputing. If you need your list price back, put your pack id in metadata. Verify the HMAC-SHA256 signature with your webhook signing secret, dedupe on X-Invo-Idempotency-Key (not X-Invo-Event-Id, which changes on a replay), and reject timestamps older than 5 minutes.

tax_treatment reads "partner_responsible" on the Open tier: your business is the seller and INVO charged no tax, so tax_usd is 0.00 and tax_rate_pct is null. tax_jurisdiction carries the buyer's place when it is known and is null otherwise. On the Merchant of Record tier it says how INVO handled the tax (for example "collected"), and the event also carries receipt_number and receipt_url for the receipt INVO issued to the buyer. Those two keys are absent on every other sale. See Tiers.

4. Ask what happened to the purchase

A buyer must always be able to find out what happened to money they paid. Poll this read from the moment the payment step hands back control, and keep your modal open until it says the purchase is finished.

GET https://invo.network/api/checkout/sessions/<session_id>
Headers:
  Authorization: Bearer <the session JWT>

Response 200 (a $200.00 pack):
{
  "status":             "credited",
  "terminal":           true,
  "order_id":           "ORD_1758000000_ABCD1234",
  "session_id":         "<jti>",
  "coins_credited":     "2000.00",
  "transaction_id":     "TXN_...",
  "new_balance":        "12000.00",
  "reason_code":        null,
  "message":            null,
  "amount_charged_usd": "200.00",
  "listed_usd":         "200.00",
  "currency_name":      "Credits"
}

listed_usd is your price. amount_charged_usd is what the player is billed: the price plus any tax, which is zero on the Open tier. No card fee is added; the $7.15 card fee on this pack is charged to you. The 2,000 coins come from the listed price. Before any payment exists, amount_charged_usd is what this checkout will charge. On a Merchant of Record account, a credited read also carries receipt_number and receipt_url once INVO has issued the buyer's receipt.

The id in the path is not a credential; the session token is. Send it in the Authorization header so it never lands in an access log. X-Checkout-Session-Token and a JSON body { "token": "..." } are accepted as alternatives. In sandbox: https://sandbox.invo.network/sandbox/api/checkout/sessions/<session_id>. A token for a different session is 403 SESSION_MISMATCH; a card-saving token is 401 WRONG_SESSION_PURPOSE; a token that expired within the last 24 hours still answers, past that it is 410 SESSION_EXPIRED. Rate limit: 600 requests per minute per IP.

The six statuses

status is a closed set.

statusterminalWhat it means, and what to show
pendingnoNo payment seen yet. The buyer may still be on the form or in an authentication challenge. Keep the spinner.
paid_pending_creditnoA charge is open and the coins are not in the balance yet. Normally seconds. After about 60 seconds, show the order_id and a Contact support link, and keep polling.
creditedyesCharged and credited. Show coins_credited, currency_name and new_balance when it is not null.
refusedyesThis purchase will not credit. Show message verbatim, show order_id, and offer Contact support.
refundedyesMoney went back to the buyer. Show message verbatim and order_id.
expiredyesNever paid and can no longer be paid. Show message and offer a new checkout.
  • • Poll until terminal, every 1 to 2 seconds, and take terminal from the server rather than deriving it yourself.
  • • Treat an unknown status as non-terminal. Keep polling.
  • • Render message verbatim and never show a reason_code to a player; it is for your logs and support.
  • • A failed poll is not a failed purchase. Keep polling through network errors and 5xx. A stalled credit is recovered on our side, and an unpaid checkout turns expired only 30 minutes after its link lapses.

The reason codes

reason_codeWhat it tells support
PAYMENT_DECLINEDThe payment did not go through. Nothing was charged.
PAYMENT_CANCELLEDCancelled before it finished. Nothing was charged.
CREDIT_REFUSED_MINT_CEILINGThe coins on the order exceed what the charge justifies at the currency's rate, so the credit was withheld. The money was taken.
CREDIT_REFUSED_UNKNOWN_RAILThe order carries a payment rail the credit path does not know. The money was taken.
CREDIT_REFUSED_WRONG_ORDER_TYPEThe order is not a currency purchase and settles through its own handler.
REFUNDEDThe purchase was refunded and the coins it added were reversed.
CHARGEBACK_LOSTThe buyer's bank reversed the charge and the coins were reversed with it.

On PAYMENT_DECLINED and PAYMENT_CANCELLED nothing was charged, so offer Try again beside Contact support. On the others, do not: a second charge is the last thing the buyer needs. Treat an unrecognised code as a generic refusal and still render message.

Use your own logo on checkout

INVO's hosted checkout pages show the INVO logo at the top by default. You can replace it with your game's own logo, one logo per game, in the developer console under Game settings > Checkout branding. There is no API or SDK call for it and nothing in your integration changes: the same checkout_url and card_setup_url simply show your logo. Remove the logo in the console and the INVO logo comes back.

Where your logo appears

PageYour logo?Why
Hosted checkout (checkout_url): coin purchases, Platform Commerce item purchases, the Steam hand-off screen and the game storefront payment screenYesIt is your sale, and the buyer came from your game.
Hosted card page for subscriptions (card_setup_url)YesIt saves a card for your subscription.
Subscription payment confirmation (/subscription-auth)YesIt confirms a renewal of your subscription.
Approval and device approval, claim and collect, consent, identity, phone change and guardian pagesNo, always INVOOn these pages the player confirms something about their identity or their money with INVO. They always look the same, so a player learns what a genuine INVO approval looks like, and a copy that borrows a game's look stands out.
ReceiptsNo, always INVOOn a Merchant of Record account INVO is the seller, and the receipt is the record of who sold.

A small Secured by INVO line stays at the bottom of every checkout page, with or without your logo, because INVO processes the payment. Your logo's alt text is your game's name, and with a custom logo the checkout tab is titled Checkout · <your game name>. If the image cannot load, the page shows the INVO logo instead.

Image requirements

  • • PNG, JPEG or WebP, up to 1 MB (1,000,000 bytes). SVG and GIF are not accepted.
  • • Recommended: a transparent, wide PNG, about 4:1, for example 640 x 160 px.
  • • It is shown at up to 40 px tall and 200 px wide, scaled to fit and never cropped, so the payment form always stays on the first screen.
Sandbox and production. When a game is copied or linked between sandbox and production, its checkout logo is copied once with it, the same way the game icon is. After that the two are separate: changing or removing the logo in one environment does not change the other.

Direct route: quote, show, purchase

For integrations that collect and tokenise the card themselves (an SDK card form, or your own PCI-compliant collection) and send INVO a payment_method_id. This route charges the card in the same request, so there is no moment afterwards to show the player anything. That is why it has a separate quote call: on Merchant of Record the total includes tax, so the player should see it before paying.

1. Quote the total

Moves no money and creates nothing, so it is safe to call on every price render (300 requests per hour per player email). Same authentication as the purchase call.

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

Body:
{
  "player_email": "player@example.com",
  "usd_amount":   "10.00",     // your list price
  "country":      "US",        // optional: buyer's billing country, ISO 3166-1 alpha-2
  "subdivision":  "CA"         // optional: US state or Canadian province
}

Response 200:
{
  "total_usd":          "10.00",    // listed price + tax: show this, then send it back
  "amount_cents":       1000,
  "subtotal_usd":       "10.00",
  "processing_fee_usd": "0.00",     // the player pays no card fee
  "partner_fee_usd":    "0.50",     // the card fee you will be charged (Open, $10.00)
  "international_card_fee_usd": null,  // always null on a quote: no card is known yet
  "fee_taken_from":     "partner",
  "coins":              "100.00",
  "currency_name":      "Gold Coins",
  "breakdown": {
    "subtotal_usd": "10.00", "tax_usd": "0.00", "tax_rate_pct": null,
    "tax_jurisdiction": null, "tax_treatment": "partner_responsible",
    "tax_note": "No tax is charged on this purchase.",
    "processing_fee_usd": "0.00", "processing_fee_note": "",
    "total_usd": "10.00", "coins": "100.00", "currency_name": "Gold Coins"
  },
  "if_unacknowledged": {            // what happens if you skip step 3's acknowledgement
    "charge_total_usd":   "10.00",
    "processing_fee_usd": "0.00",
    "coins":              "100.00",
    "fee_taken_from":     "partner",
    "partner_fee_usd":    "0.50"
  }
}

total_usd is both the figure to display and the figure to send back. The breakdown gives you each line for your own price screen. processing_fee_usd reads "0.00": the player pays no card fee, and the card fee is charged to you, previewed as partner_fee_usd. international_card_fee_usd is always null on a quote, because the card is not known yet; the purchase response carries the figure. An amount of zero or above the card maximum is 400 INVALID_INPUT. Below the minimum card purchase, the quote carries min_purchase_warning until 2026-10-27 and is refused with 400 BELOW_CARD_MINIMUM from then on. The example is an Open-tier account, where tax_treatment is "partner_responsible" and no tax is added; on Merchant of Record tax on the listed price is added where INVO is registered, so total_usd can be higher than the price.

2. Show the player the total

Put total_usd on your confirm screen, with the tax as its own line where there is any ("$10.00 + $0.80 tax = $10.80"). This is the number the player agrees to. There is no processing fee line to show.

3. Purchase, acknowledging the total

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

Body:
{
  "player_email":           "player@example.com",
  "usd_amount":             "10.00",     // the same list price you quoted
  "acknowledged_total_usd": "10.00",     // total_usd from /quote
  "payment_method_id":      "<tokenised_card_id_from_your_card_collector>",
  "purchase_reference":     "<uuid_v4>", // REQUIRED, see Idempotency below
  "country":                "US",        // optional: the country you quoted with
  "subdivision":            "CA",        // optional: the state or province you quoted with
  "metadata":               { "your_user_id": "u_42" }
}

Response 200:
{
  "status":             "success",
  "transaction_id":     "txn_...",
  "order_id":           "ORD_...",
  "new_balance":        "100.00",
  "previous_balance":   "0.00",
  "currency_name":      "Gold Coins",
  "charge_total_usd":   "10.00",     // what the card was charged
  "processing_fee_usd": "0.00",      // the player paid no card fee
  "partner_fee_usd":    "0.50",      // the card fee charged to you
  "international_card_fee_usd": "0.00",
  "fee_taken_from":     "partner",   // the fee is charged to you, not the player
  "coins_credited":     "100.00",
  "exchange_rate":      "...",
  "purchase_details":   { "usd_charged": "10.00", "currency_received": "100.00", ... },
  ...
}

The card is charged the price (+ tax where applicable), never more than the total you acknowledged, and the player gets every coin you listed. Branch on status: requires_action is a normal 3-D Secure step, not a failure (see below).

Send the place you quoted with. country and subdivision are optional and take the same values you sent to /quote. On Merchant of Record, INVO uses them only when it cannot read the buyer's tax location from the card, so a buyer who was shown tax is charged it. Without them, such a purchase is refused with 409 TAX_PLACE_UNAVAILABLE and nothing is charged (see the errors below). On Open they change nothing.

Read the new fields for money. charge_total_usd is what the card was charged, coins_credited what the player received, partner_fee_usd the card fee charged to you, international_card_fee_usd the extra fee on a card issued outside the US ("0.00" otherwise, including when the card's country cannot be determined), and fee_taken_from says who paid it: "partner" on every card purchase of coins. partner_fee_usd and international_card_fee_usd are null only if the fee could not be booked when the purchase completed: the purchase itself succeeded, and INVO books the fee shortly after, so type both as nullable strings. They are absent on purchases through a third-party storefront or payment rail. processing_fee_usd reads "0.00", because the player pays no card fee. The earlier values of fee_taken_from ("charge", "coins") are no longer produced; treat an unknown value as informational. The older purchase_details.usd_charged keeps its original meaning, the usd_amount you sent, so it is not the card total when tax was added. On a Merchant of Record account the success response also carries receipt_number and receipt_url; on Open they are absent.

If you do not acknowledge a total, or acknowledge an older one

Leave out acknowledged_total_usd and the card is charged exactly the usd_amount you send, never more, and the player gets every coin you listed. No tax is collected on an unacknowledged purchase.

An integration built when the player paid the card fee may still send the older, higher total that included the fee. That is accepted: the player is charged the current, lower total, and never more than the figure you acknowledged. Only an acknowledged total below the total INVO would charge is refused (QUOTE_STALE). In every case the card fee is charged to you.

You send (Open tier)Card is chargedCoins creditedYour card feefee_taken_from
usd_amount: "10.00" + acknowledged_total_usd: "10.00"$10.00100.00$0.65partner
usd_amount: "10.00", no acknowledgement$10.00100.00$0.65partner
usd_amount: "10.00" + an older, fee-inclusive acknowledged_total_usd (higher than $10.00)$10.00100.00$0.65partner

Coins are shown at the default 10 per dollar. Add $0.15 to your card fee when the card was issued outside the US.

Errors specific to the total and the minimum

HTTPerror_codeMeaning and what to do
409QUOTE_STALEThe acknowledged_total_usd you sent is below the total INVO would charge. Nothing was charged. The body carries acknowledged_total_usd and expected_total_usd. Quote again, show the new total, and retry with a new purchase_reference (the failed order has used up the old one). Sending the bare price as the acknowledged total is stale only where tax is added (Merchant of Record), because the quoted total then includes the tax.
409TAX_PLACE_UNAVAILABLEMerchant of Record only. You acknowledged a total that includes tax, INVO could not read the buyer's tax location from the card on this attempt, and the request carried no country. Nothing was charged. The body carries retryable: true, acknowledged_total_usd and untaxed_total_usd. Retry (a new attempt usually reads the card), or send the country and subdivision you quoted with. On hosted checkout /complete the same body carries error in place of message.
400BELOW_CARD_MINIMUMThe amount is below a card floor. Nothing was charged. error_code, code and error_code_public all read BELOW_CARD_MINIMUM, and min_usd tells you which floor refused it. "2.99": from 2026-10-27, the price is below the minimum card purchase; the body also carries enforced_from, and before that date the purchase goes through with min_purchase_warning instead. "0.50": at any date, the charge is below the smallest amount a card charge can settle for; there is no enforced_from. The $0.50 floor is checked on this route, on hosted checkout session create, /complete and create-intent, and on Platform Commerce card sales. Raise the price to at least min_usd.
400INVALID_ACKNOWLEDGED_TOTALacknowledged_total_usd is not a decimal amount.

AMOUNT_BELOW_FEE is no longer returned: the card fee is not part of the player's charge, so no amount is too small to cover it. Keep handling it if your code already does; it is harmless.

Other responses from /purchase-currency

200, status:"success"                 purchase complete
200, status:"requires_action"         3-D Secure needed, see below
200, status:"success", duplicate:true replay of a completed purchase_reference
409, status:"pending", duplicate:true purchase already in flight for this purchase_reference
400, error_code:"MISSING_PURCHASE_REFERENCE"
400, error_code:"INVALID_PURCHASE_REFERENCE"   (over 255 characters)
400, error_code:"INVALID_SAVE_CARD"            (save_card is not a JSON boolean)
429, error:"spending_limit_exceeded"

Never treat a non-success response as a decline. Raw card fields are not accepted: tokenise the card client-side and send payment_method_id.

Idempotency: purchase_reference is required

  • • Generate it once per logical purchase attempt (when the player taps Buy), not per HTTP call. A UUID v4 is fine; at most 255 characters, unique per title.
  • • Resend the same value on every retry. If the network dropped mid-response, INVO returns the prior result (duplicate: true) instead of charging again.
  • • Generate a new one for a genuinely new purchase.
  • • client_request_id is accepted as an alias for existing integrations.

Reserved prefix: a value beginning with sub_ (any case) is rejected with 400 CLIENT_REQUEST_ID_RESERVED on every purchase endpoint. That key space belongs to subscription renewals. UUIDs are unaffected.

Handling 3-D Secure (status: "requires_action")

No money has been charged and nothing has failed. The cardholder has to complete a bank challenge.

Step 1. The response carries what you need:

{
  "status":            "requires_action",
  "client_secret":     "<opaque client secret>",
  "payment_intent_id": "<opaque payment id>",
  "order_id":          "ord_xxx",
  "new_balance":       null
}

Step 2. Run the challenge on the client with your card collector's SDK, passing client_secret. Use its confirm-payment call (the one that confirms and runs the challenge together), not a manual "handle next action" call: INVO uses the automatic-confirmation flow and the manual variant fails with a runtime error.

Step 3. Finalise server-side. This is where the coins are credited:

POST https://invo.network/api/currency-purchases/confirm-payment
Headers:
  X-Game-Secret-Key: ivsdk_<your_sdk_key>

Body:
{ "payment_intent_id": "<opaque payment id>", "order_id": "ord_xxx" }

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

confirm-payment is idempotent: a retry, or a late call after INVO has already credited the purchase, returns already_processed: true.

credited and reason_code (additive). status: "success" here means the payment succeeded. It is unchanged, and on its own it does not prove the coins were added. The response also carries:

  • • credited: true: the coins are in the player's balance (added by this call or an earlier one).
  • • credited: false with a CREDIT_REFUSED_* reason_code (for example CREDIT_REFUSED_MINT_CEILING): the money was taken and no coins were added. INVO's team is alerted. Do not ask the buyer to pay again.
  • • credited: false with reason_code REFUNDED or CHARGEBACK_LOST: the payment was returned to the buyer and any unspent coins it added were 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.

Tell the buyer the purchase is complete only when credited is true. If the field is absent, fall back to the order's status. Read credited rather than already_processed, which is also true whenever this call added nothing.

Refunds and chargebacks

Refunds of coin purchases

Only coins the player has not spent can be refunded. Spent coins are never refunded.

  • • If part of the purchase has been spent, a partial refund covers the unspent part only, in proportion.
  • • Coins that left the balance after the purchase (items, transfers, sends) count as spent first. Coins received later do not make a purchase refundable again.
  • • A refund never takes the balance below zero.
  • • Refunds of card coin purchases are issued by INVO. To request one for a player, contact INVO support with the order_id.
  • • A full refund returns the full amount charged to the card: you paid the card fee, so nothing is deducted from the player's refund (the card fee charged to you is not returned). Only purchases made before the 2026-09 pricing, or a refund INVO chooses to make net, keep the card processing cost back. The purchase.refunded webhook states both the gross and the net figure.
  • • The card fee charged to you is not returned on a refund.
  • • Steam and game storefront purchases are refunded by the storefront, on its side.

Chargebacks

A chargeback is forced by the cardholder's bank, so it is not the same as a refund.

  • • Coin purchase, chargeback lost: INVO takes back the coins the player still holds from that purchase. The player's balance never goes below zero. The value of any coins the player had already spent is charged to your earnings at the price the player paid per coin (before tax), reported as spent_coins_charged_usd. INVO's card fee is not returned.
  • • Item sold by card (Platform Commerce card leg): no coins move. Your share of that sale is reversed: if it has not been paid out yet it is cancelled, and if it has, it is deducted from your future payouts.
  • • Held while open: while a dispute you are responsible for is open, the disputed amount is held from your earnings so it cannot be paid out. On a coin purchase that is the price paid before tax, less anything already refunded, and never more than the disputed amount; on a Platform Commerce card sale, your share of the sale. Your withdrawable balance already excludes it (dispute_holds_usd on GET /api/withdrawals/available-balance). Won: the hold is released. Lost: the hold is released at the same moment the loss is booked, so the amount is taken only once.
  • • Chargeback guarantee (Merchant of Record; Open-tier card payments made before October 21, 2026): when the dispute is coded as fraud (reason fraudulent or unrecognized), INVO absorbs it: nothing is held while the dispute is open, your share is not taken back, the value of spent coins is not charged to you, and no dispute fee is charged to you. Card payments on INVO's hosted checkout only. A dispute that is not coded as fraud (for example product not received) is yours on both tiers.
  • • Dispute fee, both tiers: From October 21, 2026, on both the Open and Merchant of Record tiers, the card network's dispute fee is passed through to you at cost on every card dispute opened from that date, whatever the date of the sale. INVO adds no administrative fee. If the dispute is won and the network returns the fee, it is credited back to you. INVO's own card fees are not returned on a chargeback. No dispute fee is charged on a dispute covered by the chargeback guarantee. The closing purchase.disputed carries dispute_fee_usd (a string, or null) and dispute_fee_charged_to_partner (true, false or null). Card payments only. Purchases through a third-party storefront or payment rail are unaffected. See Tiers.
  • • You hear about each stage on the purchase.disputed webhook, with dispute_status on close.

Saving a card without a purchase

A player can save a card without buying anything. This captures the card and the cardholder's authorisation to charge it later without them present, and charges nothing. Subscriptions use that authorisation. Hosted checkout also saves cards for returning players, with no extra integration.

POST /api/currency-purchases/setup-intent

Starts a card capture. X-Game-Secret-Key auth. No money moves and no order is created.

FieldReq.Notes
player_emailyesThe player must already exist in your title; an unknown email returns 404 PLAYER_NOT_FOUND.
setup_referenceyesYour idempotency anchor, 1 to 200 characters of A-Z a-z 0-9 . _ : -. The same value returns the same setup. client_request_id is accepted as an alias.
payment_method_idnoA card your client already tokenised. Send it and we confirm server-side; omit it and you get a client_secret to confirm on the client. Card payment methods only (RAW_CARD_NOT_SUPPORTED otherwise).
// Done: the card is saved.
{
  "status":          "succeeded",
  "message":         "Card saved for future payments",
  "setup_intent_id": "<opaque id>",
  "card": { "id": 91, "last_four": "4242", "brand": "visa",
            "exp_month": 11, "exp_year": 2029,
            "created_at": "2026-08-13T10:22:00+00:00" },
  "already_saved": false
}

// Not done yet: confirm on the client, then call /setup-intent/confirm.
// status is "requires_action", "requires_confirmation" or "requires_payment_method".
{
  "status":          "requires_action",
  "client_secret":   "<confirm with this on the client>",
  "setup_intent_id": "<opaque id>",
  "publishable_key": "<client-side key>",
  "card":            null
}

Never treat a non-succeeded status as a failure. Saving a card for later charges is a stronger consent than a one-off charge, so banks challenge it more often, especially for UK and EU cardholders.

POST /api/currency-purchases/setup-intent/confirm

Records the card once the client has confirmed. Idempotent.

Body:     { "setup_intent_id": "<the id from /setup-intent>" }
Response: { "status": "success", "setup_intent_id": "...", "card": { "id": 91, ... }, "already_saved": true }

If the player has not finished the bank challenge you get 400 with status: "still_requires_action". A setup id that is not your title's is a 404.

GET /api/currency-purchases/player-cards?player_email=...

Lists a player's usable saved cards, newest first, with expired cards filtered out. An unknown player returns 200 with an empty list. Cards are referenced by the INVO card.id integer (pass it as player_card_id to subscriptions or /payment-method); no processor-side identifier is ever returned. Card ids are scoped to one player in one title.

save_card on a purchase

Send save_card: true with a newly tokenised payment_method_id on /purchase-currency and one call both buys coins and saves the card; the response reports card_saved: true.

save_card must be a real JSON boolean. The strings "true" and "false" are rejected with 400 INVALID_SAVE_CARD, because read loosely the string "false" is true and would store an authorisation the player never gave.

Error handling

Match on error_code_public

A few partner-facing error codes were originally named after a payment processor. Every affected response now carries both the original error_code, unchanged, and a provider-neutral error_code_public. Existing matches on error_code keep working; new integrations should match on error_code_public.

error_code_publicMeaning
PROCESSOR_UNAVAILABLEPayment processing rejected our credentials. Nothing was sent and no charge exists. 503.
PROCESSOR_ERRORA classified, terminal payment processing error. 502.
PROCESSOR_NOT_CONFIGUREDPayment processing is not configured for this operation or environment.
RAIL_DISABLEDThat payment rail is not enabled for your title. See Payment Methods.
RAIL_CHANNEL_MISMATCHYou sent a rail that disagrees with the storefront key you authenticated with. The key decides the rail; omit the field. 400.
STEAM_RAIL_NOT_ENTITLEDA Steam purchase for a title INVO has not enabled for Steam yet. Ask us. 403.
STEAM_PURCHASE_LAYER_REQUIREDCurrency for a title distributed on Steam must be bought through the Steam purchase flow. 409.
RAIL_NOT_AVAILABLE_TO_PLATFORMA platform tenant tried a game-scoped payment route. The card route is available to every tenant. See Partners. 403.

One case carries no error code. When INVO's circuit breaker is open, the response is 503 with { "status": "service_unavailable" }. Match it on status. No charge exists, so a fresh identifier is safe.

PAYMENT_OUTCOME_UNKNOWN: reuse the identifier

Occasionally INVO sends a charge and never learns what happened (a timeout, or an unreadable response). The card may have been charged. You get 503 or 502:

{
  "error":                     "We could not confirm whether this payment went through...",
  "error_code":                "PAYMENT_OUTCOME_UNKNOWN",
  "order_id":                  "ORD_...",
  "retry_with_same_reference": true,
  "poll_endpoint":             "/api/currency-purchases/order-details"
}

Retry with the SAME purchase_reference, or poll GET /api/currency-purchases/order-details. Never mint a new one: a new identifier is a new order, and if the first charge landed the retry is a second charge. Switch on the error code, not the HTTP status. Keep the player on "processing". A charge that did land is found and credited in about 15 minutes; one that did not fails once it ages out, up to 24 hours. Size your own timeouts for the 24-hour case.

Reading a replay

ResponseMeaningYour move
200 { "status": "success", "duplicate": true }It landed.Mark complete. Do not charge again.
409 { "status": "pending", "duplicate": true }Still in flight.Keep waiting.
409 { "status": "failed", "order_status": "..." }The identifier is spent and the order is terminal.Now, and only now, retry with a new identifier.

Limits

Spending limits. INVO enforces hourly, daily and monthly USD caps per player email and title. A purchase over a cap returns 429 with error: "spending_limit_exceeded" and a message naming the cap. Only successful purchases count, so a player whose card was declined can try again at once.

Card amount limits

Read the minimum and maximum from GET /api/checkout/validate-game, which returns them as min_amount and max_amount. They are the values the payment routes enforce.

The minimum card purchase is $2.99, enforced from 2026-10-27 with 400 BELOW_CARD_MINIMUM (a min_purchase_warning before then). It applies to INVO card payments only; purchases through a third-party storefront or payment rail are unaffected. On a game whose checkout runs on INVO's card rail, min_amount reads 2.99 from 2026-10-27; before that date it reads 0.50 USD, the smallest amount a card charge can settle for. On a game that sells through a third-party storefront or payment rail, min_amount stays that processing floor.

GET /api/checkout/validate-game

{
  "min_amount": 0.5,                 // 2.99 on a card-rail game from 2026-10-27
  "min_card_purchase": {             // null on a storefront-rail game
    "usd": "2.99",
    "enforced_from": "2026-10-27",
    "enforced": false                // true from the enforcement date
  },
  "max_amount": 100000.0,
  ...
}

min_card_purchase says what is coming before it is enforced, so you can raise small pack prices in time. GET /api/dev/fee-schedule also returns it as min_card_purchase. The maximum is a platform setting that can differ between sandbox and production and can change without a release, so do not hard-code it; it currently defaults to 100000.00 USD.

The maximum applies to the price you list (usd_amount), not the charge. No card fee is added to the charge, but tax is where INVO collects it, so a Merchant of Record purchase at the maximum can charge more than the maximum. An amount of zero or above the maximum is refused before anything is charged: on hosted checkout and /quote with 400 INVALID_INPUT, on /purchase-currency with a 400 whose message names the maximum in force.

Purchase webhook events

Every delivery carries X-Invo-Signature, X-Invo-Event-Id (unique per delivery) and X-Invo-Idempotency-Key (stable; dedupe on this). Retries back off at about 30s, 2m, 10m, 1h and 6h (6 attempts in all by default). See Receiving Webhooks.

EventWhen it firesBalance effect
purchase.completedPayment cleared on any rail and the coins were credited. On an INVO card purchase it carries partner_fee_usd and international_card_fee_usd.+credit
purchase.failedCard declined, 3-D Secure abandoned, payment cancelled, or an async payment failed.none
purchase.refundedA full or partial refund. Only unspent coins are debited, and never below zero. The order stays completed until the charge is fully refunded.debit of unspent coins
purchase.disputedA chargeback opened, with order_id, transaction_id, payment_intent_id, charge_id, dispute_id, reason and usd_amount. Fires again on close with dispute_status won or lost; on a coin purchase the close carries currency_debited, new_balance, spent_coins_charged_usd (the value of coins the player had already spent, charged to you at the price paid per coin before tax; "0.00" if none), dispute_fee_usd and dispute_fee_charged_to_partner. A Steam chargeback arrives once, as lost, with payment_intent_id, charge_id and dispute_id null and rail: "steam" and steam_transid added. A lost coin chargeback takes back the coins the player still holds and charges you for the coins already spent, unless the chargeback guarantee covers it. On a card dispute opened from October 21, 2026, on either tier, dispute_fee_usd (a string, or null) and dispute_fee_charged_to_partner (true, false or null) report the card network's dispute fee, passed through to you at cost.debit on lost of the coins the player still holds from the purchase, never below zero
purchase.fraud_warningThe card network flagged the charge as likely fraudulent. A chargeback is probable.none (advisory)

purchase.refunded, full refund of a $10.00 hosted checkout purchase

{
  "event_type": "purchase.refunded",
  "tenant_id":  "<your game_id>",
  "data": {
    "order_id":                    "ORD_...",
    "transaction_id":              "txn_...",
    "usd_original":                "10.00",
    "usd_refunded_gross":          "10.00",   // what the refund covers
    "usd_refunded_net":            "10.00",   // what went back to the card
    "processing_fee_retained_usd": "0.00",    // nothing kept back: you paid the card fee
    "usd_refunded":                "10.00",   // same as usd_refunded_net
    "fully_refunded":              true,
    "currency_debited":            "100",
    "new_balance":                 "0",
    "currency_name":               "Gold Coins",
    "order_status":                "refunded",
    "tax_refunded_usd":            "0.00",
    "tax_jurisdiction":            null
  }
}

On a Merchant of Record account the event also carries receipt_number and receipt_url for the refund receipt INVO issued, so you can link the refund to it. A refund still pending when the event is sent carries no number yet. Absent on Open.

purchase.disputed, closed and lost

{
  "event_type": "purchase.disputed",
  "data": {
    "order_id":         "ORD_...",
    "dispute_status":   "lost",
    "currency_debited": "60",    // the purchase credited 100; the player held 60
    "new_balance":      "0",
    "spent_coins_charged_usd": "4.00"  // the 40 spent coins at $0.10 each, charged to you
  }
}

Order status lifecycle

GET /api/currency-purchases/order-details returns an order's current state:

  • pending_payment: created, waiting on payment.
  • requires_action: 3-D Secure challenge pending.
  • completed: charged and credited.
  • failed: declined, refused before charging (for example QUOTE_STALE), or an async payment failed.
  • cancelled: 3-D Secure abandoned, the payment session timed out, or an admin cancelled.
  • refunded: fully refunded.
  • disputed_lost: chargeback lost; the coins the player still held from the purchase taken back, never below zero, and any spent coins charged to you.

These are internal order states. For anything a player sees, use the hosted checkout status read in step 4 above, which maps them onto six player-facing statuses.

Next: the Developer Integration Guide for platform-specific hosted checkout code, Tiers for who the seller is and the fees on each tier, and Sandbox Testing for test cards.