Initiate Cross-Game Transfer
Start a secure transfer of in-game currency to another game in your ecosystem. This endpoint handles validation, fee calculation, balance reservation, and SMS verification initiation for cross-game transfers.
Transfer vs Currency Send
Cross-Game Transfers are for moving currency from your game to another specific game where the same player will receive it.
For player-to-player currency sends (including peer-to-peer within your game), use the/api/currency-sends/initiate-send endpoint instead.
Key Difference: Transfers move currency between games for the same player account, while Currency Sends allow any player to send currency to any other player via phone number.
/api/transfers/initiate-transfer
Initiates a cross-game transfer with SMS verification
Security Requirement
This endpoint must only be called from your secure game server. Never call it directly from a game client.
Authentication Required
All requests must include your game's secret key in the request headers:
X-Game-Secret-Key: your_game_secret_key_hereRequest Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| client_request_id | string | Yes | Unique ID for this transfer attempt (prevents duplicates) |
| source_player_name | string | Yes | Display name of the player initiating the transfer |
| source_player_email | string | Yes | Email address of the source player |
| source_player_phone | string | Yes | Phone number for SMS verification (format: +1234567890) |
| target_player_email | string | Yes | 🔒 SECURITY: Email address of intended recipient. Required as of 2026-05-07 so the phone-share approval gate can fire at initiate time, before any transaction is staged. If this email is new on the network and the supplied phone is already on file under a different email, the response is a 409 PHONE_SHARE_APPROVAL_REQUIRED with an SMS auto-sent to the existing phone owner. |
| target_player_phone | string | Yes | 🔒 SECURITY: Phone number of intended recipient. Claim code can ONLY be redeemed by this number (format: +1234567890) |
| target_game_id | integer | Yes | ID of the destination game (must be different from source game) |
| amount | string | Yes | Amount to transfer (as decimal string, e.g. "100.50") |
Implementation Examples
UnityUnity C# Transfer Initiation
// This function MUST be called on your secure server, NOT the client.
public IEnumerator InitiateTransfer(string clientRequestId, string playerName,
string playerEmail, string playerPhone, string targetPlayerEmail, string targetPlayerPhone,
int targetGameId, string amount,
Action<string> onSuccess, Action<string> onError)
{
string url = "https://invo.network/api/transfers/initiate-transfer";
var requestData = new {
client_request_id = clientRequestId,
source_player_name = playerName,
source_player_email = playerEmail,
source_player_phone = playerPhone,
target_player_email = targetPlayerEmail, // REQUIRED 2026-05-07: gates phone-share check at initiate
target_player_phone = targetPlayerPhone, // SECURITY: Claim code can only be used by this phone
target_game_id = targetGameId, // Must be different from your game ID
amount = amount
};
string jsonBody = JsonUtility.ToJson(requestData);
using (UnityWebRequest request = new UnityWebRequest(url, "POST"))
{
byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonBody);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("X-Game-Secret-Key", "your_game_secret_key_here");
request.SetRequestHeader("Content-Type", "application/json");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
onSuccess?.Invoke(request.downloadHandler.text);
}
else
{
onError?.Invoke(request.error + ": " + request.downloadHandler.text);
}
}
}UnrealUnreal C++ Transfer Initiation
// This function MUST be called on your secure server.
void AYourGameMode::InitiateTransfer(const FString& ClientRequestId,
const FString& PlayerName, const FString& PlayerEmail,
const FString& PlayerPhone, const FString& TargetPlayerEmail,
const FString& TargetPlayerPhone, int32 TargetGameId, const FString& Amount)
{
TSharedPtr<FJsonObject> RequestObj = MakeShareable(new FJsonObject);
RequestObj->SetStringField("client_request_id", ClientRequestId);
RequestObj->SetStringField("source_player_name", PlayerName);
RequestObj->SetStringField("source_player_email", PlayerEmail);
RequestObj->SetStringField("source_player_phone", PlayerPhone);
RequestObj->SetStringField("target_player_email", TargetPlayerEmail); // REQUIRED 2026-05-07: gates phone-share check at initiate
RequestObj->SetStringField("target_player_phone", TargetPlayerPhone); // SECURITY: Claim code can only be used by this phone
RequestObj->SetNumberField("target_game_id", TargetGameId);
RequestObj->SetStringField("amount", Amount);
FString RequestBody;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&RequestBody);
FJsonSerializer::Serialize(RequestObj.ToSharedRef(), Writer);
TSharedRef<IHttpRequest> Request = FHttpModule::Get().CreateRequest();
Request->SetURL("https://invo.network/api/transfers/initiate-transfer");
Request->SetVerb("POST");
Request->SetHeader("X-Game-Secret-Key", "your_game_secret_key_here");
Request->SetHeader("Content-Type", "application/json");
Request->SetContentAsString(RequestBody);
Request->OnProcessRequestComplete().BindUObject(this, &AYourGameMode::OnTransferInitiated);
Request->ProcessRequest();
}GodotGodot GDScript Transfer Initiation
# This function MUST be called on your secure server.
func initiate_transfer(client_request_id: String, player_name: String,
player_email: String, player_phone: String,
target_player_email: String, target_player_phone: String,
target_game_id: int, amount: String):
var url = "https://invo.network/api/transfers/initiate-transfer"
var headers = [
"X-Game-Secret-Key: your_game_secret_key_here",
"Content-Type: application/json"
]
var body = {
"client_request_id": client_request_id,
"source_player_name": player_name,
"source_player_email": player_email,
"source_player_phone": player_phone,
"target_player_email": target_player_email, # REQUIRED 2026-05-07: gates phone-share check at initiate
"target_player_phone": target_player_phone, # SECURITY: Claim code can only be used by this phone
"target_game_id": target_game_id, # Must be different from your game ID
"amount": amount
}
http_request.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(body))
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 REQUIRED as of 2026-05-07 — required so the
// phone-share approval gate can fire at initiate time, before any
// transaction is staged. See PHONE_SHARE_APPROVAL_REQUIRED docs.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 replies YES or NO via SMS within 15 minutes. 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. Please verify with the SMS PIN sent to your phone.",
"transaction_id": "txn_abc123def456",
"order_id": "TFRO_1719235200_A1B2C3D4",
"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"
},
"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
}
}Pending Guardian Approval (202 Accepted)
{
"status": "pending_guardian_approval",
"message": "Transfer initiated. SMS PIN sent to your phone, but verification is held until the guardian on file approves via SMS.",
"transaction_id": "txn_abc123def456",
"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/txn_abc123def456/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–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 game-developer SDK integrations and platform-tier partners. The OTP SMS is auto-sent — Invo texts the code to the existing phone owner before returning the 409. Streamlined recovery: have the owner read the SMS, 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 (Twilio outage fallback). 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 received an SMS-PIN. 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 SMS 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 SMS sent, no approval_id returned, surface "please wait before requesting another code."
Three valid 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/approvewith{ approval_id, otp }. - Approve via SMS reply: text the code (or
YES <code>) back from their phone. Invo's inbound webhook stamps the approval server-side. - Reject (new, 2026-05-14): either call
POST /api/wallet/phone-share/rejectwith{ approval_id, otp }from your UI, OR replyNO(orNO <code>) by SMS from the phone on file. Rejected rows are terminal — the requesting email must re-trigger a fresh request to retry. A subsequent/phone-share/initiatefor the same pair creates a new approval row.
If a user takes either SMS path, your client gets no direct callback — poll GET /api/wallet/phone-share/status?phone=...&email=... every 3–5s while your panel is open. Auto-retry the original action when approved: true; surface a clear rejection message and offer a "try again" affordance if the user reports a NO reply (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 (same game) are not allowed through this endpoint. Use currency sends for peer-to-peer transactions within your game."
}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 - Rate Limited (429 Too Many Requests)
{
"error": "velocity_limit_exceeded",
"message": "Hourly transfer limit of 10 transfers exceeded. Please try again later."
}What Happens After Initiation
SMS PIN Sent
The player receives an SMS with a 6-digit verification PIN at the provided phone number. The message format is:
"Your Invo transfer from Adventure Quest to Space Warriors verification code is 123456. Valid for 10 minutes. Our employees will never ask you for this code."
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 10 minutes to verify the SMS PIN using the /verify-sms endpoint. After verification, a claim code will be generated and sent to the player, which can then be used in the target game.
Validation Rules & Restrictions
Phone Number Format
- • Must start with country code (e.g., +1 for US)
- • Must be 10-15 digits total after country code
- • Only digits allowed after the + symbol
- • Examples: +1234567890, +441234567890
Transfer Amount Limits
- • Must be greater than currency minimum (usually 0.01)
- • Must not exceed currency maximum (if set by game)
- • Must not exceed player's available balance
- • Daily transfer limit: 5,000 currency units per player
- • Hourly transfer limit: 10 transfers per player
Game Status Requirements
- • Source Game: Must be in 'live' status (not 'testing')
- • Target Game: Must be in 'live' status (not 'testing')
- • Self-Transfers: Not allowed (use currency sends instead)
- • Cross-Game: Requires both games to be live and policy-compliant
Transfer Policy Requirements
- • Universal Transfers (universal_transfers: "yes"): Can transfer to any live game
- • Linked Transfers (universal_transfers: "no"): Can only transfer to games in linked_game_ids
- • Source game must have allows_outgoing_transfers enabled
- • Target game must have allows_incoming_transfers enabled
- • Target game must be found in ecosystem
Duplicate Prevention
- • client_request_id must be unique per game
- • 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-destinationsendpoint to get valid target games - • 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 game 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 game client
- • Validate all input data before API submission
- • Use unique client_request_id for each transfer
- • Implement proper timeout handling
- • Never expose game secret keys in client code
- • Validate target_game_id against allowed destinations
Transfer Flow
- • Save transaction_id for subsequent verification calls
- • Display SMS PIN entry UI immediately after success
- • Show countdown timer for PIN expiration (10 minutes)
- • 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