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 to | Use | Card fee |
|---|---|---|
| Sell coins for card payment, with no payment UI of your own (recommended) | Hosted checkout: POST /api/checkout/sessions | Paid 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-currency | Paid by you, charged against your earnings. The player pays the listed price (plus tax where owed). |
| Sell coins in a game distributed on Steam | Steam purchases | None. Steam charges the player. |
| Sell coins through regional game-store payment methods | Hosted checkout with rail: "game" (the game storefront rail) | None. The storefront charges the player. |
| Let a player spend coins they already hold on an item | Item Purchase | No card involved. INVO takes 5% of the item price. |
| Sell an item on a platform, paid by card or from the user's balance | Platform Commerce | Nothing 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 list | Player pays (before tax) | Coins credited (10 per dollar) | Your card fee, Open | Your card fee, Merchant of Record | Extra if the card is issued outside the US |
|---|---|---|---|---|---|
| $2.99 | $2.99 | 29.90 | $0.25 | $0.30 | $0.04 |
| $4.99 | $4.99 | 49.90 | $0.32 | $0.40 | $0.07 |
| $10.00 | $10.00 | 100 | $0.50 | $0.65 | $0.15 |
| $50.00 | $50.00 | 500 | $1.90 | $2.65 | $0.75 |
| $100.00 | $100.00 | 1,000 | $3.65 | $5.15 | $1.50 |
| $200.00 | $200.00 | 2,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_amountyou 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_FEEis 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 answers400 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-3dson an item sale also returnsorder_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. Asteamcheckout 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>: setiframe.src = checkout_url(any https site may frame the page). When embedded, the page does not redirect your top window; listen for theINVO_CHECKOUT_COMPLETEpostMessage. 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.
| status | terminal | What it means, and what to show |
|---|---|---|
pending | no | No payment seen yet. The buyer may still be on the form or in an authentication challenge. Keep the spinner. |
paid_pending_credit | no | A 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. |
credited | yes | Charged and credited. Show coins_credited, currency_name and new_balance when it is not null. |
refused | yes | This purchase will not credit. Show message verbatim, show order_id, and offer Contact support. |
refunded | yes | Money went back to the buyer. Show message verbatim and order_id. |
expired | yes | Never paid and can no longer be paid. Show message and offer a new checkout. |
- • Poll until
terminal, every 1 to 2 seconds, and taketerminalfrom the server rather than deriving it yourself. - • Treat an unknown
statusas non-terminal. Keep polling. - • Render
messageverbatim and never show areason_codeto 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 turnsexpiredonly 30 minutes after its link lapses.
The reason codes
| reason_code | What it tells support |
|---|---|
PAYMENT_DECLINED | The payment did not go through. Nothing was charged. |
PAYMENT_CANCELLED | Cancelled before it finished. Nothing was charged. |
CREDIT_REFUSED_MINT_CEILING | The 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_RAIL | The order carries a payment rail the credit path does not know. The money was taken. |
CREDIT_REFUSED_WRONG_ORDER_TYPE | The order is not a currency purchase and settles through its own handler. |
REFUNDED | The purchase was refunded and the coins it added were reversed. |
CHARGEBACK_LOST | The 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
| Page | Your logo? | Why |
|---|---|---|
Hosted checkout (checkout_url): coin purchases, Platform Commerce item purchases, the Steam hand-off screen and the game storefront payment screen | Yes | It is your sale, and the buyer came from your game. |
Hosted card page for subscriptions (card_setup_url) | Yes | It saves a card for your subscription. |
Subscription payment confirmation (/subscription-auth) | Yes | It confirms a renewal of your subscription. |
| Approval and device approval, claim and collect, consent, identity, phone change and guardian pages | No, always INVO | On 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. |
| Receipts | No, always INVO | On 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.
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 charged | Coins credited | Your card fee | fee_taken_from |
|---|---|---|---|---|
usd_amount: "10.00" + acknowledged_total_usd: "10.00" | $10.00 | 100.00 | $0.65 | partner |
usd_amount: "10.00", no acknowledgement | $10.00 | 100.00 | $0.65 | partner |
usd_amount: "10.00" + an older, fee-inclusive acknowledged_total_usd (higher than $10.00) | $10.00 | 100.00 | $0.65 | partner |
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
| HTTP | error_code | Meaning and what to do |
|---|---|---|
| 409 | QUOTE_STALE | The 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. |
| 409 | TAX_PLACE_UNAVAILABLE | Merchant 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. |
| 400 | BELOW_CARD_MINIMUM | The 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. |
| 400 | INVALID_ACKNOWLEDGED_TOTAL | acknowledged_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_idis 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: falsewith aCREDIT_REFUSED_*reason_code(for exampleCREDIT_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: falsewithreason_codeREFUNDEDorCHARGEBACK_LOST: the payment was returned to the buyer and any unspent coins it added were taken back. - •
credited: falsewithreason_code: null: the credit is still being finished. PollGET /api/currency-purchases/order-detailsuntil the order iscompleted.
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.refundedwebhook 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_usdonGET /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
fraudulentorunrecognized), 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.disputedcarriesdispute_fee_usd(a string, ornull) anddispute_fee_charged_to_partner(true,falseornull). Card payments only. Purchases through a third-party storefront or payment rail are unaffected. See Tiers. - • You hear about each stage on the
purchase.disputedwebhook, withdispute_statuson 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.
| Field | Req. | Notes |
|---|---|---|
player_email | yes | The player must already exist in your title; an unknown email returns 404 PLAYER_NOT_FOUND. |
setup_reference | yes | Your 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_id | no | A 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_public | Meaning |
|---|---|
PROCESSOR_UNAVAILABLE | Payment processing rejected our credentials. Nothing was sent and no charge exists. 503. |
PROCESSOR_ERROR | A classified, terminal payment processing error. 502. |
PROCESSOR_NOT_CONFIGURED | Payment processing is not configured for this operation or environment. |
RAIL_DISABLED | That payment rail is not enabled for your title. See Payment Methods. |
RAIL_CHANNEL_MISMATCH | You sent a rail that disagrees with the storefront key you authenticated with. The key decides the rail; omit the field. 400. |
STEAM_RAIL_NOT_ENTITLED | A Steam purchase for a title INVO has not enabled for Steam yet. Ask us. 403. |
STEAM_PURCHASE_LAYER_REQUIRED | Currency for a title distributed on Steam must be bought through the Steam purchase flow. 409. |
RAIL_NOT_AVAILABLE_TO_PLATFORM | A 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
| Response | Meaning | Your 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.
| Event | When it fires | Balance effect |
|---|---|---|
purchase.completed | Payment 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.failed | Card declined, 3-D Secure abandoned, payment cancelled, or an async payment failed. | none |
purchase.refunded | A 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.disputed | A 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_warning | The 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 exampleQUOTE_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.