Currency Purchase Integration
Real-money top-ups for your branded currency, in a game or on a platform. Players check out on an INVO-hosted page, payments process server-side, and the player's balance is credited automatically. No payment UI to build, no card data on your servers.
Note on 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 / bank), not at the Invo API level. Invo trusts that whatever card was used to fund the purchase is authorized by the account holder. See Guardian Approval for the full scope.
Hosted Checkout
Your backend mints a short-lived signed session URL. You open that URL in an iframe or WebView. Player completes the purchase. INVO credits the player's balance and (optionally) notifies your backend via webhook.
What you get
- • Card data never crosses your servers (SAQ-A scope)
- • Strong Customer Authentication (3DS) where required
- • Saved card support for returning players
- • Fraud detection + velocity limits
- • Mobile-optimised checkout UI
- • Server-side credit applied on payment success
How it works
- 1. Server:
POST /api/checkout/sessionswith player email + USD amount - 2. Get back a signed
checkout_url(JWT, 15 min TTL, single-use) - 3. Open that URL in iframe/WebView
- 4. Player pays
- 5. Listen for the
INVO_CHECKOUT_COMPLETEpostMessage and/or the server-to-server webhook
Step-by-step
1. Create a checkout session (server-side)
Call this from your backend. The SDK key never leaves your server.
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": "2024-12-09T12:49:56Z",
"expires_in_seconds": 900
}checkout_url contains a 15-minute signed token. Single-use: reusing the same URL after a successful payment returns 409 SESSION_ALREADY_CONSUMED. usd_amount must be greater than 0 and at most 999.99. 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 accordingly.
Payment rail (optional rail): choose how the player pays. Defaults to platform. The hosted page renders the right experience for each, all branded as INVO.
- •
platform: cards plus Apple Pay, Google Pay, and Link, with full international billing-address support (default). Wallets appear automatically on supported devices; there is no extra fee and no app-store commission (this is a web checkout). - •
game: regional / game-store payment methods, presented in-page. - •
steam: for Steam titles, use the dedicated Steam purchase flow (server-to-server init/finalize). Asteamcheckout session only renders an in-client hand-off message; it does not by itself drive the Steam purchase.
2. Open the URL in a WebView/redirect or an iframe
The hosted page handles cards, Apple Pay / Google Pay / Link, international billing addresses, 3DS challenges (including a top-level break-out when embedded), saved-card selection, and the actual charge.
Two ways to present it:
- • WebView / full-page redirect (recommended, works everywhere): on success the page redirects to your
success_url. - • Embedded
<iframe>: works out of the box. 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 (below). Optionally, ask INVO to restrict framing to your specific origin(s) for extra hardening.
// 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 platform's WebView component.3. Listen for completion
There are two signals. Use whichever fits your stack. The server-to-server webhook is the authoritative one.
Browser postMessage (UX hint)
window.addEventListener('message', (event) => {
if (event.data?.type === 'INVO_CHECKOUT_COMPLETE') {
const { status, new_balance, currency_name, transaction_id } = event.data.data;
if (status === 'success') {
updatePlayerBalance(new_balance);
}
}
});postMessage is not signed. Treat it as a UX hint to refresh the player's balance optimistically. For ledger-of-record accuracy, rely on the webhook below or re-read the player's 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 (changes on retry/replay)
X-Invo-Idempotency-Key: <stable id> # dedupe on THIS
Body:
{
"event_id": "evt_01HX...",
"event_type": "purchase.completed",
"created_at": "2024-12-09T12: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",
"currency_amount": "100",
"currency_name": "Gold Coins",
"metadata": { "your_user_id": "u_42", "coin_pack": "starter" }
}
}Verify the HMAC-SHA256 signature using your tenant's webhook signing secret. Dedupe on X-Invo-Idempotency-Key. It is stable across retries and replays. Do not dedupe on X-Invo-Event-Id: that is unique per delivery and changes on every retry/replay. Reject timestamps older than 5 minutes.
Direct API (advanced)
If you operate your own PCI-compliant card collection (rare; most integrations should use hosted checkout), you can post a tokenised payment method directly:
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",
"payment_method_id": "<tokenised_card_id_from_your_card_collector>",
"purchase_reference": "<uuid_v4>", // REQUIRED — see "Idempotency" below
"metadata": { "your_user_id": "u_42" }
}
Possible responses:
200, status:"success" — purchase complete; body has transaction_id + new_balance
200, status:"requires_action" — 3-D Secure needed; see "Handling 3-D Secure" below
200, status:"success", duplicate:true — replay of a prior completed purchase_reference
409, status:"pending", duplicate:true — purchase already in flight for this purchase_reference
400, error_code:"MISSING_PURCHASE_REFERENCE" — field omitted
400, error_code:"INVALID_PURCHASE_REFERENCE" — field >255 chars
4xx — validation, rate limit, or card-issuer decline (see error envelope)Branch on the status field. Never treat a non-success response as a decline. requires_action is a normal step for many cards (mandatory for European cards under SCA), not a failure; prompting the player for a different card here is the wrong move.
Idempotency: purchase_reference is REQUIRED
Every call to /purchase-currency must include a stable, unique purchase_reference (a UUID v4 is fine). This is the single most important field for avoiding duplicate charges on a flaky network.
- • Generate it once per logical purchase attempt: at the moment the player taps "Buy", not per HTTP call.
- • Resend the same value on every retry. If the network dropped mid-response, INVO returns the prior result (HTTP 200,
duplicate: true) instead of charging the card a second time. - • Generate a new one for a genuinely new purchase (e.g. the player cancels, then taps Buy again).
- • Max length 255 chars; uniqueness is per title.
client_request_id. The API still accepts that name as an alias. New integrations should send purchase_reference; existing integrations sending client_request_id continue to work without change.Reserved prefix: sub_
A purchase_reference (or client_request_id) beginning with sub_, matched case-insensitively, is rejected with HTTP 400, error_code: "CLIENT_REQUEST_ID_RESERVED". The same rule applies on every purchase endpoint: currency purchase, hosted checkout, item purchase, and platform commerce.
Why: that key space is reserved for the renewal charges behind subscriptions (see renewals). Renewals use the same idempotency keys your purchases do, so a partner value parked under that prefix could collide with, or pre-empt, a renewal charge later.
Almost nobody hits this. What to check: identifiers you derive from something you did not generate yourself: a user-supplied string, an SKU, an upstream order ID. UUIDs are unaffected, and prefixing your own identifiers with a namespace of your own gives you the guarantee for free.
Handling 3-D Secure (status: requires_action)
When a card needs Strong Customer Authentication, /purchase-currency returns HTTP 200 with status: "requires_action". No money has been charged and nothing has failed. The cardholder just has to complete a bank challenge.
Step 1. The response carries what you need to run the challenge:
{
"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 3-D Secure challenge on the client using the JavaScript SDK your card collector provided, passing the client_secret. This presents the bank's authentication step to the player:
// Web — pseudocode. INVO's auth challenge uses the automatic-confirmation
// flow, so call your card-collector SDK's "confirm" variant (the one that
// confirms AND runs the challenge in a single call), NOT a manual-server-
// confirm-only "handle action" variant. If your SDK exposes both, the
// confirm variant is the correct call here.
const { error, result } = await confirmWithYourCardElement(client_secret);
if (error) {
// Player abandoned 3DS, bank declined, etc. No money moved.
// Show the user a retry / different-card prompt.
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
// 3DS passed and the auth challenge completed. Now proceed to Step 3
// so INVO credits the player's branded currency.
}
// Mobile: use the iOS / Android SDK from your card collector. Same rule —
// pick the confirm-payment variant, not a manual-confirm-only handler.
// This step talks directly to your card processor and does not use your INVO key.Step 3. Once the SDK reports success, finalise the purchase server-side. This is where the player's branded currency is credited:
POST https://invo.network/api/currency-purchases/confirm-payment
Headers:
X-Game-Secret-Key: ivsdk_<your_sdk_key>
Content-Type: application/json
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
}confirm-payment is idempotent: a retry, or a late call after INVO has already credited the purchase, returns already_processed: true instead of crediting twice.
Saving a card without a purchase
A player can save a card without buying anything. This is a separate, money-free flow: it captures the card and the cardholder's authorisation to charge it later without them present, and it charges nothing. That authorisation is what subscriptions are funded from when a member's balance runs short, so this is the call to make before, or right after, you open a recurring membership.
Two routes to a saved card. Either capture one on its own with the endpoints below, or set save_card: true on an ordinary /purchase-currency call and fund the wallet and save the card in one request. Both end up in the same place: an INVO card id you can reference later. Once a player has one, a subscription created without player_card_id picks it up automatically.
/api/currency-purchases/setup-intent
Starts a card capture. Authenticated with X-Game-Secret-Key, same as every other endpoint on this page. No money moves and no order is created.
Request body
| Field | Req. | Notes |
|---|---|---|
player_email | yes | The player must already exist in your title. This endpoint deliberately does not create players. Storing a card is not an identity event. An unknown email returns 404 PLAYER_NOT_FOUND; create the player through your normal signup or first-purchase flow first. |
setup_reference | yes | Your idempotency anchor, 1 to 200 characters of A-Z a-z 0-9 . _ : -. Generate one per logical card-setup attempt and resend it on retries. The same value returns the same setup instead of starting a second one. client_request_id is accepted as an alias, matching /purchase-currency. |
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 for the client to confirm with. Only card payment methods are accepted here (RAW_CARD_NOT_SUPPORTED otherwise). |
Responses: all 200, branch on status
// Done. The card is saved and can be referenced by card.id.
{
"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 // true = we already held this exact card
}
// Not done yet. The client must confirm and/or authenticate, then you call
// /setup-intent/confirm. status is one of:
// "requires_action" — a bank challenge (SCA / 3-D Secure)
// "requires_confirmation" — client-side confirmation needed
// "requires_payment_method" — client-side confirmation needed
{
"status": "requires_action",
"client_secret": "<confirm with this on the client>",
"setup_intent_id": "<opaque id>",
"client_side_key": "<client-side key>",
"card": null
}Never treat a non-succeeded status as a failure. Capturing a reusable-payment authorisation is a stronger consent than a one-off charge, so issuers challenge it more often, especially for UK and EU cardholders. Run the confirmation on the client with the client_secret (exactly as in Handling 3-D Secure above), then call the confirm endpoint below.
/api/currency-purchases/setup-intent/confirm
Records the card once the client has confirmed. Idempotent: call it as often as you like, the card is stored once. This is the card-capture mirror of /confirm-payment.
Body:
{ "setup_intent_id": "<the id from /setup-intent>" }
Response 200:
{
"status": "success",
"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": true // a retry that found the card already stored
}If the player has not finished the bank challenge yet you get 400 with status: "still_requires_action". Wait for the client, then call again. A setup id that does not belong to your title is a 404, never someone else's card.
/api/currency-purchases/player-cards
Lists a player's saved cards. Pass ?player_email=. Newest first, and already-expired cards are filtered out, so the list is exactly what is usable today. A player you have never seen returns 200 with an empty list, not a 404.
GET https://invo.network/api/currency-purchases/player-cards?player_email=player%40example.com
Headers:
X-Game-Secret-Key: ivsdk_<your_sdk_key>
Response 200:
{
"cards": [
{ "id": 91, "last_four": "4242", "brand": "visa",
"exp_month": 11, "exp_year": 2029,
"created_at": "2026-08-13T10:22:00+00:00" }
]
}Cards are referenced by card.id
None of these endpoints ever return a processor-side identifier: not a customer id, not a stored-card token. The saved-card surface has never exposed them and never will. The card.id integer we return is the handle: it is what you pass as player_card_id when creating a subscription or calling /payment-method, and it is what identifies the card on every read.
Card ids are scoped to one player in one title. A card id from elsewhere does not resolve. That scoping is the control that stops one player's card being billed for another.
The other route: save_card on a purchase
Send save_card: true alongside a newly tokenised payment_method_id on /purchase-currency and the one call both funds the wallet and saves the card. The response reports card_saved: true, and the card then appears in /player-cards like any other.
{
"player_email": "player@example.com",
"usd_amount": "10.00",
"payment_method_id": "<tokenised_card_id_from_your_card_collector>",
"purchase_reference": "<uuid_v4>",
"save_card": true // a real JSON boolean, not "true"
}save_card must be a real JSON boolean. The strings "true" and "false" are rejected with 400, error_code: "INVALID_SAVE_CARD". That is deliberate, and not pedantry. Read the usual truthy way, the string "false" is true, and that would store a reusable-payment authorisation the player never agreed to. An ambiguous consent input is refused loudly rather than resolved on your behalf. Sending it on the saved-card path has no effect. That card is already saved.
Error handling
Match on 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, which left those branches of the contract documented but impossible to implement. You need a literal string to match on. Every affected response now carries both keys: the original error_code, unchanged, and a provider-neutral error_code_public.
This is additive. Nothing was renamed and nothing was removed. If you already match on error_code, your integration keeps working exactly as it does today; the old key is not going away without notice. New integrations should match on error_code_public, because that is the only name public documentation can print.
| error_code_public | Meaning |
|---|---|
PROCESSOR_UNAVAILABLE | The payment processor rejected our credentials. Nothing was sent, and no charge exists. Returned as 503. |
PROCESSOR_ERROR | A classified, terminal processor error. Returned as 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 for how rails are chosen. |
RAIL_CHANNEL_MISMATCH | You sent a rail that disagrees with the storefront key you authenticated with. The key already decides the rail. Omit the field. The response names the key's storefront and its rail. Returned as 400. |
STEAM_RAIL_NOT_ENTITLED | A Steam purchase was attempted for a title INVO has not yet enabled for Steam. There is nothing to register on your side. Steam purchases run through INVO's own Steam application; enabling a title is a configuration step on ours. Holding a steam storefront key does not by itself enable it. Ask us and we configure the currency. Returned as 403. |
STEAM_PURCHASE_LAYER_REQUIRED | Currency for a title distributed on Steam must be purchased through Steam. Use the Steam purchase flow. Returned as 409. |
RAIL_NOT_AVAILABLE_TO_PLATFORM | A platform (non-game) tenant tried to sell currency through a game-scoped payment route. Those routes serve game tenants only; the general card route is available to every tenant. See Partners & Platform Status. Returned as 403. |
One case returns no error code at all. When INVO's circuit breaker is open (payment processing is shedding load and your request was never sent), the response is 503 with { "status": "service_unavailable" } and no error_code and no error_code_public. Match that one on status. No charge exists and no spending cap was consumed, so a fresh identifier is safe here.
PAYMENT_OUTCOME_UNKNOWN: the one code where you must reuse the identifier
Occasionally INVO sends a charge to the card processor and never learns what happened: the connection times out, or the response comes back unreadable. The card may well have been charged. We do not know and we will not guess, so we tell you plainly instead of reporting a failure that might not be one.
HTTP 503 (the processor was unreachable)
HTTP 502 (the processor returned something we could not read)
{
"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 the SAME purchase_reference (or client_request_id). Never mint a new one. A new identifier is a new order with a new idempotency key. If the first charge did land, the retry is a genuine second charge, and reconciliation will subsequently find and credit the first one too.
Switch on the error code, never on the HTTP status. 502 and 503 are also returned by ordinary terminal failures where no charge exists, and those want the opposite response from you. This code is already provider-neutral, so it is emitted as error_code with no alias; the codes that do carry an alias are in the table above.
| Do | Don't |
|---|---|
Replay the same identifier, or poll GET /api/currency-purchases/order-details until the order is terminal | Retry with a fresh identifier: that double-charges |
Leave your own record pending and let your reconciler resolve it | Mark your record failed: the order is still live |
| Keep the player on "processing" | Show "payment failed", or restart hosted checkout: a new session mints a new order |
Reading the replay result
Replaying the identifier tells you the order's real state. There are exactly three answers, and status is machine-readable on purpose. It is part of the published contract, so branch on it rather than on the prose message.
| Response | Meaning | Your move |
|---|---|---|
200: { "status": "success", "duplicate": true } | It landed. transaction_id and order_id are the originals. | Mark complete. Do not charge again. |
409: { "status": "pending", "duplicate": true } | Still in flight. | Keep waiting. Do not mint a new identifier. |
409: { "status": "failed", "order_status": "..." } | The identifier is spent and the order is terminal. | Now, and only now, retry with a new identifier. |
How long resolution takes
| Outcome | How it resolves | Typical time |
|---|---|---|
| The charge did land | Reconciliation finds the payment and credits the order | ~15 minutes |
| The charge did not land (the common result after a connection timeout) | Nothing exists to find, so the order fails only once it ages out | up to 24 hours |
Spending limits
INVO enforces hourly, daily, and monthly USD caps per (player_email, game). A purchase that would exceed any cap returns HTTP 429 with error: "spending_limit_exceeded" and a human-readable message naming the cap that was hit.
Only successful purchases consume the cap. Declined cards, abandoned 3-D Secure challenges, and circuit-breaker rejections leave the cap untouched, so a player whose card just failed can immediately try again with a different card.
Defaults are configurable per environment; ask your INVO contact if you need higher limits for a specific tenant.
What hosted checkout handles for you
Fast Checkout
Typical checkout completes in under 30 seconds. Players get their currency the moment payment confirms.
Compliance Out of the Box
Your scope is SAQ-A. No card data crosses your servers. We handle PCI, 3DS, and dispute messaging end-to-end.
Global Reach
Major credit and debit cards plus regional payment methods supported worldwide.
Player Experience
Saved Cards for Returning Players
Players can save payment methods for one-tap re-purchase. The selector appears automatically when a returning player checks out.
Saved-card flow
- 1Player clicks "Buy Currency" → card selector modal appears
- 2Saved cards listed with brand, last 4 digits, and expiration
- 3Player picks a saved card or selects "Use New Card"
- 4Review-purchase modal confirms the amount and card
- 5Player confirms → instant purchase, no card re-entry
Zero extra integration: saved cards work automatically with hosted checkout.
Saving a card also captures a reusable-payment mandate, the cardholder's authorisation for that card to be charged later without them present, which is what subscriptions need. It is a stronger authorisation, so issuers challenge it more often: expect status: "requires_action" (HTTP 200: in progress, nota failure) on more card-save flows, especially for UK and EU cardholders. Hosted checkout presents the challenge for you; on the direct API path, see Handling 3-D Secure above.
Review & Confirmation
Before charging a saved card we show a friendly review dialog. This satisfies FTC guidance on surprise charges and reduces accidental purchases.
Review dialog
- Friendly UI: checkmark icon, clear "Review Your Purchase" header.
- Purchase summary: amount, currency credited, card last 4.
- Explicit "Complete Purchase" button: no implicit consent.
- Easy cancel: clear secondary action.
First-Time Purchase
When a player has no saved cards, they go straight to the secure card-entry page. From there they can:
- Enter card details on the INVO-hosted page (your servers never see them)
- Opt to save the card for next time (default-on, easy to opt out)
- Complete 3DS challenges if their bank requires SCA
- See instant confirmation and have currency credited automatically
Outbound webhook events
INVO emits server-to-server webhooks for every meaningful event on a purchase. All deliveries carry an X-Invo-Signature HMAC header, an X-Invo-Event-Id (unique per delivery), and an X-Invo-Idempotency-Key (stable across retries/replays; dedupe on this). Retries follow a back-off schedule (30s, 2m, 10m, 1h, 6h, 24h).
| Event type | When it fires | Balance effect |
|---|---|---|
purchase.completed | Payment cleared on any payment method + branded currency credited. | +credit |
purchase.failed | Card declined, 3-D Secure abandoned, payment cancelled, or async payment failed. | none |
purchase.refunded | Full or partial refund issued through INVO's admin tooling. Partial refunds proportionally debit the player's branded currency; the order stays in completed status until the charge is fully refunded. | −debit (full or partial) |
purchase.disputed | Chargeback opened by the issuer. Fires again on close with dispute_status = won / lost. | −debit on lost |
purchase.fraud_warning | Early-fraud-warning signal from the card network: the issuing bank flagged the charge as likely fraudulent. Chargeback probable. | none (advisory) |
purchase.refunded: full refund
{
"event_id": "evt_01HX...",
"event_type": "purchase.refunded",
"tenant_id": "<your game_id>",
"data": {
"order_id": "ORD_...",
"transaction_id": "txn_...",
"payment_intent_id": "pi_...",
"charge_id": "ch_...",
"usd_refunded": "10.00",
"usd_original": "10.00",
"fully_refunded": true,
"currency_debited": "100",
"new_balance": "0",
"currency_name": "Gold Coins",
"order_status": "refunded"
}
}purchase.disputed: opened
{
"event_id": "evt_01HX...",
"event_type": "purchase.disputed",
"tenant_id": "<your game_id>",
"data": {
"order_id": "ORD_...",
"transaction_id": "txn_...",
"payment_intent_id": "pi_...",
"charge_id": "ch_...",
"dispute_id": "dp_...",
"reason": "fraudulent",
"usd_amount": "10.00"
}
}purchase.disputed: closed (lost, clawback applied)
{
"event_type": "purchase.disputed",
"data": {
"order_id": "ORD_...",
"dispute_id": "dp_...",
"dispute_status": "lost",
"currency_debited": "100",
"new_balance": "0"
}
}purchase.fraud_warning: Early Fraud Warning
{
"event_id": "evt_01HX...",
"event_type": "purchase.fraud_warning",
"tenant_id": "<your game_id>",
"data": {
"order_id": "ORD_...",
"transaction_id": "txn_...",
"payment_intent_id": "pi_...",
"charge_id": "ch_...",
"fraud_type": "made_with_stolen_card",
"actionable": true,
"usd_amount": "10.00",
"recommendation": "consider_refund" // or "monitor"
}
}purchase.failed: payment canceled / declined
{
"event_type": "purchase.failed",
"data": {
"payment_intent_id": "pi_...",
"order_id": "ORD_...",
"player_email": "player@example.com",
"usd_amount": "10.00",
"failure_code": "card_declined", // or "payment_canceled"
"failure_message": "Your card was declined."
}
}Order status lifecycle
Every purchase passes through a deterministic set of states. The /order-details endpoint returns the current state and timestamps; INVO retains a full append-only audit trail of every transition (server-side, available to support and finance on request).
pending_payment: order created, waiting on the payment processor.requires_action: 3-D Secure challenge pending on the cardholder.completed: charge confirmed, branded currency credited.failed: card declined or async payment failed.cancelled: the payment was cancelled (player abandoned 3-D Secure, the payment session timed out, or an admin cancelled).refunded: full refund processed; currency debited.disputed_lost: chargeback resolved against the merchant; currency clawed back.
Webhook delivery + the order-details endpoint are the two authoritative read paths. Do not infer state from response status codes alone.
Platform Support
Hosted checkout works in iframe/WebView on every major client platform:
Unity
WebGL & Mobile
Unreal Engine
All Platforms
iOS
Native & Hybrid
Android
Native & Hybrid
Why hosted checkout
Less work, faster ship
Skip the months it takes to build a payment UI, integrate a card processor, pass a PCI audit, and run dispute operations.
- • No payment UI to build
- • No PCI audit (SAQ-A scope)
- • No fraud-management infrastructure
- • No SCA / 3DS implementation
Enterprise features built in
The features production payment systems need ship by default, not as paid add-ons.
- • Saved cards / one-tap re-buy
- • 3D Secure / Strong Customer Authentication
- • Fraud detection + velocity limits
- • Dispute lifecycle + automatic balance reconciliation
Ready to integrate?
The full integration guide has platform-specific code for Unity, Unreal, iOS, and Android.