Device Approval (Consoles & TVs)

A console has no browser, so it cannot run a passkey ceremony at all. Device Approval moves the ceremony to a device that can: your title shows a short code, the player approves on their phone, and your title polls until it hears back. It is the OAuth 2.0 Device Authorization Grant (RFC 8628) , the same “enter this code on your phone” flow every console and TV app uses, so you can reach for an existing client library rather than write one.

When you need it

Use this anywhere the client running your title cannot invoke a platform authenticator in-process: consoles, TV apps, and native Steam / desktop game clients on both Mac and Windows. Web and mobile clients use the same hosted page, they differ from a console only in that they can open a browser, so the SDK opens the page for them (a popup on the web, the system browser on iOS / Android) instead of drawing a QR. One approval flow, one page, every platform.

Native Steam & desktop clients need this too. It is the client, not the OS

A console has no browser at all, so this is the only path. But a native Steam or desktop game client reaches for it for a subtler reason: even though macOS and Windows both support passkeys at the OS level, the embedded game client cannot invoke the platform authenticator (Touch ID / Windows Hello) from inside the engine. It is the client that forces the QR here, not the operating system, so a native desktop build uses Device Approval exactly as a console does.

It is available to every title, with nothing to configure

The constraint is a property of the device, not of your title. A title that ships a website and a console build uses the normal ceremony in the browser and this one on the console. There is no flag to set and no domain to verify, the ceremony happens on INVO’s own domain, which is the whole point.

Which method on which platform

The right verification method is a property of the platform the player is on. Where a passkey can run in-client it is always the strongest option, hardware-bound, phishing-resistant, and free of any messaging cost. Where the client cannot invoke a platform authenticator, use the QR device-approval flow on this page. This table is the canonical mapping; the other verification pages point back here.

PlatformIn-client passkey?Recommended method
iOS / Android app (Unity, Unreal)Yes. Face ID / Touch ID / fingerprint, on the hosted pageHosted page in the system browser, channel: "app_browser". The Unity / Unreal plugin (InvoHostedApproval / UInvoHostedApprovalSubsystem) opens verification_uri_complete in an ASWebAuthenticationSession / Custom Tab and the app returns on invo-sdk-<game_id>://done, which carries nothing. First-time enrolment is auto-confirmed (see below): no prompt on mobile.
Web game / web platform (desktop or mobile browser)Yes. Touch ID / Windows Hello / phone biometric, on the hosted pageHosted page in a popup (channel: "popup"). The web SDK’s approveHosted() runs the whole grant from the browser with the player token it holds: begin, popup, poll, the match-code prompt, confirm-enrollment, approve. Your server does nothing new. A title with a passkey domain verified before the freeze may keep using its in-app passkey (Platform Step-Up).
Consoles (PlayStation / Xbox / Switch)No, no browser, no WebAuthnQR device-approval flow (channel: "qr", the default). The player scans the QR, completes the passkey on their phone, and your server polls for approval.
Native Steam / desktop game clients (Mac & Windows)No, the client can’t invoke the OS authenticatorQR device-approval flow, channel: "qr", even though the OS supports passkeys. It is the client that forces QR here, not the OS.
Remote approval with no passkey path (guardian and other consents)-Email first, SMS on request: a signed link to a hosted page goes to the oldest verified address; a text goes out only when there is no verified email, the email could not be delivered, or the player asks for one. SMS is the last resort, not the primary channel. See Guardian Approval.

The flow

1. Your server (or the web SDK) starts an approval

After you initiate a transfer, ask INVO to start an approval for that transaction, saying how the page will be delivered (channel: QR, popup or app browser). You get back a short code, two URLs, a device code, a poll interval and an expiry.

2. Your title shows the code and a QR

Render verification_uri_complete as a QR code and print the user_code underneath. The QR already contains the code, so most players scan and never type anything. The typed-code box on the approval page exists for a TV with no camera: when the QR pre-fills the code, the box is never shown. It is the RFC 8628 user code, not a field for a text-message PIN.

3. The player approves on their phone

They land on an INVO page and see what they are approving. The first time, the page also sets up their INVO passkey, and the proof that it is really them is your title’s screen: the phone says “Confirm on your game screen”, your title shows “Set up INVO on this phone: iPhone?” (see Polling below), the player presses Yes on the console, and their phone’s fingerprint, face or screen lock creates the passkey and approves. No code to type, no text message. A backup email goes out at the moment of the scan, for a prompt they missed. Every later scan is scan, biometric, done. Beyond showing that one Yes/No prompt, nothing about this step touches your servers. On iOS / Android the page runs on the same phone as the app, so there is no screen to ask and the prompt is skipped (see Mobile: enrolment is auto-confirmed below).

4. Your title polls until it hears back

Poll with the device code at the interval INVO gave you. You can also subscribe to the device_approval.approved webhook and learn the moment it lands, instead of waiting for the next poll. The poll is not the end of the flow. See step 5, which is the step that moves money.

5. Your server calls the flow’s approve endpoint with the device code

This is what a grant is for. The approval proved who; this call settles the transaction, and until you make it nothing has happened to the money. See The device code is a factor, not a settlement below for the exact call.

Starting an approval

With the player’s SDK session token. flow is the action being approved: transfer, send, send_receipt or transfer_receipt. channel (optional, default qr) says how the client will deliver the page; it changes only how the page says “done” afterwards, you poll in every case.

POST /api/sdk/approvals/device/begin
Authorization: Bearer <sdk_session_token>
Content-Type: application/json

{
  "transaction_id": "TXN_...",
  "flow": "transfer",
  "channel": "qr"                           // qr (default) | popup | app_browser
}
200 OK
{
  "device_code": "b1s4...",                 // secret; keep it on your server
  "user_code": "K7QP-3MRD",                 // show this
  "verification_uri": "https://invo.network/device",
  "verification_uri_complete": "https://invo.network/device?user_code=K7QP-3MRD",
  "expires_in": 600,
  "interval": 5,
  "channel": "qr"                           // echoed; standard RFC 8628 clients ignore it
}
channelWho calls beginHow the page finishes
qrYour server (server-to-server). Consoles, TVs, native Steam / desktop clients.The page shows “approved”; your poll reports it. Nothing is signalled anywhere.
popupThe browser, via the web SDK’s approveHosted(). The request must carry the browser’s own https Origin header. INVO stores it as the popup’s opener and refuses 400 INVALID_INPUT without one, so a server-to-server caller cannot use this channel. Nothing in the body can set the origin.The page posts a status-only { type: "invo.approval", status } message to the window that opened it (targeted at the stored origin, never *) and closes itself on approved. It is a wake-up to poll now, never the result.
app_browserYour server, on behalf of the Unity / Unreal plugin. INVO derives the return scheme invo-sdk-<game_id> from the grant’s own game id; it is never caller-supplied.On approved the page navigates to invo-sdk-<game_id>://done, which carries nothing. Start polling on interval the moment begin returns, the return is only a hint to poll sooner.

Whatever the channel, money moves only when your side calls the flow’s approve endpoint with the device_code. The page’s signal cannot approve anything.

Polling

Poll no faster than interval seconds. The error codes are the RFC 8628 ones, so a standard client understands them without translation.

POST /api/sdk/approvals/device/poll
Authorization: Bearer <sdk_session_token>

{ "device_code": "b1s4..." }
ResponseMeaningWhat to do
status: approvedThe player approved. The grant is settled; the transaction is not.Immediately POST the flow’s approve / confirm-receipt endpoint with this device_code. See below. Stopping here leaves the transaction untouched and it will expire and refund.
authorization_pendingNot yetKeep polling at interval
slow_downYou are polling too fastBack off, then resume
expired_tokenThe code timed outStart a new approval
access_deniedThe player declinedStop; do not retry silently
invalid_grantUnknown or not yoursStart a new approval

The device code is a factor, not a settlement

A poll that answers status: "approved" means the player finished the ceremony on their phone. It does not mean the transaction moved. The grant proves who; the money is moved by the flow’s own endpoint, which then re-checks the guardian gate, the recovery hold and the transaction’s state before it does anything. Your server must make that second call, with the player’s session token (never your title’s secret key) and the device code in the body:

Authorization: Bearer <that player's session token>
Content-Type: application/json
{ "device_code": "b1s4..." }

flow "send"              ->  POST /api/sdk/send/{transaction_id}/approve
flow "transfer"          ->  POST /api/sdk/transfers/{transaction_id}/approve          (plural)
flow "send_receipt"      ->  POST /api/sdk/send/{transaction_id}/confirm-receipt
flow "transfer_receipt"  ->  POST /api/sdk/transfers/{transaction_id}/confirm-receipt

200 -> { "status": "approved", "next": "pending_claim", ... }        (the approves)
200 -> { "status": "completed", "amount_received": "45.00" }         (the confirm-receipts)

The flow you began the grant with decides which of these four you call, and they are not interchangeable: a grant begun as send presented to a confirm-receipt endpoint is refused as 400 DEVICE_APPROVAL_NOT_APPROVED, which reads as though the player never approved at all.

A repeat call is not a failure. Once the transaction has moved on, these endpoints answer 400 with code: "TRANSACTION_NOT_PENDING" and a current_status. Read that as “already done, here is where it got to”, not as an error to refund or alert on.

A real integration shipped without this call. It showed the QR, polled to “approved”, and stopped. Every transaction it created sat unapproved until its window lapsed and refunded, with no error anywhere, because nothing had failed. Full worked examples in curl, JavaScript and Python: Peer Send, Cross-Game Transfer, Collecting.

While a phone is waiting to enrol: the enrollment object

The first time a player scans with a phone that has no INVO passkey, the phone asks your title’s screen to vouch for it. While that is pending, the authorization_pending body carries one extra key. It is absent when no phone has asked, an already-enrolled phone never triggers it.

400 Bad Request
{
  "error": "authorization_pending",
  "enrollment": {
    "state": "awaiting_screen",               // awaiting_screen | confirmed | denied
    "device_label": "iPhone",
    "match_code": "48-27",                    // the phone page shows the same code
    "recovery": false,                        // true: the phone is REPLACING a lost INVO passkey
    "requested_at": "2026-09-03T18:04:12+00:00"
  }
}

Show a Yes/No prompt while state is awaiting_screen, “Set up INVO on iPhone? Code 48-27. Say Yes only if the phone you just scanned shows this code.”, and take it down the moment the state changes or the object disappears. The player confirms a match, not a guess by label: draw match_code large. device_label comes from a fixed list (iPhone, iPad, Android phone, Android device, Mac, Windows PC, Chromebook, Linux device, phone) derived from the scanning phone’s browser: a hint for the player, not an identity. Lead the copy with “scanned just now”, the honest signal is whether they just scanned.

When recovery is true the phone has declared it is replacing a passkey the player lost, so word the prompt as “Replace your INVO passkey with this iPhone?” rather than “set up”. The answer goes to the same confirm-enrollment call; what happens next is in Recovering a lost passkey below. On an app_browser grant the object appears already confirmed, with no awaiting_screen phase.

Confirming the phone from the title’s screen

Same authentication as begin and poll, the sender’s SDK session token, which must belong to the grant’s own identity. Send the player’s answer as soon as they give it.

POST /api/sdk/approvals/device/confirm-enrollment
Authorization: Bearer <sdk_session_token>
Content-Type: application/json

{
  "device_code": "b1s4...",
  "decision": "approve"                     // approve | deny
}
200 OK
{ "status": "confirmed" }                   // or { "status": "denied" }
ResponseMeaningWhat to do
409 DEVICE_APPROVAL_ENROLLMENT_ALREADY_DECIDEDThe backup email answered first; body carries decided and viaTake the prompt down quietly
409 DEVICE_APPROVAL_NO_ENROLLMENT_PENDINGNo phone has asked to enrol on this approvalNothing to confirm; keep polling
400 invalid_grantUnknown or not yoursStart a new approval
400 expired_tokenThe code timed outStart a new approval

Approve lets the phone continue to its biometric; your poll shows enrollment.state: "confirmed" and then status: approved. Deny ends the grant: the next poll returns access_denied, and the phone is told it was declined on the title’s screen.

The backup email is a backup, not the proof

At the moment of the scan, an email goes to the address on file: confirm on your game screen; can’t see the prompt? tap here; wasn’t you? tap here; ignore this if you already confirmed. Its links expire with the approval, and opened after the screen has answered it simply says “you already confirmed this”. There is nothing for you to do with it.

Mobile: enrolment is auto-confirmed

For channel: "app_browser" the phone running the page is the device that opened it: the title’s prompt is unreachable while the browser session covers it, and a match code proves nothing when screen and phone are one device. So INVO confirms the enrolment itself at the moment of the request, the poll goes straight to enrollment.state: "confirmed" and then status: approved, and the title shows nothing. The backup email still goes out, as the “if this wasn’t you” alert, and the existing-method rule (below) still applies. The match-code prompt is only needed when the same flow runs on a screen that is not the phone (a tablet or desktop build of a mobile title using qr).

Recovering a lost passkey

A player whose only INVO passkey was on a phone they no longer have can replace it from the approval page, with nothing new on your side. Recovery needs both halves of the identity: the title’s screen (this phone is theirs) and the email on file (an emailed link). Never the screen alone, so a shared console cannot reset its owner’s passkey.

  1. On the page the player taps “Recover my passkey by email”. The phone asks to enrol with recovery: true; your poll shows the enrollment object with "recovery": true and a match code. Show “Replace your INVO passkey with this iPhone? Code 48-27” and send the answer to confirm-enrollment as usual. On mobile this step is auto-confirmed like any other enrolment.
  2. Only after the screen has confirmed, the page emails a single-use recovery link to the address on file for the identity (never an address the page supplies; one recovery per identity per 24 hours). The player opens it on the same phone; that tab confirms and the page they started from carries on by itself.
  3. Both proofs held, INVO deactivates the identity’s INVO passkeys, notifies the owner on every channel it has, enqueues an identity.passkey_reset webhook to your backend, and the phone creates its new passkey and settles the approval, your poll reports status: approved and device_approval.approved fires.

Money out is paused for 24 hours after a recovery

The approval settles, but the approve call for a transfer or send is refused with 403 PASSKEY_RECOVERY_COOLDOWN (with retry_after) for 24 hours. SIM-swap protection. The page tells the player “recovered; start this transfer again after the hold”, so expect a fresh initiate later, not a retry. Receiving and collecting are unaffected: the cooldown runs only on the money-out approves.

The completion webhook

Subscribe to device_approval.approved and your server hears the moment an approval lands. Polling still works on its own, the webhook is a latency improvement, not a replacement, and you should not rely on it as your only signal.

{
  "event_id": "...",
  "idempotency_key": "...",              // DEDUPE ON THIS
  "event_type": "device_approval.approved",
  "schema_version": "1.0",
  "created_at": "2026-09-04T18:04:12+00:00",
  "tenant_id": "155963559928",
  "data": {
    "transaction_id": "TXN_...",
    "flow": "transfer",                  // decides WHICH endpoint you call next
    "identity_id": "9f2c...",
    "method": "device_grant_webauthn"
  }
}

// No device code is in this payload, deliberately — a webhook body lands in your logs.
// You already hold it from the begin response. This event is the CUE to make the approve /
// confirm-receipt call, not a substitute for it.

Rules worth knowing

One approval authorises one transaction

Unlike the plain RFC 8628 grant, which authorises a client, an INVO device code is bound to the single transaction and flow you named. “Used” means the moment your server presents it to that flow’s approve or confirm-receipt endpoint, the step above, and that call advances the transaction past the state a second presentation would need. So the code settles exactly one transaction, once: a repeat presentation finds the transaction already moved on and answers 400 TRANSACTION_NOT_PENDING. It can never approve a different transaction, a different flow, or a different player.

Codes are short-lived, and only one is live per transaction

A code expires in minutes, and only one is live per transaction per flow, so a sender’s send grant and a recipient’s send_receipt grant on the same transaction never collide.

Beginning again while a grant is still pending supersedes it: the old code is closed and you are handed a fresh one. That is the right answer after a crash, a lost screen, or a phone that never finished, and a page still open on the old code is told to scan again.

Beginning again while a grant is already approved and inside its window returns 409 DEVICE_APPROVAL_ALREADY_PENDING with expires_at, the approval already exists, so settle with the code you hold rather than minting a second one for money that is already authorised. Once that window has passed you may start again, which is the escape hatch when your server lost the device code after the player approved.

Starting an approval extends the transaction’s window

For transfer and send, starting an approval pushes the transaction’s own approval window past the code’s expiry, so an approval at minute nine cannot land on a transfer that has already expired and been refunded.

Keep the device code on your server

The user_code is meant to be seen; the device_code is not. Poll from your backend, not from the client. The one exception is the web SDK’s approveHosted(), which holds the device code inside the call for its duration and never exposes it to your page code.

An identity with a method elsewhere must vouch for the new one

If the identity already has an active approver somewhere else, a passkey on a partner domain, the INVO app’s device key, the hosted page refuses to enrol beside it with 409 ENROLLMENT_REQUIRES_PROOF and tells the player to add this phone from the app or website where that method lives (a method-link grant), then scan again. An identity with no method enrols freely. A stolen fifteen-minute session must not be able to add a second approver.

First-time approvers enrol on the spot, your only job is one Yes/No prompt

The approval page uses a passkey registered with INVO, on INVO’s own domain. A passkey a player set up on your domain is a different credential and cannot be used here, that is how passkeys work, not a limitation of this flow. So the first scan enrols one, and the proof it is really them is your title’s screen: your poll reports the waiting phone, you show “Set up INVO on this phone: iPhone?”, and you send the answer to confirm-enrollment. No code to type, no text message, ever. Same begin and poll endpoints, same device_approval.approved webhook, same method value device_grant_webauthn, same approve call with the device code afterwards.

What this is not for

Currency purchases on a console go through that console’s own store, as required by the platform holder. This flow is for approving transfers and sends, moving currency a player already has.