Claim Cross-Game Transfer
Complete a cross-game transfer by claiming the funds with a claim code. This endpoint validates the claim code, creates or updates the recipient player, and credits the transferred currency to their balance.
/api/transfers/claim-transfer
Claims a transfer using the provided claim code
Authentication Required
Must include the target game's secret key (where the funds will be received):
X-Game-Secret-Key: target_game_secret_key_hereRequest Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| claim_code | string | Yes | Claim code received from SMS or transfer initiator. Format isABCDE-12345— 5 uppercase letters (excluding O) + hyphen + 5 digits (excluding 0). Total 11 characters. Case-sensitive. |
| target_player_name | string | Yes | Display name of the player claiming the transfer |
| target_player_email | string | Yes | Email address of the recipient player |
| target_player_phone | string (E.164) | Yes | 🔒 SECURITY: Phone of the claiming player. MUST match the target_player_phone sent at initiation. Strict E.164 — leading + is required (e.g., +15551234567). Without the + the request returns 400 "Phone number must start with country code." See callout below. |
| target_currency_id | integer | Yes | Currency ID in the target game where funds will be credited |
Phone format — strict E.164
target_player_phone is validated as strict E.164: a leading + followed by 10–15 digits. Common formatting ((555) 123-4567, spaces, dashes) is stripped server-side, but the + itself is required. Without it the backend returns:
{"status": "error", "message": "Phone number must start with country code (e.g., +1234567890)"}The backend will not auto-prepend a default country code (e.g., assuming 5551234567 means +1) because the universal player identity hash and cross-tenant phone-binding security check require phones to canonicalize identically across tenants — guessing would silently misalign US and Canadian numbers with the same trailing digits.
Your UI should canonicalize before submit. Either use a phone-input component that always emits E.164 (react-phone-number-input, libphonenumber-js), or normalize manually:
function toE164(input, defaultCountry = 'US') {
if (!input) return null;
if (input.startsWith('+')) return input.replace(/[^\d+]/g, '');
const digits = input.replace(/\D/g, '');
if (defaultCountry === 'US' && digits.length === 10) return `+1${digits}`;
if (defaultCountry === 'US' && digits.length === 11 && digits[0] === '1') return `+${digits}`;
return null; // ambiguous — surface a 'include country code' hint
}The same rule applies to every phone field on every transfer/send endpoint (source_player_phone, target_player_phone, receiver_player_phone) — fix it once at your input layer.
Claim Code Requirements
Code Format
- • Format:
ABCDE-12345(5 letters · hyphen · 5 digits, 11 chars total) - • Uppercase letters only, excluding
O(to avoid 0/O confusion) - • Digits 1–9 only, excluding
0(same reason) - • Case-sensitive, unique per transfer, one-time use
- • Example:
KJMRS-47281
Time Limits
- • Code expires in 24 hours
- • Maximum 5 claim attempts
- • Failed attempts count toward rate limits
- • Expired codes cannot be reused
Implementation Examples
UnityUnity C# Transfer Claim
// This function MUST be called on your secure server, NOT the client.
public IEnumerator ClaimTransfer(string claimCode, string playerName,
string playerEmail, string playerPhone, int targetCurrencyId,
Action<string> onSuccess, Action<string> onError)
{
string url = "https://invo.network/api/transfers/claim-transfer";
var requestData = new {
claim_code = claimCode,
target_player_name = playerName,
target_player_email = playerEmail,
target_player_phone = playerPhone,
target_currency_id = targetCurrencyId
};
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", "target_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 Claim
// This function MUST be called on your secure server.
void AYourGameMode::ClaimTransfer(const FString& ClaimCode,
const FString& PlayerName, const FString& PlayerEmail,
const FString& PlayerPhone, int32 TargetCurrencyId)
{
TSharedPtr<FJsonObject> RequestObj = MakeShareable(new FJsonObject);
RequestObj->SetStringField("claim_code", ClaimCode);
RequestObj->SetStringField("target_player_name", PlayerName);
RequestObj->SetStringField("target_player_email", PlayerEmail);
RequestObj->SetStringField("target_player_phone", PlayerPhone);
RequestObj->SetNumberField("target_currency_id", TargetCurrencyId);
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/claim-transfer");
Request->SetVerb("POST");
Request->SetHeader("X-Game-Secret-Key", "target_game_secret_key_here");
Request->SetHeader("Content-Type", "application/json");
Request->SetContentAsString(RequestBody);
Request->OnProcessRequestComplete().BindUObject(this, &AYourGameMode::OnTransferClaimed);
Request->ProcessRequest();
}GodotGodot GDScript Transfer Claim
# This function MUST be called on your secure server.
func claim_transfer(claim_code: String, player_name: String,
player_email: String, player_phone: String, target_currency_id: int):
var url = "https://invo.network/api/transfers/claim-transfer"
var headers = [
"X-Game-Secret-Key: target_game_secret_key_here",
"Content-Type: application/json"
]
var body = {
"claim_code": claim_code,
"target_player_name": player_name,
"target_player_email": player_email,
"target_player_phone": player_phone,
"target_currency_id": target_currency_id
}
http_request.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(body))
Request & Response Examples
Example Request
POST /api/transfers/claim-transfer
Content-Type: application/json
X-Game-Secret-Key: target_game_secret_key_here
{
"claim_code": "KJMRS-47281",
"target_player_name": "PlayerTwo",
"target_player_email": "player2@example.com",
"target_player_phone": "+1987654321",
"target_currency_id": 1
}Success Response (200 OK)
{
"status": "success",
"message": "Transfer claimed successfully.",
"transaction_id": "txn_abc123def456",
"transfer_details": {
"amount_received": "450.00",
"source_game": "Adventure Quest",
"target_currency": "Gold",
"target_player": "PlayerTwo",
"new_balance": "1450.00"
},
"completion_time": "2024-06-24T15:30:00Z",
"order_id": "TFRO_1719235200_A1B2C3D4"
}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 claiming player's email is new on a phone that's already on file with a different email. Same contract for game-developer SDK integrations and platform-tier partners. The OTP SMS is auto-sent — have the existing phone 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 claim with the same claim code. Only call /phone-share/initiate manually if code_sent: false. Match on error_code, not message text.
If already_approved: true: auto-retry the claim 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."
Two equally valid approval paths the user can choose between: (1) type the 6-digit code into your UI → call POST /api/wallet/phone-share/approve, or (2) text the code back from their phone (Invo's inbound webhook approves server-side). If a user takes path 2, your client gets no direct callback — poll GET /api/wallet/phone-share/status?phone=...&email=... every 3–5s while your panel is open and auto-retry the claim when approved: true.
Account Selection — Multi-Email-on-Phone (200 OK with picker shape)
{
"status": "needs_account_selection",
"claim_code": "ABCDE-12345",
"message": "Multiple accounts share this phone number on the destination game and the supplied email did not match any of them. Re-submit with target_player_id set to the chosen account, or fix the target_player_email.",
"candidates": [
{ "player_id": 12345, "email_hint": "al***@example.com" },
{ "player_id": 67890, "email_hint": "bo***@example.com" }
]
}Why this fires: Invo's wallet model supports multiple emails on one verified phone (parent + child, two-account gamers, family-shared device). When the destination game has 2+ Player rows on the same phone, the claim endpoint resolves which one to credit using this priority order:
target_player_idin the request body (must match a candidate) — your client's explicit picker selection.target_player_emailmatches exactly one candidate's email — the common case. The user already typed their email into your claim form, so we don't prompt twice. No picker fires.- Picker response (the 200 above) — only when steps 1 and 2 both fail.
Recovery: render a picker from candidates showing each email_hint, let the user tap one, then re-submit /claim-transfer with the same body PLUS target_player_id set to the chosen player_id. Status code is 200 not an error — the call hasn't failed, it just needs disambiguation.
Most claims never see this response. It surfaces only in genuinely ambiguous cases (e.g., the user typo'd their email). Implementing the picker UI is a low-effort defensive measure — same shape applies to /claim-currency.
Error Response - Invalid Claim Code (400 Bad Request)
{
"status": "error",
"message": "Invalid claim code.",
"attempts_remaining": 4
}Error Response - Wrong Claim Endpoint (400 Bad Request)
Returned when a claim code exists for this game but was minted by the player-to-player send flow rather than the self-transfer flow. Switch your call to /api/currency-sends/claim-currency with receiver_* field names.
{
"status": "error",
"error_code": "WRONG_CLAIM_ENDPOINT",
"message": "This claim code is for a player-to-player currency send. Use POST /api/currency-sends/claim-currency instead.",
"expected_endpoint": "/api/currency-sends/claim-currency",
"expected_body_fields": [
"claim_code",
"receiver_player_name",
"receiver_player_email",
"receiver_player_phone"
]
}Switch on error_code === "WRONG_CLAIM_ENDPOINT" in your handler — the message text may evolve, but the code and expected_endpoint field are stable contract.
Error Response - Expired Code (400 Bad Request)
{
"status": "error",
"message": "Claim code is not valid: Claim code expired."
}Error Response - Transfer Not Found (404 Not Found)
{
"status": "error",
"message": "Invalid claim code or transfer not intended for this game."
}Error Response - Phone Number Mismatch (403 Forbidden)
{
"status": "error",
"message": "Phone number mismatch: This claim code can only be redeemed by the intended recipient's phone number."
}What Happens During a Successful Claim
Currency Credited
The net transfer amount (original amount minus fees) is immediately credited to the recipient's balance in the target game. If the player doesn't exist in the target game, a new player account is automatically created.
Transfer Completed
The transfer status is updated to "completed" and the transaction is finalized. The sender's reserved funds are permanently deducted, and fee distributions are recorded for all participating games and the platform.
SMS Confirmation
A confirmation SMS is sent to the recipient's phone number confirming the successful claim:
"Invo Transfer Claimed: You have successfully claimed 450.00 Gold from a transfer via Adventure Quest."
Automatic Player Creation
New Player Handling
If the recipient email doesn't exist in the target game, the system automatically creates a new player account with the provided information:
- • Player Name: Set from target_player_name
- • Email: Normalized and set from target_player_email
- • Phone: Set from target_player_phone
- • Balance: New currency balance created with claimed amount
- • Join Date: Set to current timestamp
Note: If the player already exists, their name and phone number are updated with the new information if different.
Security & Validation
🔒 BREAKING CHANGE: Phone-Based Security
As of the latest update, claim codes can ONLY be redeemed by the exact phone number specified during transfer initiation via target_player_phone.
- • The
target_player_phoneprovided during claim MUST exactly match thetarget_player_phonefrom initiation - • Phone mismatch results in 403 Forbidden error
- • This prevents unauthorized claim code sharing and redemption
- • Enhances security for cross-game transfers
Claim Code Validation
- • Hashed storage - codes never stored in plain text
- • Case-sensitive matching
- • Maximum 5 claim attempts per code
- • 24-hour expiration window
- • One-time use enforcement
- • Phone number verification required
Rate Limiting
- • 30 claim attempts per hour per player
- • 6 attempts per minute per claim code
- • 150 attempts per hour per IP address
- • Progressive blocking for repeated failures
Input Validation
- • Phone number format validation (+country code)
- • Email format validation
- • Currency ID must belong to target game
- • Player name length and character validation
Fraud Prevention
- • Failed claim attempt tracking
- • IP-based suspicious activity monitoring
- • Game authorization validation
- • Circuit breaker for service protection
Fee Distribution on Claim
When a transfer is successfully claimed, the fee distribution is automatically processed:
Source Game (3.5%)
The game that sent the currency receives 3.5% of the original transfer amount as compensation for losing a player's funds.
Target Game (3.5%)
The game that receives the currency gets 3.5% of the original transfer amount as compensation for accepting new funds.
Platform Fee (3.0%)
Invo Network retains 3.0% of the original transfer amount for providing the secure transfer infrastructure.
Common Error Scenarios
HTTP 400 - Bad Request
- • Invalid claim code: Code doesn't match or has wrong format
- • Code expired: More than 24 hours have passed since generation
- • Max attempts reached: 5 incorrect claim attempts used up
- • Missing parameters: Required fields not provided
- • Wrong transfer state: Transfer not in pending_claim status
- • Invalid phone format: Phone number doesn't meet format requirements
- • Currency mismatch: target_currency_id doesn't belong to this game
HTTP 403 - Forbidden
- • Phone number mismatch: target_player_phone doesn't match the phone from initiation
- • Unauthorized claim: Only the intended recipient can redeem this claim code
HTTP 404 - Not Found
- • Transfer not found: Invalid claim code or transfer not intended for this game
- • Wrong target game: Transfer was meant for a different game
- • Currency not found: target_currency_id doesn't exist in this game
HTTP 429 - Too Many Requests
- • Player rate limit: Too many claim attempts from this player
- • Claim code rate limit: Too many attempts for this specific claim code
- • IP rate limit: Too many requests from the same network
- • Invalid claim blocking: Player blocked due to repeated invalid claims
- • CLAIM_LOCKED (new, 2026-05-14): the DB-backed multi-dimensional lockout has tripped — see the dedicated
error_code: CLAIM_LOCKEDresponse below.
HTTP 429 - CLAIM_LOCKED (multi-dimensional claim-attempt lockout)
Shipped 2026-05-14. Replaces the old Redis-only claim_code_blocked counter (which failed open on Redis outage and could be evaded by rotating the submitted email). Every failed claim attempt — wrong code, wrong phone, wrong endpoint, wrong PIN — writes an audit row to claim_attempt_failures. ANY of three dimensions tripping locks the attempt out.
HTTP/1.1 429 Too Many Requests
Retry-After: 1800
{
"status": "error",
"error_code": "CLAIM_LOCKED",
"error": "invalid_claim_code_blocked",
"message": "Too many failed claim attempts for this phone number. Please wait 30 minutes before trying again.",
"locked_dimension": "phone",
"retry_after_seconds": 1800,
"retry_after": 1800
}Two equivalent ways to match: new integrations should switch on error_code === "CLAIM_LOCKED". Existing integrations whose security-block handler keys on the legacy field can switch on error === "invalid_claim_code_blocked" — both fields are present on every lockout response. Same goes for the duration field: retry_after_seconds (canonical) and retry_after (legacy alias) carry the same value.
| Dimension | Threshold | Window | Lockout |
|---|---|---|---|
target_player_phone | 5 failures | trailing 30 min | 30 min |
target_player_email | 5 failures | trailing 30 min | 30 min |
ip_address | 20 failures | trailing 60 min | 60 min |
Client behavior: match on error_code === "CLAIM_LOCKED". Read retry_after_seconds (also exposed in the Retry-After header) to drive your back-off. The locked_dimension field is mainly informational — your UI should treat all three dimensions the same way (show the retry timer; don't leak which specific dimension tripped to the user, since that's enumeration-adjacent). locked_dimension: "db_unavailable" appears if Invo's audit DB is briefly unreachable (fail-CLOSED behavior); retry_after_seconds is 60s in that case — short retry, not a real lockout.
HTTP 500 - Server Error
- • Database connectivity: Temporary database issues
- • SMS service error: Unable to send confirmation SMS
- • Circuit breaker open: Service temporarily unavailable
- • Fee distribution error: Error recording fee distributions (claim still succeeds)
Important Implementation Notes
Game Secret Key
The X-Game-Secret-Key header must contain the secret key of the target game (where the funds will be received), not the source game. This ensures only authorized games can claim transfers intended for them.
Atomic Operations
The claim process is atomic - either the entire operation succeeds (currency credited, fees distributed, notifications sent) or it fails completely with no partial state changes. This ensures data consistency.
Automatic Cleanup
If claim codes expire (24 hours), the system automatically returns the net transfer amount to the sender's balance and marks the transfer as expired. No manual intervention is required.
Implementation Best Practices
User Experience
- • Provide clear claim code input validation
- • Display remaining attempts when claim fails
- • Show clear success messages with amount received
- • Update player balance immediately in UI after successful claim
- • Provide claim code format hints (e.g., "Enter code like: KJMRS-47281")
Error Handling
- • Parse error responses to show user-friendly messages
- • Handle rate limiting gracefully with retry suggestions
- • Differentiate between expired codes and invalid codes
- • Log claim attempts for customer support purposes
- • Implement proper timeout handling for network requests
Security
- • Never log or store claim codes in your application
- • Validate claim code format before API submission
- • Clear claim code input fields after processing
- • Use HTTPS for all API communications
- • Validate phone number format on client side
Player Data
- • Pre-populate player fields if user is already logged in
- • Validate email format before submission
- • Ensure phone numbers include country codes
- • Show currency selection if multiple currencies available
- • Confirm player details before final claim submission