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_hereThe 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
/api/platform-commerce/purchase
Request body
| Field | Type | Req. | Notes |
|---|---|---|---|
client_request_id | string | yes | Your idempotency key. A repeat with the same id is de-duplicated. |
funding_source | string | yes | "balance" or "card". Validated + enforced server-side. |
player_email | string | yes | The buyer's email. |
player_name | string | yes | ≤ 255 chars. |
item_id | string | yes | Your item identifier. ≤ 255 chars. |
item_name | string | yes | Shown to the buyer on the card checkout. ≤ 255 chars. |
item_quantity | int | yes | 1 to 1000. |
unit_price | decimal | yes | Rounded to 2 dp. |
total_price | decimal | yes | Must equal unit_price × quantity. Card: USD, $0.50 to $999.99. Balance: your currency units. |
item_description, item_category | string | no | item_category ≤ 100 chars. |
player_phone | string | no | Optional. |
success_url, cancel_url | string | no | Card only: where to send the buyer after checkout. |
metadata | object | no | Card 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
- Your server calls
POST /purchasewithfunding_source: "card"and gets acheckout_url. - Send the buyer to it: either redirect (
window.location = checkout_url) or embed it in an iframe. - The buyer enters card + billing address on Invo's hosted checkout (card / Apple Pay / Google Pay / 3-D Secure) and pays.
- Invo verifies the payment, records the sale + fee, captures the billing address, and fires
platform_commerce.purchased. - The buyer is returned to your
success_url(orcancel_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
/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
/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
}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
| HTTP | Code | Meaning |
|---|---|---|
| 400 | none | Validation (missing/invalid field, price mismatch, bad email). |
| 400 | AMOUNT_BELOW_MINIMUM | Fee rounds to zero: the amount is too small. |
| 400 | none | Card 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. |
| 400 | ABOVE_CARD_MAXIMUM | Card total above $999.99. |
| 400 | CLIENT_REQUEST_ID_RESERVED | client_request_id begins with sub_, a reserved prefix. See Idempotency below. |
| 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 / order already refunded. |
| 429 | none | Rate limited. |
| 503 | none | Temporarily 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).