Platform Commerce

Sell items on your platform through Invo, with two ways to pay: a user's existing Invo-network balance, or a direct card payment on Invo's hosted checkout. Funding is resolved on your server, so network value always moves through Invo, never around it.

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. For in-game item spending on a game account, use the Item Purchase API instead.

Two funding legs

Balance

The user spends Invo-network currency they already hold. A ledger debit: no card, settles synchronously (the response is the completed sale). Amount is in your currency units. Invo fee: 3.5% flat.

funding_source: "balance"

Card

A direct card payment (USD) on Invo's hosted checkout: card, Apple Pay, Google Pay, 3-D Secure, and international billing address all handled by us. Settles asynchronously on a verified webhook. Invo fee: 3.5% + $0.30.

funding_source: "card"

Authentication

All Platform Commerce endpoints are called from your server with your secret key:

X-Game-Secret-Key: your_secret_key_here

The header name is historical: the API calls the tenant a game and names its key X-Game-Secret-Key, but a platform tenant uses the same field and the same flow.

Never call these from a client, and never ship the secret key in a browser or app bundle. Funding is resolved server-side; for 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. A repeat with the same id is de-duplicated.
funding_sourcestringyes"balance" or "card". Validated + enforced server-side.
player_emailstringyesThe buyer's email.
player_namestringyes≤ 255 chars.
item_idstringyesYour item identifier. ≤ 255 chars.
item_namestringyesShown to the buyer on the card checkout. ≤ 255 chars.
item_quantityintyes1 to 1000.
unit_pricedecimalyesRounded to 2 dp.
total_pricedecimalyesMust equal unit_price × quantity. Card: USD, $0.50 to $999.99. Balance: your currency units.
item_description, item_categorystringnoitem_category ≤ 100 chars.
player_phonestringnoOptional.
success_url, cancel_urlstringnoCard only: where to send the buyer after checkout.
metadataobjectnoCard only: your correlation data, echoed back to you.

Note: the card leg does not take a billing address. The buyer enters it on Invo's hosted checkout and we capture it from the confirmed payment.

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"
}

Card leg: send the buyer to the hosted checkout

A card purchase is not paid when this returns. The order does not exist yet. Send the buyer to checkout_url; they pay on Invo's hosted page. 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: either redirect (window.location = checkout_url) or embed it in an iframe.
  3. The buyer enters card + billing address on Invo's hosted checkout (card / Apple Pay / Google Pay / 3-D Secure) and pays.
  4. Invo verifies the payment, records the sale + fee, captures the billing address, and fires platform_commerce.purchased.
  5. The buyer is returned to your success_url (or cancel_url). Fulfil the item on the webhook, not on the browser return.

Embedding (optional)

If you iframe the checkout, listen for the completion message from Invo's page:

window.addEventListener('message', (e) => {
  if (e.data && e.data.type === 'INVO_CHECKOUT_COMPLETE') {
    // e.data.data.status === 'completed'
    // Show a confirmation UI — but still fulfil on the webhook.
  }
});

3. Check status

GET

/api/platform-commerce/purchase-status/{order_id}

Poll an order (useful as a fallback to the webhook for card purchases). Returns:

{
  "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..."
}

4. Refund

POST

/api/platform-commerce/refund

Refund a completed purchase. Body: { "order_id": 123 } or { "client_request_id": "..." }, plus optional reason.

{
  "status": "refunded",
  "funding_source": "card",             // or "balance"
  "order_id": 123,
  "transaction_id": "TXN_...",
  "refunded_amount_usd": "5.00",        // card;  balance -> "refunded_amount" + "new_balance"
  "fee_retained": true
}
On the card leg the response also carries a rail-side refund reference, an opaque identifier from the payment rail that processed the refund, useful only when you raise a support case with us. It is returned under a legacy, vendor-named key that public documentation does not print, so it is omitted from the sample above rather than removed from the API. Nothing depends on it: reconcile on transaction_id and order_id, which are stable and identical in shape across both funding legs. If you need the rail reference for a specific dispute, ask your Invo technical contact.

The customer is made whole: a balance purchase re-credits the wallet, and a card purchase refunds the card.

Invo retains its fee on refunds (fee_retained: true).

Only a completed order is refundable; a second refund on the same order returns 409.

Error codes

HTTPCodeMeaning
400noneValidation (missing/invalid field, price mismatch, bad email).
400AMOUNT_BELOW_MINIMUMFee rounds to zero: the amount is too small.
400noneCard total below the $0.50 minimum. This response still carries a legacy, vendor-named error_code that public documentation does not print. Branch on the 400 and validate the $0.50 to $999.99 range before you call.
400ABOVE_CARD_MAXIMUMCard total above $999.99.
400CLIENT_REQUEST_ID_RESERVEDclient_request_id begins with sub_, a reserved prefix. See Idempotency below.
401noneMissing or invalid secret key.
403NOT_A_PLATFORM_TENANTThis API is for platform tenants only.
409noneDuplicate client_request_id / order already refunded.
429noneRate limited.
503noneTemporarily unavailable. Retry shortly. Arrives as { "error": "service_unavailable" } with no error code; match on that field.

Provider-neutral error codes (error_code_public)

A few of Invo's partner-facing error codes were named after a payment processor years ago. Public documentation never names a processor, so those responses now carry a second, provider-neutral key alongside the original: error_code_public. Match on error_code_public. This is additive: nothing was renamed, and an integration matching error_code keeps working unchanged.

The full alias table lives on the Currency Purchase page. Note the temporary-unavailability case above returns no error code at all, so it must be matched on its error field rather than on a code.

Webhooks

Subscribe (see Webhook Management). Card purchases must be fulfilled here. This is the source of truth, not the browser return.

platform_commerce.purchased

{
  "event": "platform_commerce.purchased",
  "data": {
    "transaction_id": "TXN_...",
    "order_id": 123,
    "funding_source": "card",            // or "balance"
    "player_email": "buyer@example.com",
    "identity_id": "...",
    "item_id": "sticker_pack_01",
    "item_name": "Sticker Pack",
    "item_quantity": 1,
    "total_price_usd": "5.00",           // card;  balance -> "total_price" + "currency_name" + "new_balance"
    "client_request_id": "...",          // card
    "fee_breakdown": {
      "total_paid": "5.00", "platform_fee": "0.30", "developer_revenue": "4.70"
    }
  }
}

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
  }
}

Idempotency. Always send a stable client_request_id.

Balance: a repeat returns 409 (already processed).

Card: a repeat returns the same checkout session (idempotent_replay: true) so a retry can't create a second charge.

Reserved prefix: sub_

A client_request_id that begins with sub_ (case-insensitive) is rejected with 400 CLIENT_REQUEST_ID_RESERVED. That prefix is reserved for the renewal charges behind subscription renewals, which share this idempotency key space. A partner value parked there could collide with a future renewal.

What to check: identifiers you derive from something you did not generate yourself: a user-supplied string, an item SKU, an upstream order ID. UUIDs are unaffected.

Fees. Balance leg: 3.5% flat. Card leg: 3.5% + $0.30.

Your revenue on each sale is the total minus the Invo fee, returned in financial_breakdown (balance response) and on the purchase webhook (fee_breakdown).