Initiate Player Currency Send
Start a secure currency send to another player, addressed by their phone number. Supports peer-to-peer sends within your own game or platform and cross-game sends elsewhere in the Invo network. The sender then approves it with a passkey, or by scanning a QR code on consoles and native desktop builds.
Currency Send Types
Peer-to-Peer Sends
Send currency to another player within your own title by setting receiving_game_id to your title's ID.
- • Player-to-player transactions
- • Marketplace purchases
- • Gifting between friends
- • In-app services and tips
Cross-Game Sends
Send currency to players in other titles by setting receiving_game_id to a different title's ID.
- • Cross-game community building
- • Ecosystem-wide interactions
- • Portfolio value distribution
- • Multi-title rewards
/api/currency-sends/initiate-send
Initiates a player-to-player currency send 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_herePolicy Requirements
For cross-game sends, your title's transfer policy determines which destinations are allowed:
- • Universal Transfers: Can send to any live title in the ecosystem
- • Linked Transfers: Can only send to titles in your linked_game_ids list
- • Titles in Testing: Cannot send to or from titles in testing status
Request Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| client_request_id | string | Yes | Unique ID for this send attempt (prevents duplicates) |
| sender_player_name | string | Yes | Display name of the player sending the currency |
| sender_player_email | string | Yes | Email address of the sending player |
| sender_player_phone | string | Yes | The sender's phone number on file, E.164 (format: +1234567890) |
| receiver_player_email | string | No (recommended) | 🔒 SECURITY: Receiver's email address. Optional, and recommended: routing is by phone, so a phone-only send still reaches the phone's owner. When supplied, it lets the phone-share approval gate fire at initiate time, before any transaction is staged, and it is used for the claim email only when that email is already on file for the receiver phone (otherwise nothing is sent and claim_delivery_planned is "none"). 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 and a one-time code already delivered to the existing phone owner. |
| receiver_player_phone | string | Yes | 🔒 SECURITY: Receiver's phone number. Claim code can ONLY be redeemed by this number (format: +1234567890) |
| receiving_game_id | integer | Yes | ID of the title where currency will be received (same ID for peer-to-peer) |
| amount | string | Yes | Amount to send (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/currency-sends/initiate-send', {
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
sender_player_name: 'PlayerOne',
sender_player_email: 'player1@example.com',
sender_player_phone: '+15555550111',
receiver_player_email: 'recipient@example.com', // optional, recommended
receiver_player_phone: '+15555550122',
receiving_game_id: 123456789012,
amount: '500.00',
}),
});
const data = await res.json();
// 201: data.transaction_id; the sender approves next (see Peer Send, stage by stage).Python (server)
# Python (server)
import os, uuid, requests
r = requests.post(
"https://invo.network/api/currency-sends/initiate-send",
headers={"X-Game-Secret-Key": os.environ["INVO_GAME_SECRET"]},
json={
"client_request_id": str(uuid.uuid4()),
"sender_player_name": "PlayerOne",
"sender_player_email": "player1@example.com",
"sender_player_phone": "+15555550111",
"receiver_player_email": "recipient@example.com",
"receiver_player_phone": "+15555550122",
"receiving_game_id": 123456789012,
"amount": "500.00",
},
timeout=15,
)
data = r.json()
# 201: data['transaction_id']; the sender approves next.Request & Response Examples
Example: Peer-to-Peer Send (Same Title)
POST /api/currency-sends/initiate-send
Content-Type: application/json
X-Game-Secret-Key: your_game_secret_key_here
{
"client_request_id": "p2p_send_12345_20240624",
"sender_player_name": "PlayerOne",
"sender_player_email": "player1@example.com",
"sender_player_phone": "+1234567890",
"receiver_player_email": "recipient@example.com",
"receiver_player_phone": "+1987654321",
"receiving_game_id": 123456789012,
"amount": "500.00"
}
// Note: receiving_game_id matches your own game ID for peer-to-peer sends
// Note: receiver_player_email is optional and recommended. When supplied, the
// phone-share approval gate can fire at initiate time, before any
// transaction is staged. See PHONE_SHARE_APPROVAL_REQUIRED docs.Example: Cross-Game Send
POST /api/currency-sends/initiate-send
Content-Type: application/json
X-Game-Secret-Key: your_game_secret_key_here
{
"client_request_id": "cross_send_67890_20240624",
"sender_player_name": "PlayerOne",
"sender_player_email": "player1@example.com",
"sender_player_phone": "+1234567890",
"receiver_player_email": "recipient@example.com",
"receiver_player_phone": "+1987654321",
"receiving_game_id": 987654321098,
"amount": "250.00"
}
// Note: receiving_game_id is different from your game ID for cross-game sendsWatch for HTTP 202, guardian approval pending
If the sending account is flagged as a minor on Invo, this endpoint returns 202 Accepted instead of 201, with a guardian_approval block. The send 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": "Currency send initiated. Approve on your enrolled device to continue.",
"transaction_id": "TXN20260904121000123456ABC123",
"order_id": "ORD_1757000000_A1B2C3D4",
"new_balance": "9500.00",
"currency_name": "Gold",
"verification_method": "in_app",
"verification_expires_at": "2026-01-01T12:10:00Z",
"claim_delivery_planned": "email",
"send_details": {
"sending_game": "Adventure Quest",
"receiving_game": "Space Warriors",
"receiving_game_id": "987654321098",
"currency": "Gold",
"currency_id": 1,
"amount_initiated": "500.00",
"fees_preview": {
"total_fee": "50.00",
"source_rate_pct": "3.5",
"target_rate_pct": "3.5",
"sending_game_fee": "17.50",
"receiving_game_fee": "17.50",
"platform_fee": "15.00",
"net_amount": "450.00"
},
"receiver_phone": "+1987654321",
"send_type": "cross_game",
"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 sender's available balance after the send amount has been reserved. Display this value rather than recomputing it client-side. fees_preview also includes source_rate_pct and target_rate_pct.
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; do not build on it. In both cases, approve with a passkey or a device approval grant and call POST /api/sdk/send/{id}/approve, both paths are live on the transaction regardless of this value. verification_expires_at is the end of the approval window. Full worked examples: Peer Send, stage by stage.
Pending Guardian Approval (202 Accepted)
{
"status": "pending_guardian_approval",
"transaction_id": "TXN20260904121000123456ABC123",
"order_id": "ORD_1757000000_A1B2C3D4",
"guardian_approval": {
"approval_id": "1f2a8c0d-...",
"state": "pending",
"expires_at": "2026-05-04T22:15:00+00:00",
"poll_endpoint": "/api/transactions/TXN20260904121000123456ABC123/approval-status"
},
"send_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. 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-send as well as /claim-currency. 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/approvewith{ approval_id, otp }. - Reject (new, 2026-05-14): either call
POST /api/wallet/phone-share/rejectwith{ 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/initiatefor 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 - Policy Violation (403 Forbidden)
{
"status": "error",
"message": "Currency send not allowed: Receiving game 'Space Warriors' (ID: 987654321098) is not in the linked games list for sending game 'Adventure Quest'. Linked games: [123456789012, 456789012345]",
"error_code": "CURRENCY_SEND_POLICY_VIOLATION"
}Error Response - Title in Testing (403 Forbidden)
{
"status": "error",
"error_code": "TARGET_GAME_NOT_LIVE",
"game_status": "testing",
"message": "Currency sends require BOTH games to be live. The receiving game 'Test Game' is currently in testing and cannot receive currency until its owner switches it to Live in their developer console (Game Settings > Status)."
}A sending title in Testing answers the same way with error_code: "SOURCE_GAME_NOT_LIVE". Match on error_code.
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 sending 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 currency send limit of 25 sends exceeded. Please try again later."
}This 429 is a business limit (sends per player per hour, 25 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 sender approves the send with a passkey (web and mobile) or a QR device approval (consoles, TVs and desktop clients). Your side then calls POST /api/sdk/send/{id}/approve with that proof.
Funds Reserved
The send amount is immediately reserved from the sender's available balance. This ensures the funds are secured for the send but not yet deducted. If the send fails or expires, the reserved amount is automatically returned to their available balance.
Next Steps
The sender has until verification_expires_at to approve. Once approved, a receiver in a game collects with their player token (minted with email + phone; created automatically). For a receiver outside the game, INVO emails the claim link when the receiver's email is already on file for that phone; otherwise the receiver collects in the game. claim_delivery_planned on this response predicts it: "email", "none" (the email you supplied is not on file for that phone, so nothing is sent), or the legacy "sms" (only when no receiver email was supplied and none is on file). See Peer Send, stage by stage.
Validation Rules & Restrictions
Phone Number Format
- • Both sender and receiver phones 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
Send Amount Limits
- • At least the sending title's minimum send amount, in its own currency. The default is 1.00; each title sets its own in the console. Below it: 400 with
error_codeAMOUNT_BELOW_MINIMUM,minimum_amountandcurrency_name - • Must not exceed the title's maximum (if it sets one) or the network ceiling per send. Above it: 400 with
error_codeAMOUNT_ABOVE_MAXIMUMandmaximum_amount - • Must not exceed sender's available balance
- • Daily send limit: one platform-wide amount per player per day, counted across all titles and set by INVO
- • Hourly send limit: 25 sends per player by default (a business limit, configurable by INVO; 429
velocity_limit_exceeded). This is separate from the request rate limit.
Title Status Requirements
- • Sending Title: Must be in 'live' status (not 'testing')
- • Receiving Title: Must be in 'live' status (not 'testing')
- • Peer-to-Peer: Always allowed within the same live title
- • Cross-Game: Requires both titles to be live and policy-compliant
Transfer Policy Requirements
- • Universal Transfers (universal_transfers: "yes"): Can send to any live title
- • Linked Transfers (universal_transfers: "no"): Can only send to titles in linked_game_ids
- • Sending title must have allows_outgoing_transfers enabled
- • Target title must be found in ecosystem
Duplicate Prevention
- • client_request_id must be unique per title
- • Rapid identical sends (same amount/receiver) are blocked
- • Rate limiting prevents spam attempts
- • Velocity limits prevent excessive send activity
Common Error Scenarios
HTTP 400 - Bad Request
- • Missing required fields
- • Invalid phone number format (sender or receiver)
- • Invalid amount (negative, too large, etc.)
- • Receiving title not found
- • Insufficient balance
HTTP 403 - Forbidden
- • Title in testing status (sender or receiver)
- • Transfer policy violation (not in linked titles)
- • Outgoing transfers not allowed
- • Universal transfers disabled and target not linked
HTTP 409 - Conflict
- • Duplicate client_request_id
- • Send already initiated or processed
HTTP 429 - Too Many Requests
- • Request rate limit: 100 initiate calls per sender per hour (429
RATE_LIMITED; see Rate Limits). The 25-sends business limit usually binds first. - • IP address exceeded rate limits
- • Velocity limits exceeded, a separate control that returns
velocity_limit_exceeded - • Rapid identical send detection
HTTP 500 - Server Error
- • Database connectivity issues
- • Internal system errors
Implementation Best Practices
User Experience
- • Use the
/available-destinationsendpoint to get valid receiving titles - • Validate phone numbers before submitting
- • Show send fee preview before confirmation
- • Display clear error messages for validation failures
- • Provide phone number formatting hints
- • Show receiving title name clearly in send UI
- • Distinguish between peer-to-peer and cross-game sends in UI
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 send attempts for debugging and monitoring
- • Provide fallback options when services are unavailable
- • Handle policy violations gracefully with clear explanations
Security
- • Always call from secure server, never from the client
- • Validate all input data before API submission
- • Use unique client_request_id for each send
- • Implement proper timeout handling
- • Never expose game secret keys in client code
- • Validate receiving_game_id against allowed destinations
Send Flow Management
- • 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 send before verification
- • Update sender balance display to show reserved amount
- • Tell the sender how the receiver will collect: INVO emails the claim link when the receiver's email is already on file for that phone; otherwise the receiver collects in the game (read
claim_delivery_planned) - • Handle both peer-to-peer and cross-game send flows