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_id stable 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

GET

/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

1

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.

2

Poll /approval-status every 5–10s

Show subtle progress (e.g., "Still waiting..."). Stop polling at any terminal state.

3

On state === "approved"

Enable the PIN-submit button. Optional message: "Parent approved — please enter your SMS PIN." The user proceeds with /verify-sms normally.

4

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

Inbound SMS to guardian's phone
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:

  1. Initiate a transfer or send from the minor's player. Confirm you receive the 202 response.
  2. Watch the guardian's phone for the SMS. (Sandbox sends real SMS via Twilio.)
  3. Reply YES <token> from the guardian's phone.
  4. Continue polling — within ~5s, state should flip to approved.
  5. Submit the SMS PIN normally — the transfer completes.
  6. Repeat with NO <token> to verify your rejected-state UI.
  7. Don't reply for 15+ minutes to verify expiry.

HTTP status code reference

StatusWhenWhat to do
202Initiate succeeded but is awaiting guardian approvalShow waiting UI, start polling
202/verify-sms while approval still pendingKeep waiting state, continue polling
200/verify-sms after approval (or non-minor account)Normal success path
410/verify-sms when guardian rejected or expiredShow 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_approval on /initiate-*
  • ✓ Poll GET /api/transactions/<id>/approval-status every 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