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_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/sdk/transfers/{id}/approve
  • /api/currency-sends/initiate-send
  • /api/sdk/send/{id}/approve
  • and the legacy /verify-sms on 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

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

1

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.

2

Poll /approval-status every 5 to 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

First: an email with a link to a hosted Invo page (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.

Otherwise: a text to the guardian's phone (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.

POST

/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" }
ResponseMeaningWhat to do
400 INVALID_INPUTchannel was not smsFix the body
403 NOT_INITIATORThe token is not the minor who started the transactionOnly offer the button in the initiator's session
404 APPROVAL_NOT_FOUNDUnknown id, or not a guardian approval (opaque)Nothing to resend
409 CHANNEL_WAS_SMSThe request already went by text at creationHide the button when consent_channel is sms
409 ALREADY_RESENTOne text per approvalDisable the button after a 200
410 APPROVAL_GONEAlready decided or expiredYour poll will show the terminal state
503 DELIVERY_FAILEDThe text could not be handed overRetryable, 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:

  1. Initiate a transfer or send from the minor's player. Confirm you receive the 202 response.
  2. 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.)
  3. For the text path, reply YES <token> from the guardian's phone.
  4. Keep polling. Within about 5 seconds the 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 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