Platform Commerce
Sell items on your platform through INVO, paid one of two ways: from the user's existing INVO-network balance, or by card on INVO's hosted checkout. You choose the funding on your server; INVO charges, records the sale, takes its fee from your proceeds and pays you the rest.
Who this is for: platform tenants (non-game apps: content platforms, creator stores, marketplaces). Every endpoint below is available only to platform-tagged accounts; a standard game account receives 403 NOT_A_PLATFORM_TENANT. In a game, players spend coins on items through the Item Purchase API, and buy coins through Currency Purchase.
Two funding legs
Balance
The user spends INVO-network currency they already hold. No card is involved, and the sale settles in the same request: the response is the completed sale. On a public checkout, ask the buyer to approve it first. Amounts are in your currency units. INVO fee: 3.5%, taken from your proceeds.
funding_source: "balance"Card
A card payment in USD on INVO's hosted checkout (card, Apple Pay, Google Pay, 3-D Secure, international billing addresses). The buyer is charged exactly your price. The sale settles later, on a verified webhook. INVO card fee: 3.5% + $0.15 (Open; 5.0% + $0.15 on Merchant of Record), plus 1.5% on a card issued outside the US, taken from your proceeds. Fraud screening is included on every card payment; the chargeback guarantee (fraud chargebacks) is included on Merchant of Record and on Open-tier card sales made before October 21, 2026.
funding_source: "card"Fees and what you receive
As with a card purchase of coins, nothing is added to the buyer's bill. The buyer pays your price, and INVO's fee comes out of the sale before your share is recorded. A card fee is a percentage of the listed price (before tax) plus a fixed amount, rounded to the nearest cent: 3.5% + $0.15 on Open, 5.0% + $0.15 on Merchant of Record, and on either tier an extra 1.5% of the listed price (before tax) when the card was issued outside the US.
| Sale | Buyer pays | INVO fee | Your revenue |
|---|---|---|---|
| Card, $5.00 | $5.00 | $0.33 (3.5% + $0.15) | $4.67 |
| Card, $50.00 | $50.00 | $1.90 | $48.10 |
| Card, $50.00, card issued outside the US | $50.00 | $2.65 ($1.90 + 1.5%) | $47.35 |
| Balance, 5.00 units | 5.00 units | 0.18 (3.5%) | 4.82 |
These are the Open-tier fees. On Merchant of Record the same $5.00 and $50.00 card sales carry $0.40 and $2.65, leaving you $4.60 and $47.35. Read your revenue from developer_revenue in the response or webhook rather than computing it yourself. On the default Open tier your business is the seller of these items and is responsible for any sales tax, VAT or GST on them; the Merchant of Record tier changes the seller and the card fee. See Tiers.
Minimum card sale: $2.99. From 2026-10-27 a card sale below $2.99 is refused with 400 BELOW_CARD_MINIMUM; until then it goes through and the response carries a min_purchase_warning with code (BELOW_CARD_MINIMUM), min_usd, enforced_from, message and enforced: false: the object the coin purchase routes return, plus enforced. The refusal has the coin routes' shape too: message, error, and error_code, code and error_code_public all BELOW_CARD_MINIMUM, with min_usd and enforced_from. The balance leg has no such minimum. The card rates, the international fee, the minimum and the chargeback guarantee apply to INVO card payments only; purchases through a third-party storefront or payment rail are unaffected.
On the Merchant of Record tier two more things apply to the card leg: INVO issues the buyer a receipt (and a refund receipt for a card refund), emailed when the buyer's email is known, and part of your developer_revenue on each card sale is held as a chargeback reserve (10% for 90 days by default) before it becomes available to withdraw. The balance leg is not held and gets no receipt. See receipts and the reserve.
Authentication
Call every Platform Commerce endpoint from your server with your secret key:
X-Game-Secret-Key: your_secret_key_hereThe header name is historical; a platform tenant uses the same header. Never ship the key in a browser or app bundle. On the card leg the browser only ever receives a single-use checkout_url.
1. Create a purchase
/api/platform-commerce/purchase
Request body
| Field | Type | Req. | Notes |
|---|---|---|---|
client_request_id | string | yes | Your idempotency key. See Idempotency below. |
funding_source | string | yes | "balance" or "card". |
player_email | string | yes | The buyer's email. |
player_name | string | yes | Up to 255 characters. |
item_id | string | yes | Your item identifier. Up to 255 characters. |
item_name | string | yes | Shown to the buyer on the card checkout. Up to 255 characters. |
item_quantity | int | yes | 1 to 1000. |
unit_price | decimal | yes | Rounded to 2 decimal places. |
total_price | decimal | yes | Must equal unit_price × item_quantity. Card: USD, and exactly what the buyer is charged; see card total limits. Balance: your currency units. |
item_description, item_category | string | no | item_category up to 100 characters. |
player_phone | string | no | Optional. |
success_url, cancel_url | string | no | Card only: where to send the buyer after checkout. |
metadata | object | no | Your correlation data (for example your order id), up to 8 KB. Echoed as metadata on every platform_commerce.* webhook for this purchase, on both legs. Keys starting with _invo are reserved and dropped. |
require_player_approval | boolean | no | Balance only: the buyer approves before anything is debited. See Buyer-approved balance purchases. The card leg ignores it. |
return_url | string | no | With require_player_approval: the "Back to the store" link on the approval page. https, up to 2048 characters. |
The card leg does not take a billing address. The buyer enters it on INVO's hosted checkout.
Card total limits
A card total_price must be at least $0.50, the smallest amount a card charge can settle for, and at or below the platform maximum for a single card charge. The maximum is a platform setting (currently $100,000.00 by default) that can differ between sandbox and production and can change without a release, so read it as max_amount from GET /api/checkout/validate-game rather than hard-coding it.
A total above the maximum returns 400 ABOVE_CARD_MAXIMUM; one below the $0.50 floor (below the card processor's minimum charge) returns 400 with error_code: "BELOW_STRIPE_MINIMUM", error_code_public: "BELOW_CARD_MINIMUM", min_usd: "0.50" and a message naming the floor.
Balance response: settled (200)
{
"status": "success",
"funding_source": "balance",
"order_id": 123,
"transaction_id": "TXN_...",
"new_balance": "45.00",
"previous_balance": "50.00",
"currency_name": "Gems",
"financial_breakdown": {
"total_paid": "5.00",
"developer_revenue": "4.82",
"platform_fee": "0.18"
}
}Card response: send the buyer to checkout (200)
{
"status": "requires_payment",
"funding_source": "card",
"session_id": "<id>",
"checkout_url": "https://invo.network/checkout?session=<token>",
"expires_at": 1690000000,
"amount_usd": "5.00" // the total_price you sent; what the buyer is charged
}The card sale is not paid yet
Send the buyer to checkout_url. Treat the sale as final only when you receive the platform_commerce.purchased webhook. The link expires at expires_at (15 minutes).
2. The card checkout flow
- Your server calls
POST /purchasewithfunding_source: "card"and gets acheckout_url. - Send the buyer to it: redirect (
window.location = checkout_url) or embed it in an iframe. - The buyer enters card and billing address on INVO's hosted checkout and pays your price. The page shows the INVO logo, or your game's own logo if you set one in the console (see Use your own logo on checkout).
- INVO verifies the payment, records the sale and the fee split, and fires
platform_commerce.purchased. - The buyer returns to your
success_url(orcancel_url). Fulfil on the webhook, not on the browser return.
Embedding (optional)
If you iframe the checkout, listen for INVO's completion message:
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'INVO_CHECKOUT_COMPLETE') {
// Show a confirmation, but still fulfil on the webhook.
}
});Buyer-approved balance purchases
A plain balance purchase debits on your server's word. That is right when your own login already proves who the buyer is. On a public checkout, where anyone can type any email, it is not: send require_player_approval: true and nothing is debited until the buyer approves on INVO's own page with their INVO passkey. A buyer without one is emailed a link and sets one up in the same step. Nothing is reserved while the request waits, so a stranger can neither spend nor hold anyone's balance.
The flow
- Your server calls
POST /purchasewithfunding_source: "balance"andrequire_player_approval: true. - The answer is
202withstatus: "pending_approval", anapproval_idand anapproval_url. Send the buyer there: redirect, new tab or popup. - The buyer sees your store name, the item and the amount, and approves or declines. A buyer with no INVO passkey asks for an email link and approves from it, on any device.
- On approval INVO debits the balance and fires
platform_commerce.purchasedwith theapproval_idand yourmetadata. Fulfil on that webhook. - Any other ending fires
platform_commerce.declined(below). The buyer has 15 minutes.
// 202
{
"status": "pending_approval",
"approval_id": "PCA20261023120000000000ABC123",
"approval_url": "https://invo.network/pay/approve?code=ABCD-EFGH",
"client_request_id": "wc_1001",
"funding_source": "balance",
"total_price": "20.00",
"expires_at": "2026-10-23T12:15:00+00:00",
"metadata": { "wc_order": "1001" },
"reason": null, "order_id": null, "transaction_id": null, "decided_at": null
}Reading and cancelling
- •
GET /api/platform-commerce/approvals/{approval_id}, orGET /api/platform-commerce/approvals?client_request_id=: the same body.statusispending_approval,completed(withorder_id, which/purchase-statusand/refundtake as usual),declined,expired,cancelledorfailed. An unknown id is404 APPROVAL_NOT_FOUND. - •
POST /api/platform-commerce/approvals/{approval_id}/cancelwithdraws a request that is still waiting (cart abandoned, order cancelled):200withstatus: "cancelled". On a request that already ended:409 APPROVAL_ALREADY_ENDEDwith its body. It may have completed, so readstatus. - •
reasonsays why a request ended without a sale:declined,buyer_cannot_approve(the buyer chose to pay another way),expired,cancelled,insufficient_balance(the balance was spent before approval),recovery_hold(the buyer recently recovered their passkey; payments from their balance pause for 24 hours) orsettlement_failed.
Rules
- • The buyer must already hold a balance in your store. An unknown buyer and a balance that is too small get the same answer,
400 BALANCE_UNAVAILABLE, and the balance is never disclosed. Offer another way to pay. - • Retries: the same
client_request_idwhile the request waits returns it again (202,idempotent_replay: true) withapproval_url: null, so the buyer's link is never replaced. Keep the URL from the first response; if you lost it, cancel and start again with a newclient_request_id. The same id with a different item, quantity or price is409 DUPLICATE_CLIENT_REQUEST_ID, and so is any retry after the request ended (with its body). - •
platform_commerce.declinedfor an expired request can arrive up to 15 minutes afterexpires_at. Reading the request afterexpires_atends it at once. - •
503 APPROVAL_NOT_AVAILABLE: buyer approval is briefly unavailable. Retry, or offer another way to pay. - • Never show the buyer anything from a refusal body except your own wording: on a public page, error details are visible to whoever typed the email.
3. Check status
/api/platform-commerce/purchase-status/{order_id}
A fallback to the webhook for card purchases:
{
"order_id": 123,
"status": "completed", // completed | pending_payment | refunded
"game_currency_amount": "5.00", // balance leg
"usd_amount": "5.00", // card leg
"payment_method": "card", // card | network_currency
"created_at": "2026-07-20T...",
"invo_fee_usd": "0.33", // everything INVO kept on the sale, tax excluded
"partner_share_usd": "4.67" // your earnings from the sale
}INVO's fee on each sale: invo_fee_usd is everything INVO kept on the sale, tax excluded: the card fee plus any international card fee on a card sale, the platform fee on a balance sale. partner_share_usd is your earnings from the sale. Both are in USD on either leg, and both are null until the sale has settled. They appear on this read and on every entry of the purchase history below.
One player's purchases: GET /api/platform-commerce/purchase-history?player_email=&limit=&cursor= lists them newest first, each entry with exactly these fields, invo_fee_usd and partner_share_usd included. limit is 1 to 100 (default 50); pass the previous page's next_cursor as cursor while has_more is true. An unknown player is 404 PLAYER_NOT_FOUND; a bad cursor is 400 INVALID_CURSOR. For every kind of money activity at once, see player history reads.
4. Refunds and chargebacks
/api/platform-commerce/refund
You can refund a completed purchase yourself. Body: { "order_id": 123 } or { "client_request_id": "..." }, plus an optional reason. A client_request_id finds card orders as well as balance orders.
{
"status": "refunded",
"funding_source": "card",
"order_id": 123,
"transaction_id": "TXN_...",
"refunded_amount_usd": "5.00",
"tax_refunded_usd": "0.00",
"processor_refund_reference": "<opaque id>",
"fee_retained": true,
"developer_revenue_reversed_usd": "4.67",
"card_fee_charged_to_partner_usd": "0.33", // the card fee is not returned
"partner_debited_usd": "5.00" // 4.67 + 0.33: what the refund takes from you
}
// balance leg: "refunded_amount", "new_balance", "developer_revenue_reversed"The buyer gets the full price back: a balance purchase re-credits the wallet, a card purchase refunds the card.
INVO keeps its fee on a refund (fee_retained: true), and your revenue on the sale is reversed. Reconcile against developer_revenue_reversed_usd (card) or developer_revenue_reversed (balance).
On a card refund the card fee stays charged to you. card_fee_charged_to_partner_usd is the card fee part of this refund (the rate and any international card fee), and partner_debited_usd is everything the refund takes from your earnings: your revenue reversed plus that fee part. On a full refund that is the whole sale before tax. A card sale made before the 2026-09 pricing keeps its earlier terms: card_fee_charged_to_partner_usd reads 0.00 and INVO absorbs the fee. The balance leg carries neither key.
Only a completed order is refundable; a refund of an order already refunded in full returns 409. A card sale refunded in parts is the one exception, below.
processor_refund_reference identifies the refund on the payment side, useful only when you raise a support case with us.
Card sales refunded in parts
A normal refund returns the whole card charge in one go, and the response and webhook are exactly as shown above. A card sale can end up refunded in parts only when part of the charge was already refunded outside INVO's API. The next call to /refund then records that existing refund and moves no new money. The order stays completed, and the rest can still be refunded.
{
"status": "refunded",
"funding_source": "card",
"order_id": 123,
"refunded_amount_usd": "2.00", // this refund
...
"partial_refund": true,
"adopted": true, // an existing refund was recorded; no new money moved
"refunded_total_usd": "2.00", // refunded so far
"charge_total_usd": "5.00", // the whole charge
"remaining_usd": "3.00" // still refundable
}- •
partial_refund,refunded_total_usdandcharge_total_usdappear on every refund of a sale refunded in parts, including the last one (wherepartial_refundisfalse).remaining_usdappears only while something is left.adopted: trueappears only when the call recorded an existing refund. On a one-shot full refund none of the parts keys appear;adopted: truecan still appear if the call recorded an existing full refund. - • Every other figure in the response (
refunded_amount_usd,tax_refunded_usd,developer_revenue_reversed_usd) is that refund's own share. Your revenue is reversed in proportion to each part, and the parts add up to the whole sale. - • To refund what is left, call
/refundagain with"refund_remaining": true(the JSON valuetrue; anything else counts as not sent). Without it:409REFUND_REMAINING_REQUIRES_CONFIRMATION, withrefunded_total_usdandremaining_usd. Nothing moves. - • Shortly after the last refund of the order, a further refund also needs
"confirm_additional_refund": true. Without it:409REFUND_RECENTLY_ISSUED. Re-read the order first; nothing moves. - • If the refund was not completed (it failed, was canceled, or still needs an action on the payment side), nothing is recorded and the call answers
502REFUND_NOT_COMPLETED. If the payment side could not be reached or refused the refund, nothing is recorded either and the call answers502with noerror_code. Retry in both cases: a refund that was in fact created is found and recorded on the retry, never issued twice.
purchase.disputed carries card_fee_charged_to_partner_usd and partner_debited_usd, as on the refund. A lost close also carries order_type, dispute_settlement, developer_revenue_reversed_usd, processing_fee_usd, guarantee_covered, dispute_fee_usd and dispute_fee_charged_to_partner. Held while open: while a dispute you are responsible for is open, your share of that sale is held from your earnings so it cannot be paid out (dispute_holds_usd on GET /api/withdrawals/available-balance). If the dispute is won the hold is released; if it is lost the hold is released as the reversal is booked, so the amount is taken only once. Chargeback guarantee (Merchant of Record; Open-tier sales made before October 21, 2026): when the dispute is coded as fraud (the cardholder says they did not authorize the payment), INVO absorbs it. On a covered sale your share is not taken back, nothing is held while the dispute is open, and no dispute fee or card fee is charged to you; the lost purchase.disputed carries guarantee_covered: true. A dispute that is not coded as fraud (for example product not received) is yours on both tiers and works as described above. 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). See Tiers.Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | none | Validation (missing or invalid field, price mismatch, bad email). |
| 400 | AMOUNT_BELOW_MINIMUM | The fee rounds to zero: the amount is too small. |
| 400 | BELOW_CARD_MINIMUM | Card total below a card floor: the $0.50 processing floor at any date, and the $2.99 minimum card sale from 2026-10-27. The body carries min_usd, the floor that refused it ("0.50" for the processing floor, "2.99" for the minimum card purchase, which also carries enforced_from); raise the total to at least min_usd. Match on error_code_public: "BELOW_CARD_MINIMUM", which both refusals carry; the $0.50 refusal also still sends its original error_code, BELOW_STRIPE_MINIMUM (below the card processor's minimum charge), unchanged, so an integration matching on it keeps working. |
| 400 | ABOVE_CARD_MAXIMUM | Card total above the current card maximum. The message names the figure in force. |
| 400 | BALANCE_UNAVAILABLE | Buyer approval: the buyer is unknown in your store or their balance is too small. One answer for both; the balance is never disclosed. |
| 400 | INVALID_INPUT | Buyer approval: return_url is not an https URL of at most 2048 characters. |
| 400 | CLIENT_REQUEST_ID_RESERVED | client_request_id begins with sub_, a reserved prefix. |
| 401 | none | Missing or invalid secret key. |
| 403 | NOT_A_PLATFORM_TENANT | This API is for platform tenants only. |
| 409 | none | Duplicate client_request_id, or the order is already refunded. |
| 409 | DUPLICATE_CLIENT_REQUEST_ID | Card leg: a payment already exists for this client_request_id. Carries order_id and order_status. Read that order with /purchase-status/<order_id> rather than starting another payment. |
| 404 | APPROVAL_NOT_FOUND | No buyer-approved purchase with that id in your store. |
| 409 | APPROVAL_ALREADY_ENDED | Cancel on a buyer-approved purchase that already ended. Carries its body; it may have completed. |
| 503 | APPROVAL_NOT_AVAILABLE | Buyer approval is not available right now (or, as 409, not for this store). Offer another way to pay. |
| 409 | REFUND_REMAINING_REQUIRES_CONFIRMATION | A card sale already refunded in part: send refund_remaining: true to refund the rest. Carries refunded_total_usd and remaining_usd. See refunds in parts. |
| 409 | REFUND_RECENTLY_ISSUED | A refund of this order was applied very recently. Re-read the order; send confirm_additional_refund: true to refund anyway. |
| 502 | REFUND_NOT_COMPLETED | The card refund failed, was canceled or still needs an action. Nothing was recorded. Retry. |
| 502 | none | The payment side could not be reached or refused the refund. Nothing was recorded. Retry. |
| 429 | none | Rate limited. |
| 503 | none | Temporarily unavailable. Retry shortly. Arrives as { "error": "service_unavailable" } with no error code; match on that field. |
Some responses also carry a provider-neutral error_code_public; match on it where present. The full table is on Currency Purchase.
Webhooks
Subscribe in Webhook Management. Fulfil card purchases here.
platform_commerce.purchased (card, $5.00)
{
"event": "platform_commerce.purchased",
"data": {
"transaction_id": "TXN_...",
"order_id": 123,
"funding_source": "card",
"player_email": "buyer@example.com",
"identity_id": "...",
"client_request_id": "...",
"item_id": "sticker_pack_01",
"item_name": "Sticker Pack",
"item_quantity": 1,
"total_price_usd": "5.00", // what the buyer was charged
"item_price_usd": "5.00", // the sale, less any tax
"tax_usd": "0.00",
"tax_jurisdiction": null,
"fee_breakdown": {
"total_paid": "5.00", "platform_fee": "0.33", "developer_revenue": "4.67",
"international_card_fee_usd": "0.00"
},
"international_card_fee_usd": "0.00", // already inside platform_fee
"metadata": { "cart_id": "c_9" } // what you sent; {} when none
}
}
// balance leg: "unit_price", "total_price", "currency_name", "new_balance" instead of the USD
// fields, plus "client_request_id" and "approval_id" (null unless the buyer approved it)fee_breakdown describes the sale the fee is taken on. platform_fee is INVO's whole fee and includes the international card fee, so platform_fee + developer_revenue = total_paid. The international card fee is also reported on its own as international_card_fee_usd (top level and in fee_breakdown; "0.00" on a US card and on the balance leg), so the card rate part is platform_fee - international_card_fee_usd. Do not add it to platform_fee again. On a $5.00 sale on a card issued outside the US, platform_fee is 0.41 (the card rate part, 3.5% of 5.00 plus 0.15 = 0.325, rounds to 0.33; the international part, 1.5% of 5.00 = 0.075, rounds to 0.08, each to the nearest cent), international_card_fee_usd is 0.08 and developer_revenue is 4.59. Reconcile your revenue on developer_revenue and a card statement on total_price_usd. On a Merchant of Record card sale the event also carries receipt_number and receipt_url, as does GET /api/platform-commerce/purchase-status/<order_id> once the receipt is issued; on platform_commerce.refunded they are the refund receipt's. Absent otherwise.
platform_commerce.declined (opt-in)
{
"event": "platform_commerce.declined",
"data": {
"approval_id": "PCA20261023120000000000ABC123",
"client_request_id": "wc_1001",
"funding_source": "balance",
"status": "declined", // declined | expired | cancelled | failed
"reason": "declined", // see Buyer-approved balance purchases
"total_price": "20.00",
"currency_name": "Gems",
"item_id": "sku_1",
"item_quantity": 1,
"metadata": { "wc_order": "1001" }
}
}A buyer-approved purchase that ended without a sale. Nothing was debited. This event is opt-in: a subscription to all events (*) does not receive it. Add platform_commerce.declined to your subscription by name. It fires once per request.
platform_commerce.refunded
{
"event": "platform_commerce.refunded",
"data": {
"order_id": 123,
"funding_source": "card",
"player_email": "buyer@example.com",
"identity_id": "...",
"refunded_amount": "5.00",
"amount_unit": "usd", // "usd" (card) or "currency" (balance)
"fee_retained": true,
"developer_revenue_reversed": "4.67",
"tax_refunded_usd": "0.00",
"tax_jurisdiction": null,
"card_fee_charged_to_partner_usd": "0.33", // card refunds only
"partner_debited_usd": "5.00", // card refunds only
"metadata": { "cart_id": "c_9" } // the purchase's; {} when none
}
}For a card sale refunded in parts, every platform_commerce.refunded event also carries partial_refund, refunded_total_usd and charge_total_usd, and refunded_amount is that refund's own amount. A sale refunded in one go sends none of the three, exactly as before.
Idempotency. Always send a stable client_request_id.
Balance: a repeat returns 409 (already processed). With require_player_approval, see retries.
Card: a repeat normally returns the same checkout session (idempotent_replay: true), so a retry cannot create a second charge. If that session cannot be returned and a payment already exists for the client_request_id, the answer is 409 DUPLICATE_CLIENT_REQUEST_ID with order_id and order_status: read that order with /purchase-status/<order_id> instead of starting another payment. Handle both answers.
Reserved prefix: a client_request_id beginning with sub_ (any case) is rejected with 400 CLIENT_REQUEST_ID_RESERVED. That key space belongs to subscription renewals. UUIDs are unaffected.
Who the seller is, who handles tax, and the fees on each tier: Tiers.