Guardian Approval
The Invo Network supports accounts flagged as belonging to a minor with a designated guardian. When a minor initiates a cross-game transfer or player-to-player send, the transaction is held until the guardian replies YES or NO to an SMS — within 15 minutes. This is the standard integration pattern going forward — implement it once and it covers all your users, including the majority who will never trigger the path.
Implement defensively — you can't detect minor status from your side
The minor flag lives on Invo's backend and is never exposed in API responses. There's no way for your client to look up "is this user a minor?" before initiating a transfer — and that's by design (privacy + compliance). Your integration should treat pending_guardian_approval as a normal possible response on every /initiate-* and /verify-sms call.
For adult accounts (the vast majority of your traffic), the 202 path simply never fires — the response shape, status codes, and timing are identical to what you have today. Implementing the handler costs you ~50 lines of UI code and a polling loop. Once it's in, you're done forever — Invo can flag any account as a minor at any time and your integration handles it without redeploy.
If you skip it: minor users on your platform see generic failures when they try to transfer/send. Adult users still work fine. Recommended: implement the 202 path as part of your standard integration checklist.
Context: this is part of the wallet rollout
Behind the scenes, Invo groups one human's emails (across tenants) under a single wallet keyed off their verified phone. This applies to all users, not just minors — many adults legitimately operate multiple emails on one phone (separate gaming accounts, pseudonymous handles, family-shared devices). The wallet model is what lets us:
- Skip redundant phone-share OTPs once an email is verified for a phone (better UX for everyone)
- Apply per-human guardrails like guardian approval (relevant when an account is flagged as a minor)
- Keep
identity_idstable across a user's emails network-wide
Your integration doesn't need to be aware of the wallet model directly — identity_id behaves the same as before, no new fields surface in API responses, and existing flows continue working. The 202 guardian-approval path documented here is the only partner-visible artifact of the wallet rollout, and it's the integration pattern we recommend for all new partners.
What is and isn't gated
Gated for minor accounts
- •
/api/transfers/initiate-transfer - •
/api/transfers/verify-sms - •
/api/currency-sends/initiate-send - •
/api/currency-sends/verify-sms
Not gated (intentionally)
- • Item purchases — currency was already approved when it entered the wallet
- • Currency top-ups — gated upstream at the parent's payment-method level
- • Claims — receiving currency, not sending it
The guardrail is on value movement off the account. Spending balance on items is treated as already authorized at deposit time.
Initiate response when guardian approval is needed
When a minor initiates a transfer or send, the standard /initiate-transfer or /initiate-send endpoint returns HTTP 202 Accepted instead of 200/201. The body includes all the standard fields plus a guardian_approval block:
{
"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_1715000000_ABC123",
"guardian_approval": {
"approval_id": "1f2a8c0d-...",
"state": "pending",
"expires_at": "2026-05-04T22:15:00+00:00",
"poll_endpoint": "/api/transactions/TXN_1715000000_ABC123/approval-status"
},
"transfer_details": { /* ...standard fields... */ },
"verification_required": { /* ...standard fields... */ }
}The user's SMS PIN is still sent at initiate time. Show your normal PIN-entry UI, but disable the submit button and surface a "Waiting for parent approval" indicator until the polling endpoint says approved.
Poll the approval status
/api/transactions/<transaction_id>/approval-status
Returns the current state of a transaction's guardian approval
Authentication: X-Game-Secret-Key header (same as other partner endpoints).
Recommended polling interval: 5–10 seconds.
Response while pending
{
"status": "ok",
"approval": {
"approval_id": "1f2a8c0d-...",
"transaction_id": "TXN_1715000000_ABC123",
"state": "pending",
"expires_at": "2026-05-04T22:15:00+00:00",
"decided_at": null,
"decision_source": null,
"action_description": "send 50 IPC from GameX to GameY"
}
}Terminal states
The state field transitions to one of:
approved— guardian replied YES. The user can now submit their SMS PIN normally; the transfer will complete.rejected— guardian replied NO. Transaction is dead; show the user a clear message.expired— 15 minutes passed without a reply. Same as rejected from the user's perspective.
When you see a terminal state, stop polling. decided_at tells you when the decision landed; decision_source is sms_inbound for guardian replies and expiry_job for time-outs.
Behavior on /verify-sms while pending
If your user enters their SMS PIN and you call /verify-sms before the guardian has approved, the endpoint returns HTTP 202 with:
{
"status": "pending_guardian_approval",
"error_code": "GUARDIAN_APPROVAL_PENDING",
"message": "Verification is held until the guardian on file approves via SMS.",
"guardian_approval": { /* current approval state */ }
}The same call works once the approval lands — no extra step required from the user. They can leave the PIN entered, and as soon as the guardian replies, your client's next /verify-sms call (driven by your status polling) will succeed normally.
Rejection / expiry
If the guardian rejected or the window expired, /verify-sms returns HTTP 410 Gone:
{
"status": "error",
"error_code": "GUARDIAN_APPROVAL_REJECTED", // or GUARDIAN_APPROVAL_EXPIRED
"message": "The guardian on file rejected this transaction.",
"guardian_approval": { /* state: "rejected" | "expired" */ }
}Treat both as terminal — the transaction cannot be revived. Show the user a clear message and let them re-initiate if they want to try again (a fresh approval row will be created).
Suggested partner UI flow
On 202 from initiate
Show "Waiting for parent approval" with a countdown to expires_at. Keep the PIN-entry UI visible but disabled. SMS PIN is already on the user's phone.
Poll /approval-status every 5–10s
Show subtle progress (e.g., "Still waiting..."). Stop polling at any terminal state.
On state === "approved"
Enable the PIN-submit button. Optional message: "Parent approved — please enter your SMS PIN." The user proceeds with /verify-sms normally.
On state === "rejected" | "expired"
Show the corresponding terminal message. Provide a "Try again" button that re-initiates a fresh transaction. Don't reuse the same client_request_id — generate a new one.
What the guardian sees
Invo Approval Request Alex wants to send 50 IPC from GameX to GameY. Reply YES ABC234XY56QZ to approve or NO ABC234XY56QZ to reject. Expires in 15 minutes. - Invo Platform
The 12-character token in the SMS is single-use and embedded so a stray YES/NO can't match a stale request. The guardian must reply from the phone number on file; replies from any other number are silently rejected by Invo's inbound webhook.
Edge cases worth handling
User refreshes the page mid-flow
Cache the transaction_id client-side or in your backend. On reload, re-poll /approval-status to recover the state.
Guardian replies but doesn't see your UI update
The Invo backend transitions state on receipt of the SMS. Your polling will pick it up within 5–10s. If the user complains they pressed YES and nothing happened, ask them to wait a few seconds — the round-trip through Twilio and your polling cycle takes a moment.
User submits PIN before approval lands
Your /verify-sms call returns 202 GUARDIAN_APPROVAL_PENDING. Treat it the same as the polling response — keep showing "Waiting for parent." The PIN entry is preserved server-side; the next call after approval will succeed.
"Our platform doesn't serve minors"
You probably do, even if your TOS says otherwise — Invo's minor flag is set based on signals across the whole network, not your platform's self-reported audience. Implement the 202 path. If your audience really is 100% adult, the path is dead code; if you're wrong about your audience even occasionally, the dead code stops being dead and saves you a support escalation.
Testing in sandbox
Sandbox supports the same flow end-to-end. To test, contact your Invo onboarding rep to flag a test wallet as a minor with a guardian. Then:
- Initiate a transfer or send from the minor's player. Confirm you receive the 202 response.
- Watch the guardian's phone for the SMS. (Sandbox sends real SMS via Twilio.)
- Reply
YES <token>from the guardian's phone. - Continue polling — within ~5s, state should flip to
approved. - Submit the SMS PIN normally — the transfer completes.
- Repeat with
NO <token>to verify your rejected-state UI. - Don't reply for 15+ minutes to verify expiry.
HTTP status code reference
| Status | When | What to do |
|---|---|---|
202 | Initiate succeeded but is awaiting guardian approval | Show waiting UI, start polling |
202 | /verify-sms while approval still pending | Keep waiting state, continue polling |
200 | /verify-sms after approval (or non-minor account) | Normal success path |
410 | /verify-sms when guardian rejected or expired | Show terminal message, offer re-init |
Implementation Examples
The 202 detection + polling loop runs on your secure backend, alongside your existing /initiate-transfer code. The samples below show the full pattern in Unity C#, Unreal C++, Godot GDScript, and Node.js. Adapt to your language as needed — the contract is the same.
Node.jsBackend polling loop (works for any platform-tier integrator)
// On your secure server. Call this AFTER /initiate-transfer or /initiate-send returns.
// It returns the final approval state, or null if the response was 200/201 (no gate).
async function handleInitiateResponse(initiateResponse, transactionId) {
// 200/201 → no guardian needed. Proceed with normal verify flow.
if (initiateResponse.status !== 202) return null;
const body = await initiateResponse.json();
if (body.status !== 'pending_guardian_approval') return null;
const expiresAt = new Date(body.guardian_approval.expires_at);
const pollUrl = `https://invo.network${body.guardian_approval.poll_endpoint}`;
while (Date.now() < expiresAt.getTime()) {
await new Promise(r => setTimeout(r, 7000)); // 7s cadence (within 5-10s recommended)
const statusRes = await fetch(pollUrl, {
headers: { 'X-Game-Secret-Key': process.env.INVO_SDK_KEY },
});
const statusBody = await statusRes.json();
const state = statusBody.approval?.state;
if (state === 'approved') return 'approved';
if (state === 'rejected') return 'rejected';
if (state === 'expired') return 'expired';
// state === 'pending' → keep polling
}
return 'expired'; // safety net
}
// Wiring it into your transfer flow:
async function startTransfer(payload) {
const initRes = await fetch('https://invo.network/api/transfers/initiate-transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Game-Secret-Key': process.env.INVO_SDK_KEY,
},
body: JSON.stringify(payload),
});
const body = await initRes.json();
const transactionId = body.transaction_id;
// Notify your client immediately so it can show "waiting for parent" UI.
if (initRes.status === 202) {
notifyClient(transactionId, { phase: 'pending_guardian', expires_at: body.guardian_approval.expires_at });
const finalState = await handleInitiateResponse(initRes, transactionId);
if (finalState === 'rejected' || finalState === 'expired') {
notifyClient(transactionId, { phase: 'guardian_terminal', state: finalState });
return;
}
notifyClient(transactionId, { phase: 'guardian_approved' });
}
// Approved (or no gate) → proceed with the standard SMS PIN flow.
// /verify-sms can now be called normally; it will succeed.
}UnityUnity C# polling (server-side coroutine)
// MUST run on your secure server, not the Unity client.
// Call after InitiateTransfer returns and you've parsed the response.
public IEnumerator PollGuardianApproval(string transactionId, DateTime expiresAt,
Action<string> onApproved,
Action<string> onTerminal /* "rejected" | "expired" */)
{
string url = quot;https://invo.network/api/transactions/{transactionId}/approval-status";
while (DateTime.UtcNow < expiresAt)
{
yield return new WaitForSeconds(7f); // 7s cadence
using (UnityWebRequest req = UnityWebRequest.Get(url))
{
req.SetRequestHeader("X-Game-Secret-Key", "your_game_secret_key_here");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) continue; // network blip; keep polling
// Parse: {"status":"ok","approval":{"state":"pending"|"approved"|"rejected"|"expired",...}}
var body = JsonUtility.FromJson<ApprovalStatusResponse>(req.downloadHandler.text);
string state = body.approval.state;
if (state == "approved") { onApproved?.Invoke(transactionId); yield break; }
if (state == "rejected" || state == "expired") { onTerminal?.Invoke(state); yield break; }
// state == "pending" → keep polling
}
}
onTerminal?.Invoke("expired"); // safety net
}
[Serializable] public class ApprovalStatusResponse {
public string status;
public ApprovalRow approval;
}
[Serializable] public class ApprovalRow {
public string approval_id;
public string transaction_id;
public string state;
public string expires_at;
public string decided_at;
public string decision_source;
public string action_description;
}UnrealUnreal C++ polling (server-side timer)
// MUST run on your secure server. Schedule a recurring HTTP poll until
// terminal state or expiry.
void AYourGameMode::PollGuardianApproval(const FString& TransactionId, const FDateTime& ExpiresAt)
{
if (FDateTime::UtcNow() >= ExpiresAt)
{
OnGuardianTerminal(TransactionId, TEXT("expired"));
return;
}
FString Url = FString::Printf(
TEXT("https://invo.network/api/transactions/%s/approval-status"),
*TransactionId);
TSharedRef<IHttpRequest> Req = FHttpModule::Get().CreateRequest();
Req->SetURL(Url);
Req->SetVerb("GET");
Req->SetHeader("X-Game-Secret-Key", "your_game_secret_key_here");
Req->OnProcessRequestComplete().BindLambda(
[this, TransactionId, ExpiresAt](FHttpRequestPtr R, FHttpResponsePtr Resp, bool bOk) {
if (!bOk || !Resp.IsValid()) {
// network blip; reschedule
GetWorld()->GetTimerManager().SetTimer(PollHandle,
FTimerDelegate::CreateUObject(this, &AYourGameMode::PollGuardianApproval, TransactionId, ExpiresAt),
7.0f, false);
return;
}
TSharedPtr<FJsonObject> Body;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Resp->GetContentAsString());
FJsonSerializer::Deserialize(Reader, Body);
FString State = Body->GetObjectField("approval")->GetStringField("state");
if (State == "approved") { OnGuardianApproved(TransactionId); return; }
if (State == "rejected" || State == "expired") { OnGuardianTerminal(TransactionId, State); return; }
// pending → reschedule another poll in 7s
GetWorld()->GetTimerManager().SetTimer(PollHandle,
FTimerDelegate::CreateUObject(this, &AYourGameMode::PollGuardianApproval, TransactionId, ExpiresAt),
7.0f, false);
});
Req->ProcessRequest();
}GodotGodot GDScript polling (server-side)
# MUST run on your secure server.
# Returns "approved", "rejected", or "expired".
func poll_guardian_approval(transaction_id: String, expires_at_iso: String) -> String:
var expires_at_unix = Time.get_unix_time_from_datetime_string(expires_at_iso)
var url = "https://invo.network/api/transactions/%s/approval-status" % transaction_id
var headers = ["X-Game-Secret-Key: your_game_secret_key_here"]
while Time.get_unix_time_from_system() < expires_at_unix:
await get_tree().create_timer(7.0).timeout
var req = HTTPRequest.new()
add_child(req)
var err = req.request(url, headers, HTTPClient.METHOD_GET)
if err != OK:
req.queue_free()
continue
var result = await req.request_completed
var body_str = result[3].get_string_from_utf8()
var body = JSON.parse_string(body_str)
req.queue_free()
var state = body.get("approval", {}).get("state", "")
if state == "approved":
return "approved"
if state == "rejected" or state == "expired":
return state
# state == "pending" → keep polling
return "expired"
AllHandling /verify-sms 202 / 410 responses
// /verify-sms can return:
// 200 → success, transfer proceeds
// 202 + GUARDIAN_APPROVAL_PENDING → guardian still hasn't replied; same call works once approved
// 410 + GUARDIAN_APPROVAL_REJECTED | GUARDIAN_APPROVAL_EXPIRED → terminal
// 400/4xx → existing PIN errors (wrong code, expired PIN, etc.) — handle as today
const verifyRes = await fetch('https://invo.network/api/transfers/verify-sms', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Game-Secret-Key': SDK_KEY },
body: JSON.stringify({ transaction_id: txnId, sms_pin: pin }),
});
if (verifyRes.status === 200) {
// success — show claim instructions, etc.
} else if (verifyRes.status === 202) {
// guardian still pending — start polling /approval-status if not already
} else if (verifyRes.status === 410) {
const body = await verifyRes.json();
// body.error_code is GUARDIAN_APPROVAL_REJECTED or GUARDIAN_APPROVAL_EXPIRED
// Show terminal message; user must re-initiate with a fresh client_request_id
} else {
// existing 400-class PIN errors
}Implementation summary
- ✓ Detect 202
pending_guardian_approvalon/initiate-* - ✓ Poll
GET /api/transactions/<id>/approval-statusevery 5–10s - ✓ Handle the four states:
pending,approved,rejected,expired - ✓ On
/verify-sms, treat 202 the same as polling-pending; treat 410 as terminal - ✓ Test the flow in sandbox before relying on it in prod