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)
/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_hereRequest Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| player_email | string | Yes | The player’s email in your title |
| player_phone | string | Recommended | International 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_name | string | No | Used 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_INVALIDCodes 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)
/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
| Parameter | Type | Required | Description |
|---|---|---|---|
| device_fingerprint | string | Yes | A stable per-device identifier (≤128 chars) |
| device_public_key | string | Recommended | PEM or base64-DER public key from the hardware keystore |
| key_algorithm | string | With key | EC_P256 | ED25519 | RSA_2048 |
| platform | string | No | ios | android | web | other |
| rotation_proof | object | Only to rotate | Required 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,
approveorconfirm-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
409with the specific codeROTATION_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.
}
}