Initiate Cross-Game Transfer

Start a secure transfer of currency to another title in your ecosystem. This endpoint validates the request, calculates fees, reserves the amount from the player's balance and opens the approval window. The player then approves it with a passkey, or by scanning a QR code on consoles and native desktop builds.

Transfer vs Currency Send

Cross-Game Transfers are for moving currency from your title to another specific title where the same player will receive it.

For player-to-player currency sends (including peer-to-peer within your title), use the/api/currency-sends/initiate-send endpoint instead.

Key Difference: Transfers move currency between titles for the same player account, while Currency Sends allow any player to send currency to any other player via phone number.

POST

/api/transfers/initiate-transfer

Initiates a cross-game transfer and opens the sender-approval window

Security Requirement

This endpoint must only be called from your secure backend. Never call it directly from a client.

Authentication Required

All requests must include your title's secret key in the request headers:

X-Game-Secret-Key: your_game_secret_key_here

Request Body Parameters

ParameterTypeRequiredDescription
client_request_idstringYesUnique ID for this transfer attempt (prevents duplicates)
source_player_namestringYesDisplay name of the player initiating the transfer
source_player_emailstringYesEmail address of the source player
source_player_phonestringYesThe player's phone number on file, E.164 (format: +1234567890)
target_player_emailstringNo (recommended)Email of the player at the destination. Optional: routing is by phone, so a phone-only transfer reaches the phone's owner. When you supply it, the phone-share approval check runs at initiate: if this email is new on the network and the phone is already on file under a different email, the response is 409 PHONE_SHARE_APPROVAL_REQUIRED.
target_player_phonestringYes🔒 SECURITY: Phone number of intended recipient. Claim code can ONLY be redeemed by this number (format: +1234567890)
target_game_idintegerYesID of the destination title (must be different from the source title)
amountstringYesAmount to transfer (as decimal string, e.g. "100.50")

Implementation Examples

Server-side only. This call takes your title's X-Game-Secret-Key, which must never ship in a game or app client (Unity, Unreal, Godot, web or mobile). Your client asks your server, and your server makes this call. See Credentials & Key Handling.

Node.js (server)

// Node (server). INVO_GAME_SECRET comes from your server's environment.
const res = await fetch('https://invo.network/api/transfers/initiate-transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Game-Secret-Key': process.env.INVO_GAME_SECRET,
  },
  body: JSON.stringify({
    client_request_id: crypto.randomUUID(),   // reuse the same id if you retry
    source_player_name: 'PlayerOne',
    source_player_email: 'player@example.com',
    source_player_phone: '+15555550111',
    target_player_email: 'player@example.com',
    target_player_phone: '+15555550111',
    target_game_id: 987654321098,
    amount: '500.00',
  }),
});
const data = await res.json();
// 201: data.transaction_id; the player approves next (see Cross-Game Transfer, stage by stage).
// 202: a guardian approval hold (see Guardian Approval).

Python (server)

# Python (server)
import os, uuid, requests
r = requests.post(
    "https://invo.network/api/transfers/initiate-transfer",
    headers={"X-Game-Secret-Key": os.environ["INVO_GAME_SECRET"]},
    json={
        "client_request_id": str(uuid.uuid4()),  # reuse the same id if you retry
        "source_player_name": "PlayerOne",
        "source_player_email": "player@example.com",
        "source_player_phone": "+15555550111",
        "target_player_email": "player@example.com",
        "target_player_phone": "+15555550111",
        "target_game_id": 987654321098,
        "amount": "500.00",
    },
    timeout=15,
)
data = r.json()
# 201: data['transaction_id']; the player approves next.

Request & Response Examples

Example Request

POST /api/transfers/initiate-transfer
Content-Type: application/json
X-Game-Secret-Key: your_game_secret_key_here

{
  "client_request_id": "transfer_12345_20240624",
  "source_player_name": "PlayerOne",
  "source_player_email": "player@example.com",
  "source_player_phone": "+1234567890",
  "target_player_email": "recipient@example.com",
  "target_player_phone": "+0987654321",
  "target_game_id": 987654321098,
  "amount": "500.00"
}

// Note: target_game_id must be different from your game ID
// Note: target_player_email is optional and recommended. When supplied, the
//       phone-share approval check runs at initiate time, before any
//       transaction is staged. See PHONE_SHARE_APPROVAL_REQUIRED.

Watch for HTTP 202, guardian approval pending

If the initiating account is flagged as a minor on Invo, this endpoint returns 202 Accepted instead of 201, with a guardian_approval block. The transaction is held until the guardian answers. The request goes to them by email when they have a verified address; a text is an explicit fallback. consent_channel in the response says which went out. This is the standard integration pattern going forward, handle it defensively on every initiate call; you can't detect minor status from the partner side.

Full contract: Guardian Approval.

Success Response (201 Created)

{
  "status": "success",
  "message": "Transfer initiated. Approve on your enrolled device to continue.",
  "transaction_id": "TXN20260904121000123456ABC123",
  "order_id": "TFRO_1719235200_A1B2C3D4",
  "new_balance": "1500.00",
  "currency_name": "Gold",
  "verification_method": "in_app",
  "verification_expires_at": "2026-01-01T12:10:00Z",
  "transfer_details": {
    "source_game": "Adventure Quest",
    "target_game": "Space Warriors",
    "target_game_id": "987654321098",
    "currency": "Gold",
    "currency_id": 1,
    "amount_initiated": "500.00",
    "fees_preview": {
      "total_fee": "50.00",
      "source_game_fee": "17.50",
      "target_game_fee": "17.50",
      "invo_fee": "15.00",
      "net_amount": "450.00",
      "source_rate_pct": "3.500",
      "target_rate_pct": "3.500"
    },
    "transfer_policy": {
      "source_universal_transfers": "yes",
      "policy_applied": "universal",
      "target_in_linked_list": null
    }
  },
  "verification_required": {
    "phone_number_masked": "******7890",
    "pin_expires_in_minutes": 10
  }
}

Top-level new_balance is the canonical post-write balance (the source player's available balance after the transfer amount is reserved), display this value rather than recomputing. currency_name names the source currency, and fees_preview also returns the per-side rate keys source_rate_pct and target_rate_pct.

Branch on verification_method

verification_method is 'in_app' | 'sms', and it tells you whether a legacy fallback message went out, not which factor you may use. 'in_app' means Invo saw an approval route for this sender and sent nothing. 'sms' is a legacy value meaning none was detected yet; do not build on it.

In both cases, approve with a passkey or a device approval grant and call POST /api/sdk/transfers/{id}/approve. Both paths are live on the transaction regardless of this value, a first-time console sender can see 'sms' and still approve by QR seconds later. Full worked examples: Cross-Game Transfer, stage by stage.

verification_expires_at is the ISO timestamp marking the end of the verification window.

Pending Guardian Approval (202 Accepted)

{
  "status": "pending_guardian_approval",
  "transaction_id": "TXN20260904121000123456ABC123",
  "order_id": "TFRO_1719235200_A1B2C3D4",
  "guardian_approval": {
    "approval_id": "1f2a8c0d-...",
    "state": "pending",
    "expires_at": "2026-05-04T22:15:00+00:00",
    "poll_endpoint": "/api/transactions/TXN20260904121000123456ABC123/approval-status"
  },
  "transfer_details": { /* same as 201 success */ },
  "verification_required": { /* same as 201 success */ }
}

Show a "waiting for parent approval" UI and poll poll_endpoint every 5 to 10s. See Guardian Approval for the full state machine.

Error Response - Phone-Share Approval Required (409 Conflict)

{
  "status": "error",
  "error_code": "PHONE_SHARE_APPROVAL_REQUIRED",
  "message": "This phone number is already linked to another Invo account. The existing phone owner must approve via SMS before this email can also use this phone.",
  "phone": "+15551234567",
  "requesting_email": "bob@example.com",
  "existing_account_hints": ["al***@example.com"],
  "next_endpoint": "/api/wallet/phone-share/initiate",
  "approval_id": "1f2a8c0d-...",
  "expires_at": "2026-05-04T22:25:00+00:00",
  "code_sent": true,
  "already_approved": false
}

Fires when the supplied phone is already on file with a different email and no approval is recorded. Same contract for developer SDK integrations and platform-tier partners. INVO delivers a one-time code to the existing phone owner before returning the 409. Have the owner enter it in your UI, then call POST /api/wallet/phone-share/approve with the approval_id from the body and the 6-digit OTP, then retry the original call. Only call /phone-share/initiate manually if code_sent: false. Match on error_code, not message text.

Initiate-time gate (since 2026-05-07): the gate now fires at /initiate-transfer as well as /claim-transfer. Previously the conflict was only detected at claim time, after the sender had reserved funds and started approval. Now the 409 fires at initiate, before any Transaction, TransferPin, or OrderBook row is created. Net effect: zero side effects until the existing phone owner has approved.

If already_approved: true: auto-retry the original action immediately, do not show the OTP entry UI. The flag means a concurrent approval landed between gate-check and response, the pair is approved on the backend; the gate fired in a brief race window. The retry will succeed. One retry, no loops.

Anti-abuse fields (optional, present only when triggered): cooldown_seconds appears when a fresh OTP for the same (phone, email) was minted less than 30s ago, no new code is sent, the previous code on the user's phone is still valid, and the included approval_id is the same row. Show the OTP entry panel as normal; do not show "code not sent" copy and do not auto-call /phone-share/initiate until the cooldown elapses. rate_limited: true + retry_after_seconds appear when this phone has hit the platform-wide 40-OTP-per-hour cap, no code sent, no approval_id returned, surface "please wait before requesting another code."

Two response paths the existing phone owner can choose between:

  • Approve via UI: type the 6-digit code into your UI → call POST /api/wallet/phone-share/approve with { approval_id, otp }.
  • Reject (new, 2026-05-14): either call POST /api/wallet/phone-share/reject with { approval_id, otp } from your UI. Rejected rows are terminal, the requesting email must re-trigger a fresh request to retry. A subsequent /phone-share/initiate for the same pair creates a new approval row.

While your approval panel is open, poll GET /api/wallet/phone-share/status?phone=...&email=... every 3 to 5s. Auto-retry the original action when approved: true; surface a clear rejection message and offer a "try again" affordance on rejection (the status endpoint returns approved: false for both pending and rejected states, distinguishable only by the existence of a newer pending row after a fresh initiate).

Error Response - Self Transfer (400 Bad Request)

{
  "status": "error",
  "message": "Self-transfers are not allowed."
}

Error Response - Policy Violation (403 Forbidden)

{
  "status": "error",
  "message": "Transfer not allowed: Target game 'Space Warriors' (ID: 987654321098) is not in the linked games list for source game 'Adventure Quest'. Linked games: [123456789012, 456789012345]",
  "error_code": "TRANSFER_POLICY_VIOLATION"
}

Error Response - Games Excluded From Each Other (403 Forbidden)

One of the two games has excluded the other from transfers. This applies in both directions and even with universal transfers on. The message never says which game set it.

{
  "status": "error",
  "error_code": "TRANSFER_GAME_EXCLUDED",
  "message": "Transfers between these games aren't available."
}

Error Response - Transfers Briefly Unavailable (503 Service Unavailable)

INVO could not confirm the transfer settings between the two games at that moment, so the transfer was not started and nothing was reserved. Retry after a short wait with the same client_request_id.

{
  "status": "error",
  "error_code": "TRANSFER_RULES_UNAVAILABLE",
  "message": "Transfers are briefly unavailable. Please try again in a moment.",
  "retryable": true
}

Error Response - Into a Platform Store Title from Outside It (409 Conflict)

{
  "status": "error",
  "error_code": "NON_STEAM_VALUE_INTO_STEAM_BLOCKED",
  "message": "This title only accepts currency from other Steam titles."
}

Fires when transferring from a title outside a platform store into a title distributed through that store (a per-title setting INVO manages): currency from outside cannot enter. Currency bought through a platform store is subject to that store's rules: moving it to a title outside the store answers 409 STEAM_VALUE_NON_TRANSFERABLE, which you must handle too. Destination lists already leave out titles this would block, and most integrations never see it. Match on error_code, not message text.

Error Response - Rate Limited (429 Too Many Requests)

{
  "error": "velocity_limit_exceeded",
  "message": "Hourly transfer limit of 10 transfers exceeded. Please try again later."
}

This 429 is a business limit (transfers per player per hour, 10 by default), so its body carries error, not error_code: match on error: "velocity_limit_exceeded". A request rate limit answers a different 429 with error_code: "RATE_LIMITED". Honour Retry-After when it is present.

What Happens After Initiation

Sender Approval Opens

The player approves the transfer with a passkey (web and mobile) or a QR device approval (consoles, TVs and desktop clients). Your side then calls POST /api/sdk/transfers/{id}/approve with that proof.

Funds Reserved

The transfer amount is immediately reserved from the player's available balance. This ensures the funds are secured for the transfer but not yet deducted. If the transfer fails or expires, the reserved amount is automatically returned to their available balance.

Next Steps

The player has until verification_expires_at to approve. Once approved, the transfer waits for the player to collect it in the target title with their player token. See Cross-Game Transfer, stage by stage.

Validation Rules & Restrictions

Phone Number Format

  • • Must start with country code (e.g., +1 for US)
  • • 10 to 15 digits after the +, country code included
  • • Only digits allowed after the + symbol
  • • Examples: +1234567890, +441234567890

Transfer Amount Limits

  • • At least the sending title's minimum transfer amount, in its own currency. The default is 1.00; each title sets its own in the console. Below it: 400 with error_code AMOUNT_BELOW_MINIMUM, minimum_amount and currency_name
  • • Must not exceed the title's maximum (if it sets one) or the network ceiling per transfer. Above it: 400 with error_code AMOUNT_ABOVE_MAXIMUM and maximum_amount
  • • Must not exceed player's available balance
  • • Daily transfer limit: one platform-wide amount per player per day, counted across all titles and set by INVO (not per title)
  • • Hourly transfer limit: 10 transfers per player

Title Status Requirements

  • • Source Title: Must be in 'live' status (not 'testing')
  • • Target Title: Must be in 'live' status (not 'testing')
  • • Self-Transfers: Not allowed (use currency sends instead)
  • • Cross-Game: Requires both titles to be live and policy-compliant

Transfer Policy Requirements

  • • Universal Transfers (universal_transfers: "yes"): Can transfer to any live title
  • • Linked Transfers (universal_transfers: "no"): Can only transfer to titles in linked_game_ids
  • • Source title must have allows_outgoing_transfers enabled
  • • Target title must have allows_incoming_transfers enabled
  • • Target title must be found in ecosystem

Duplicate Prevention

  • • client_request_id must be unique per title
  • • Rapid identical transfers (same amount/target) are blocked
  • • Rate limiting prevents spam attempts
  • • Velocity limits prevent excessive transfer activity

Implementation Best Practices

User Experience

  • • Use the /available-destinations endpoint to get valid target titles
  • • Validate phone number format before submitting
  • • Show transfer fee preview before confirmation
  • • Display clear error messages for validation failures
  • • Provide phone number formatting hints (+1234567890)
  • • Show target title name clearly in transfer UI
  • • Explain the difference between transfers and currency sends

Error Handling

  • • Parse API error responses for user-friendly messages
  • • Handle rate limiting with appropriate retry delays
  • • Implement exponential backoff for server errors
  • • Log all transfer attempts for debugging
  • • Provide fallback options when services are unavailable
  • • Handle policy violations gracefully with clear explanations

Security

  • • Always call from secure server, never from a client
  • • Validate all input data before API submission
  • • Use unique client_request_id for each transfer
  • • Implement proper timeout handling
  • • Never expose secret keys in client code
  • • Validate target_game_id against allowed destinations

Transfer Flow

  • • Save transaction_id for subsequent verification calls
  • • Show the approval step (passkey, or QR device approval) immediately after success
  • • Show a countdown to verification_expires_at
  • • Provide option to cancel transfer before verification
  • • Update player balance display to show reserved amount
  • • Inform player that claim code will be generated after verification

Integration Checklist

Before Going Live