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.

SaleBuyer paysINVO feeYour 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 units5.00 units0.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_here

The 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

POST

/api/platform-commerce/purchase

Request body

FieldTypeReq.Notes
client_request_idstringyesYour idempotency key. See Idempotency below.
funding_sourcestringyes"balance" or "card".
player_emailstringyesThe buyer's email.
player_namestringyesUp to 255 characters.
item_idstringyesYour item identifier. Up to 255 characters.
item_namestringyesShown to the buyer on the card checkout. Up to 255 characters.
item_quantityintyes1 to 1000.
unit_pricedecimalyesRounded to 2 decimal places.
total_pricedecimalyesMust 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_categorystringnoitem_category up to 100 characters.
player_phonestringnoOptional.
success_url, cancel_urlstringnoCard only: where to send the buyer after checkout.
metadataobjectnoYour 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_approvalbooleannoBalance only: the buyer approves before anything is debited. See Buyer-approved balance purchases. The card leg ignores it.
return_urlstringnoWith 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

  1. Your server calls POST /purchase with funding_source: "card" and gets a checkout_url.
  2. Send the buyer to it: redirect (window.location = checkout_url) or embed it in an iframe.
  3. 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).
  4. INVO verifies the payment, records the sale and the fee split, and fires platform_commerce.purchased.
  5. The buyer returns to your success_url (or cancel_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

  1. Your server calls POST /purchase with funding_source: "balance" and require_player_approval: true.
  2. The answer is 202 with status: "pending_approval", an approval_id and an approval_url. Send the buyer there: redirect, new tab or popup.
  3. 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.
  4. On approval INVO debits the balance and fires platform_commerce.purchased with the approval_id and your metadata. Fulfil on that webhook.
  5. 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}, or GET /api/platform-commerce/approvals?client_request_id=: the same body. status is pending_approval, completed (with order_id, which /purchase-status and /refund take as usual), declined, expired, cancelled or failed. An unknown id is 404 APPROVAL_NOT_FOUND.
  • • POST /api/platform-commerce/approvals/{approval_id}/cancel withdraws a request that is still waiting (cart abandoned, order cancelled): 200 with status: "cancelled". On a request that already ended: 409 APPROVAL_ALREADY_ENDED with its body. It may have completed, so read status.
  • • reason says 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) or settlement_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_id while the request waits returns it again (202, idempotent_replay: true) with approval_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 new client_request_id. The same id with a different item, quantity or price is 409 DUPLICATE_CLIENT_REQUEST_ID, and so is any retry after the request ended (with its body).
  • • platform_commerce.declined for an expired request can arrive up to 15 minutes after expires_at. Reading the request after expires_at ends 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

GET

/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

POST

/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_usd and charge_total_usd appear on every refund of a sale refunded in parts, including the last one (where partial_refund is false). remaining_usd appears only while something is left. adopted: true appears only when the call recorded an existing refund. On a one-shot full refund none of the parts keys appear; adopted: true can 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 /refund again with "refund_remaining": true (the JSON value true; anything else counts as not sent). Without it: 409 REFUND_REMAINING_REQUIRES_CONFIRMATION, with refunded_total_usd and remaining_usd. Nothing moves.
  • • Shortly after the last refund of the order, a further refund also needs "confirm_additional_refund": true. Without it: 409 REFUND_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 502 REFUND_NOT_COMPLETED. If the payment side could not be reached or refused the refund, nothing is recorded either and the call answers 502 with no error_code. Retry in both cases: a refund that was in fact created is found and recorded on the retry, never issued twice.
Chargebacks on the card leg. If the buyer's bank reverses a card sale and the chargeback is lost, no coins move (none were sold), and your share of that sale is reversed: if it has not been paid out it is cancelled, and if it has, it is deducted from your future payouts. The card fee on that sale also stays charged to you, exactly as on a card refund (a sale made before the 2026-09 pricing keeps its earlier terms, where INVO absorbs it). The lost 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

HTTPCodeMeaning
400noneValidation (missing or invalid field, price mismatch, bad email).
400AMOUNT_BELOW_MINIMUMThe fee rounds to zero: the amount is too small.
400BELOW_CARD_MINIMUMCard 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.
400ABOVE_CARD_MAXIMUMCard total above the current card maximum. The message names the figure in force.
400BALANCE_UNAVAILABLEBuyer approval: the buyer is unknown in your store or their balance is too small. One answer for both; the balance is never disclosed.
400INVALID_INPUTBuyer approval: return_url is not an https URL of at most 2048 characters.
400CLIENT_REQUEST_ID_RESERVEDclient_request_id begins with sub_, a reserved prefix.
401noneMissing or invalid secret key.
403NOT_A_PLATFORM_TENANTThis API is for platform tenants only.
409noneDuplicate client_request_id, or the order is already refunded.
409DUPLICATE_CLIENT_REQUEST_IDCard 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.
404APPROVAL_NOT_FOUNDNo buyer-approved purchase with that id in your store.
409APPROVAL_ALREADY_ENDEDCancel on a buyer-approved purchase that already ended. Carries its body; it may have completed.
503APPROVAL_NOT_AVAILABLEBuyer approval is not available right now (or, as 409, not for this store). Offer another way to pay.
409REFUND_REMAINING_REQUIRES_CONFIRMATIONA 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.
409REFUND_RECENTLY_ISSUEDA refund of this order was applied very recently. Re-read the order; send confirm_additional_refund: true to refund anyway.
502REFUND_NOT_COMPLETEDThe card refund failed, was canceled or still needs an action. Nothing was recorded. Retry.
502noneThe payment side could not be reached or refused the refund. Nothing was recorded. Retry.
429noneRate limited.
503noneTemporarily 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.