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 approves or declines, within 15 minutes. The guardian is asked by email first: a signed, single-use link to a hosted Invo page with Approve / Decline buttons. A text (“reply YES or NO”) goes out only when the guardian has no verified email, the email could not be delivered, or the minor asks for one. 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 cannot 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-* call and on every approve 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/sdk/transfers/{id}/approve - •
/api/currency-sends/initiate-send - •
/api/sdk/send/{id}/approve - and the legacy
/verify-smson both rails
The gate runs on every approval factor, a passkey and a device approval grant are held exactly as the legacy PIN is. There is no factor that bypasses it.
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",
// The first clause branches on whether a fallback PIN was actually sent. On the
// passkey / QR rail it reads "Approve on your enrolled device to continue" instead.
// The channel word is "by email" or "by text". Do not match on this string.
"message": "Transfer initiated. Approve on your enrolled device to continue, but verification is held until the guardian on file approves it by email.",
"transaction_id": "TXN_1715000000_ABC123",
"guardian_approval": {
"approval_id": "1f2a8c0d-...",
"state": "pending",
"expires_at": "2026-05-04T22:15:00+00:00",
"consent_channel": "email", // "email" (link sent) | "sms" (text sent instead)
"poll_endpoint": "/api/transactions/TXN_1715000000_ABC123/approval-status",
"resend_endpoint": "/api/sdk/approvals/guardian/1f2a8c0d-.../resend"
},
"transfer_details": { /* ...standard fields... */ },
"verification_required": { /* ...standard fields... */ }
}consent_channel tells you how the guardian was reached: email when the signed link went to their oldest verified address, or sms when there was no verified email (or the email could not be handed over) and the reply-YES text went out instead. Use it for your waiting copy (“we emailed your parent” vs “we texted your parent”). resend_endpoint is where the minor can ask for a text. See Asking for a text instead. The same two fields appear on the SDK send/transfer 202 bodies and on the wallet app's.
The transaction is staged and the funds are reserved while the guardian decides. Show your normal approval UI, but disable the approve button and surface a "Waiting for parent approval" indicator until the polling endpoint says approved. Calling the approve endpoint before then simply returns 202 GUARDIAN_APPROVAL_PENDING.
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 to 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,
"consent_channel": "email",
"action_description": "send 50 IPC from GameX to GameY"
}
}Terminal states
The state field transitions to one of:
approved, guardian pressed Approve on the emailed page, or replied YES to a text. The player can now approve as normal, a passkey, or a QR device approval on a console, and the transaction will complete.rejected, guardian pressed Decline or replied NO. Transaction is dead; show the user a clear message.expired, 15 minutes passed without a decision. 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 email_link for a decision on the hosted page, sms_inbound for a texted reply and expiry_job for time-outs. consent_channel is the channel the request went out on.
Transient 503: the poll endpoint may return HTTP 503 { "status": "unavailable" } when the approval lookup is momentarily unavailable. This is not a terminal state. Treat it as "keep polling / retry shortly" and try again on your next interval.
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 it by email.", // or "by text"
"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, and disable your approve button until the gate clears. Do not promise a text. On the passkey and QR rail no sender PIN is sent at all, which is the normal case for the tenants these docs describe; the message field tells you which happened.
Poll /approval-status every 5 to 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
consent_channel: "email")The email goes to the guardian's oldest verified address (an explicitly primary verified address wins; an unverified address is never used). It describes the action, “Alex wants to send 50 IPC from GameX to GameY”, and carries a signed, single-use link that expires with the approval. Opening the link decides nothing: the page shows the request and two buttons, Approve and Decline, and the decision is made on the click. A second click, or a link opened after the decision, is told the request was already decided. The guardian never needs an account, an app or a code.
consent_channel: "sms", or after a resend)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. When the request went by email and the text is sent later on request, the emailed link keeps working alongside it, whichever answers first wins.
Asking for a text instead, the resend endpoint
A guardian who does not check email can be texted the reply-YES message on request. Only the minor who started the transaction can ask, from the SDK player session or wallet session that initiated it, and only once per approval. Offer it as a “Text my parent instead” button next to your waiting indicator; it is the address in guardian_approval.resend_endpoint.
/api/sdk/approvals/guardian/<approval_id>/resend
Sends the guardian the reply-YES text for an approval that went out by email
Authentication: the initiator's SDK player token (Authorization: Bearer), not your title’s secret key.
POST /api/sdk/approvals/guardian/1f2a8c0d-.../resend
Authorization: Bearer <sdk_session_token>
Content-Type: application/json
{ "channel": "sms" } // the only accepted value
200 { "status": "sent", "channel": "sms" }| Response | Meaning | What to do |
|---|---|---|
400 INVALID_INPUT | channel was not sms | Fix the body |
403 NOT_INITIATOR | The token is not the minor who started the transaction | Only offer the button in the initiator's session |
404 APPROVAL_NOT_FOUND | Unknown id, or not a guardian approval (opaque) | Nothing to resend |
409 CHANNEL_WAS_SMS | The request already went by text at creation | Hide the button when consent_channel is sms |
409 ALREADY_RESENT | One text per approval | Disable the button after a 200 |
410 APPROVAL_GONE | Already decided or expired | Your poll will show the terminal state |
503 DELIVERY_FAILED | The text could not be handed over | Retryable, a failed delivery does not use up the one allowance |
The 15-minute window is unchanged by a resend, and the emailed link keeps working. The status poll and the terminal states are the same whichever channel the guardian answers on.
The same page for phone-share and recipient-identity consents
Two other consents used to be “reply YES” texts, and now go to the same hosted page by email first. Neither changes your integration: the status codes, bodies and retry contracts you already handle are identical; only how the consenting person is reached changed, and a consent_channel field tells you which way it went.
Phone-share consent (409 PHONE_SHARE_APPROVAL_REQUIRED)
A new email wants to use a phone number that is already on file with a different email. What the existing holder sees: an email, “Someone wants to use your phone number with a new email”, showing the masked requester, with Allow / Decline buttons on the hosted page. It goes to at most three holders of the number whose address Invo has itself proven (verified by a code Invo sent), one email per request, and expires with the request.
What you see: the same 409 body, with consent_channel: "email" (and consent_email_sent), no code for your UI to collect; poll /api/wallet/phone-share/status and retry when approved. When no proven holder exists the OTP text goes out as before and consent_channel is sms; the typed-code /phone-share/approve and the in-app approve both still work.
Recipient-identity step-up (202 RECIPIENT_IDENTITY_PENDING)
A claim arrives for a phone number under an email the number's owner has not used before. What the phone owner sees: an email, “Someone is claiming currency sent to your number”, with the masked claiming email and the amount, and Confirm / Decline buttons. It goes to their oldest verified address, and only when they verified that number with Invo themselves; otherwise the text goes out as before.
What you see: the same 202 hold on confirm-receipt / claim; the money stays held until the decision, a decline refunds the sender, and the in-app approve remains available. Poll and retry exactly as today.
On the page: opening the link decides nothing; unknown, expired and used links all read as “not valid”; a request already decided says so. The decision runs through the same server-side helpers as the in-app and texted paths, so money behaviour cannot differ by channel.
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 decides but doesn't see your UI update
The Invo backend transitions state on the click (or on receipt of the SMS). Your polling will pick it up within 5 to 10s. If the user complains they pressed YES and nothing happened, ask them to wait a few seconds, the round-trip through the mobile networks 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 inbox for the email (
consent_channel: "email") and press Approve on the page, or, to exercise the text path, give the test guardian no verified email, or call the resend endpoint from the minor's session. (Sandbox sends real SMS.) - For the text path, reply
YES <token>from the guardian's phone. - Keep polling. Within about 5 seconds the 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 to 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