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 a short-lived approval window. The guardian is asked by email first: a signed, single-use link to a hosted Invo page with Approve / Decline buttons. Only when the guardian has no verified email, or the email could not be delivered, is the guardian reached on the phone on file instead. 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

The gate runs on every approval factor, a passkey and a device approval grant are held exactly the same way. 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",
  // Human-readable. 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" (phone on file, no verified email)
    "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 the literal value sms when there was no verified email (or the email could not be handed over) and the guardian was reached on the phone on file instead. Use it for your waiting copy (“we emailed your parent” vs “we contacted your parent”). resend_endpoint is a legacy field, see The resend endpoint. The same 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. The player can now approve as normal, with a passkey or a QR device approval, and your side calls the approve endpoint (POST /api/sdk/transfers/{id}/approve or POST /api/sdk/send/{id}/approve); the transaction then completes.
  • rejected, guardian pressed Decline. Transaction is dead; show the user a clear message.
  • expired, the short-lived approval window closed 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 (a legacy value) for a reply from the guardian's phone, 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.

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. Tell the player how their parent was reached using consent_channel.

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 approve button. Optional message: "Parent approved, confirm with your passkey or device." The player approves with a passkey or a QR device approval and your side calls the approve endpoint as normal. See Cross-Game Transfer, stage by stage.

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.

When the guardian has no verified email, or the email could not be delivered, the request reaches them on the phone on file instead, and consent_channel carries the legacy literal sms. The poll, the approval window and the terminal states are the same either way.

The resend endpoint (legacy)

guardian_approval.resend_endpoint points at POST /api/sdk/approvals/guardian/<approval_id>/resend, which re-sends the request to the phone on file. It is kept for integrations that already call it; do not build new UI on it. The emailed link is the path to design around. If you already call it: the only accepted body is the literal { "channel": "sms" }, it is allowed once per approval and only from the initiating minor's SDK player token, and its error codes are 400 INVALID_INPUT, 403 NOT_INITIATOR, 404 APPROVAL_NOT_FOUND, 409 CHANNEL_WAS_SMS (the request already went to the phone at creation), 409 ALREADY_RESENT, 410 APPROVAL_GONE and the retryable 503 DELIVERY_FAILED.

The short-lived approval 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 go to the same hosted page by email. 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, consent_channel carries the literal sms; the in-app approve still works.

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.

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 path, 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. Your polling will pick it up within 5 to 10s. If the user says their parent approved and nothing happened, ask them to wait a few seconds for your next polling cycle.

Player approves before the guardian does

The approve endpoint returns 202 GUARDIAN_APPROVAL_PENDING. Treat it the same as the polling response, keep showing "Waiting for parent", and call approve again once the poll says approved. A 410 GUARDIAN_APPROVAL_REJECTED or GUARDIAN_APPROVAL_EXPIRED is terminal; let the player re-initiate.

"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.
  3. Keep polling. Within about 5 seconds the state should flip to approved.
  4. Approve with a passkey or QR device approval and call the approve endpoint; the transfer completes.
  5. Repeat and press Decline to verify your rejected-state UI.
  6. Leave it undecided until the short-lived approval window closes to verify expiry.

HTTP status code reference

StatusWhenWhat to do
202Initiate succeeded but is awaiting guardian approvalShow waiting UI, start polling
202Approve endpoint while guardian approval still pendingKeep waiting state, continue polling
200Approve endpoint after guardian approval (or non-minor account)Normal success path
410Approve endpoint 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 sample below shows the full pattern in Node.js; the contract is the same in any server language. Never run it in a game client: it uses your title's secret key.

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) → the player approves with a passkey or QR device approval,
  // then call POST /api/sdk/transfers/{id}/approve as normal.
}

AllHandling approve 202 / 410 responses

// POST /api/sdk/transfers/{id}/approve (or /api/sdk/send/{id}/approve) can return:
//   200 → approved, transfer proceeds
//   202 + GUARDIAN_APPROVAL_PENDING → guardian has not decided; call again once approved
//   410 + GUARDIAN_APPROVAL_REJECTED | GUARDIAN_APPROVAL_EXPIRED → terminal

if (approveRes.status === 200) {
  // success: show the collect state
} else if (approveRes.status === 202) {
  // guardian still pending: keep polling /approval-status
} else if (approveRes.status === 410) {
  // terminal: user must re-initiate with a fresh client_request_id
}

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 the approve endpoint, treat 202 the same as polling-pending; treat 410 as terminal
  • ✓ Test the flow in sandbox before relying on it in prod