Device Enrollment

Your server mints a short-lived player token for the player. From there, the player either sets up an Invo passkey on Invo’s own approval page the first time they approve (the recommended path, nothing for you to build), or your app registers a hardware-backed public key for the device. That key is what every in-app approval signature is verified against.

Start with the hosted approval page

The simplest enrolment is none at all on your side. When a player approves a send, a transfer or a collect through Invo’s hosted approval page (the web SDK’s approveHosted(), or the QR device grant on consoles and desktop clients), a player with no Invo passkey sets one up on that page, in the same visit. Your game screen is asked to confirm the new phone, and Invo emails the player as a backup. Every later approval is one biometric.

There is nothing to configure. Passkeys live on Invo’s own domain, so you do not register a passkey domain, verify a domain, or set up a relying party. The rest of this page is for titles that also enroll a device key inside their own app.

1. Mint a player token (server-side)

POST

/api/sdk/player-token

Mints a short-lived token for one of your players, creating the player if they are new

Server-side only

This is the one SDK call that uses your title’s secret key. Call it from your server, then hand the returned token to the device. Never ship the secret to the client.

X-Game-Secret-Key: your_game_secret_key_here

Request Body

ParameterTypeRequiredDescription
player_emailstringYesThe player’s email in your title
player_phonestringRecommendedInternational format. When the player is not on Invo yet, they are created with this phone. An existing player’s phone is never changed here.
player_namestringNoUsed only when the player is created

Response

{
  "token": "<player token, hand this to the device>",
  "expires_at": "2026-01-01T12:15:00Z",   // 15-minute lifetime
  "identity_id": "id_9f3c…",               // opaque player identity (safe to store)
  "created": true                          // only when this call created the player
}

You get only the opaque identity_id. A player who exists with a different phone gets a token plus phone_mismatch: true. A player not found in your title, with no player_phone passed, returns 404 player_not_found; a title not yet enabled for in-app verification returns 403 sdk_not_enabled_for_tenant. Registering players and changing phones are covered on Player Identity.

Proving it is the player: the enrollment code

A player’s first in-app key or passkey may need a one-time code first (the SDK tells you with ENROLLMENT_REQUIRES_AUTHORIZATION). Request the code, have the player type it, verify it, then retry the registration.

POST $BASE/api/sdk/device/enrollment/begin
Authorization: Bearer <player token>
{}                       // the code goes to the player's email

200 OK { "status": "sent", "channels": ["email"] }
       // "channels" names where the code went: tell the player to check their email.

POST $BASE/api/sdk/device/enrollment/verify
Authorization: Bearer <player token>
{ "code": "123456" }

200 OK { "status": "verified" }
400 ENROLLMENT_CODE_INVALID

Codes go by email

The code goes to the player’s email address on file. Word your screen from channels rather than assuming.

import { InvoClient } from "@invonetwork/web-sdk";
const client = new InvoClient({ token: playerToken, baseUrl });

const sent = await client.enrollmentBegin();   // code goes by email
// sent.channels -> ["email"]
await client.enrollmentVerify(codeFromPlayer);
// then retry client.enrollPasskey()

2. Register the device (client-side)

POST

/api/sdk/device/register

Registers the device + its hardware public key under the player's identity

Authenticated with the player token

Authorization: Bearer <player token>

Generate the keypair in secure hardware

At registration the plugin generates a keypair inside the device's secure hardware (iOS Secure Enclave / Android StrongBox) and sends only the public key. The private key never leaves the device and is never transmitted or logged.

Request Body

ParameterTypeRequiredDescription
device_fingerprintstringYesA stable per-device identifier (≤128 chars)
device_public_keystringRecommendedPEM or base64-DER public key from the hardware keystore
key_algorithmstringWith keyEC_P256 | ED25519 | RSA_2048
platformstringNoios | android | web | other
rotation_proofobjectOnly to rotateRequired to change an existing key. See "Rotating a key" below

Response

{
  "status": "registered",
  "device": {
    "identity_id": "id_9f3c…",
    "platform": "ios",
    "has_attestation_key": true,
    "key_algorithm": "EC_P256"
  }
}

The device object is a partial view. The four fields above aren't exhaustive. It also includes id, is_active,last_seen_at, and created_at.

The approval signature (device_signal)

Every approval and receipt confirmation carries a device_signal, a signature over the exact action, made with the device's hardware private key. Sign exactly this canonical message:

message   = "{action}|{transfer_id}|{nonce}|{timestamp}"   // UTF-8 bytes, pipe-delimited
//          action = the endpoint verb: "approve" when calling /approve,
//                   "confirm-receipt" when calling /confirm-receipt.

device_signal = {
  "transfer_id": "<the transaction id being approved>",
  "nonce":       "<fresh random, single-use, base64url, no '|'>",
  "timestamp":   1750000000,        // epoch SECONDS, must be within ±300s of server time
  "signature":   "<base64 signature over message>"
}
  • • Action verb (required): the message is prefixed with the endpoint verb, approve or confirm-receipt, so a signature minted for one endpoint can't be reused at the other. Sign with the verb for the endpoint you're calling.
  • • Algorithms: EC_P256 (ECDSA / SHA-256), ED25519 (raw message), RSA_2048 (PKCS#1 v1.5 / SHA-256). It must match the registered key.
  • • The nonce is single-use; do not reuse it on a retry. Generate a fresh signal.
  • • Because the private key never leaves the device, a signal can't be lifted onto another device, and the nonce + timestamp stop replay on the same device.

Rotating a key

Changing an enrolled key needs proof of the old one

The first key for a player enrolls freely. Replacing or adding a different key for a player who already has one requires arotation_proof, a signature made with the current private key over the new key.

  • • A missing proof returns 409 rotation_requires_proof.
  • • An invalid / stale / replayed / malformed proof returns 409 with the specific code ROTATION_PROOF_INVALID | ROTATION_PROOF_STALE | ROTATION_PROOF_REPLAY | ROTATION_PROOF_MALFORMED.
message = "key-rotation|{new_public_key}|{nonce}|{timestamp}"   // signed with the EXISTING key

"rotation_proof": { "nonce": "…", "timestamp": 1750000000, "signature": "<base64>" }

Example: register on login (Unity)

// 1) Your SERVER mints the token (X-Game-Secret-Key) and returns it to the client.
// 2) On the device, the plugin generates the hardware keypair and registers:
IEnumerator RegisterDevice(string playerToken, string deviceFingerprint, string publicKeyPem)
{
    var body = JsonUtility.ToJson(new {
        device_fingerprint = deviceFingerprint,
        device_public_key  = publicKeyPem,
        key_algorithm      = "EC_P256",
        platform           = "ios"
    });
    using (var req = new UnityWebRequest("https://invo.network/api/sdk/device/register", "POST"))
    {
        req.uploadHandler   = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body));
        req.downloadHandler = new DownloadHandlerBuffer();
        req.SetRequestHeader("Authorization", "Bearer " + playerToken);
        req.SetRequestHeader("Content-Type", "application/json");
        yield return req.SendWebRequest();
        // on 401 → mint a fresh token via your server, then retry.
    }
}