On this page

Your first verified player

Confirm a player is a real, unique human (and old enough, if your game sets a minimum age) and get back one anonymous ID for your game. Plain HTTPS from your backend; your servers never receive the player's name, birth date, or document.

You need: a backend that can make HTTPS calls, a web page to show a QR code, and a phone with a passport app: the Self app (the default) or the ZKPassport app (sandbox today; see Verification apps). How the pieces talk to each other: see the diagram.

1Get a sandbox key

Sign in at console.nescio.dev and create a game. You get a test API key right away (it starts with nsc_ and is shown once). Test keys run in the sandbox: only mock passports verify, so you can try the whole flow without a real document. New games have no age check; add one any time under Minimum age.

Store the key on your server, for example as an environment variable. It is a secret: never send it to a browser or game client.

shell
export NESCIO_API_KEY="nsc_…"

2Start a sessionserver

One call per verification attempt. No request body needed: the session uses the default verification app for your key's environment. To pick one, send {"provider": "zkpassport"} or {"provider": "self"} (see Verification apps).

curl -X POST https://api.nescio.dev/v1/game/verify/sessions \
  -H "Authorization: Bearer $NESCIO_API_KEY"
// Node 18+ (built-in fetch). An Express route your page calls.
const NESCIO = "https://api.nescio.dev";
const auth = { Authorization: `Bearer ${process.env.NESCIO_API_KEY}` };
const sessions = new Map(); // sessionId → your player id (use your database)

app.post("/api/verify/start", async (req, res) => {
  const r = await fetch(`${NESCIO}/v1/game/verify/sessions`, {
    method: "POST",
    headers: auth,
  });
  if (r.status === 429) {
    // Sandbox daily quota used up. See "Sandbox & going live".
    const { retryAfterSeconds } = await r.json();
    return res.status(503).json({ error: "try_later", retryAfterSeconds });
  }
  if (!r.ok) return res.status(502).json({ error: "verification_unavailable" });

  const { sessionId, client, expiresAt } = await r.json();
  sessions.set(sessionId, req.user.id);
  // client.kind says which app scans the QR: "self" or "zkpassport".
  res.json({ sessionId, universalLink: client.universalLink, app: client.kind, expiresAt });
});

The response (201):

response · json
{
  "sessionId": "b2f1c8e0-4a6d-4c3f-9b5e-2a1d7c9f4e6b",
  "provider": "self",
  "client": {
    "kind": "self",
    "universalLink": "https://redirect.self.xyz?selfApp=%7B%22version%22%3A2%2C…%7D",
    "selfApp": { "version": 2, "…": "…" }
  },
  "status": "pending",
  "mode": "sandbox",
  "minAge": null,
  "expiresAt": "2026-09-27T21:10:00.000Z"
}
  • sessionId: keep it. You poll with it in step 5 (it's the $SESSION_ID there). Remember which of your players started it.
  • client.universalLink: send this to the browser and render it as a QR. It works the same for every app. selfApp is Self's own config, passed through for teams using Self's QR component; you can ignore it.
  • client.kind (and provider): which app the player scans with, "self" or "zkpassport". Use it to name the app on your page. A ZKPassport client also carries url (the same link) and an opaque requestId.
  • mode: "sandbox" for a test key, "live" for a live key.
  • minAge: the age this session asks the player to prove, copied from your game's setting (null = no age check). See Minimum age.
  • expiresAt: the session's deadline (10 minutes by default). Poll until then, plus once more (step 5).

3Show the QR codebrowser

Your page asks your backend to start a session, then draws universalLink as a QR, whichever app the session uses. Any QR library works; this uses qrcode from a CDN (or npm install qrcode).

A Self link is long (about 750 characters), so the code is dense. Draw it at least 320 px wide, multiply by devicePixelRatio so it stays sharp, and keep error correction at L on purpose: higher levels add even more modules and make it harder to scan.

html · your page
<canvas id="qr"></canvas>
<a id="open-app" hidden></a>
<p id="status" role="status"></p>

<script type="module">
  import QRCode from "https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm";

  const status = document.getElementById("status");
  const r = await fetch("/api/verify/start", { method: "POST" });
  if (!r.ok) {
    status.textContent = "Verification is unavailable. Try again in a minute.";
    throw new Error(`start failed: ${r.status}`);
  }
  const { sessionId, universalLink, app, expiresAt } = await r.json();
  const appName = app === "zkpassport" ? "ZKPassport" : "Self";

  // 320 CSS px, drawn at the screen's pixel density so the dense code stays crisp.
  const size = 320;
  const canvas = document.getElementById("qr");
  await QRCode.toCanvas(canvas, universalLink, {
    width: size * devicePixelRatio,
    margin: 2,
    errorCorrectionLevel: "L", // intentional: the link is long
  });
  canvas.style.width = canvas.style.height = `${size}px`;

  // Players already on their phone can't scan their own screen: give them a link.
  const open = document.getElementById("open-app");
  open.href = universalLink;
  open.textContent = `Open in the ${appName} app`;
  open.hidden = false;

  // Step 5 adds the polling here.
</script>

This runs on its own: the QR appears and the player can scan it. To learn the result, add the browser code from step 5 where the last comment is.

4Scan with a mock passport

With a test key, only a mock passport verifies; a real passport won't. With Self (the default app), each tester creates one once, in the Self app:

  1. Install the Self app and open it.
  2. Tap the settings gear (top right), then Manage ID documents, then Generate Mock Document.
  3. Set an age at or above your game's minimum age, if it has one (a younger mock is how you test the under_min_age failure), then tap Generate Mock Document.
  4. Scan your page's QR from the Self app and approve the request.

Self's guide, with screenshots: Using mock passports. Their steps for the app are the same for Nescio; the parts about Self Enterprise keys don't apply to you. Stuck? See Troubleshooting mock passports.

Test a second account: start a new session and scan it with the same mock document. It verifies with duplicate: true and the same subjectId. Scan it in a different game and it's duplicate: false with a different subjectId: duplicate is per game.

Testing with ZKPassport? Turn on Developer Mode in the ZKPassport app to load its mock IDs (ZKPassport's dev mode guide), then scan a session started with {"provider": "zkpassport"}. One difference: every ZKPassport mock ID shares one identifier, so the sandbox treats each ZKPassport mock verification as a new player. You can't reproduce duplicate: true with it; use Self's sandbox for that.

5Poll for the result

Ask for the session every 2 seconds until it leaves pending. The API key stays on your server, so the browser polls your backend and your backend asks Nescio.

Keep polling past expiresAt until the status changes: the first read after the deadline returns expired, or verified if the proof landed just in time. Finished sessions stay readable for 24 hours after expiresAt, then return 404; save the subjectId the first time you see it.

On your server

# SESSION_ID is the sessionId from step 2
curl https://api.nescio.dev/v1/game/verify/sessions/$SESSION_ID \
  -H "Authorization: Bearer $NESCIO_API_KEY"
// Same Express app as step 2 (NESCIO, auth, sessions). Proxy the poll; act once.
app.get("/api/verify/:sessionId", async (req, res) => {
  const { sessionId } = req.params;
  const playerId = sessions.get(sessionId);
  if (!playerId) return res.status(404).json({ error: "unknown_session" });

  const r = await fetch(
    `${NESCIO}/v1/game/verify/sessions/${encodeURIComponent(sessionId)}`,
    { headers: auth },
  );
  if (r.status === 404) {
    // Unknown to Nescio, or purged 24 h after expiresAt: nothing left to wait for.
    sessions.delete(sessionId);
    return res.status(404).json({ error: "unknown_session" });
  }
  if (!r.ok) return res.status(502).json({ error: "verification_unavailable" });
  const s = await r.json();

  if (s.status === "verified") {
    // subjectId: the same for this human every time, in your game only.
    // duplicate: this passport already has a player in THIS game. Link this
    // account to that player's record, or block it. It isn't fraud.
    await linkPlayer(playerId, s.subjectId, { duplicate: s.duplicate });
  }
  if (s.status !== "pending") sessions.delete(sessionId);
  res.json({ status: s.status, duplicate: s.duplicate, reason: s.reason });
});

In the browser

Paste this into the step 3 script, in place of its last comment.

javascript · your page, step 3 script
// Poll your backend (never Nescio: the key stays on your server).
async function waitForResult(sessionId, expiresAt) {
  const show = (text) => { status.textContent = text; };
  // The first read after expiresAt returns "expired" (or a late "verified"),
  // so the loop ends on its own. The cap only guards against a silent backend.
  const giveUpAt = Date.parse(expiresAt) + 60_000;
  while (Date.now() < giveUpAt) {
    const r = await fetch(`/api/verify/${encodeURIComponent(sessionId)}`);
    if (r.status === 404) {
      return show("This code is no longer valid. Start again for a new one.");
    }
    if (r.ok) {
      const s = await r.json();
      if (s.status === "verified") {
        return show(s.duplicate
          ? "This passport already has an account in this game."
          : "Verified. Welcome!");
      }
      if (s.status === "failed") {
        return show(s.reason === "under_min_age"
          ? "You need to meet this game's minimum age to play."
          : "Verification failed. Start again to retry.");
      }
      if (s.status === "expired") {
        return show("The code expired. Start again for a new one.");
      }
    } // any other error is a hiccup: keep polling
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
  show("We couldn't get a result. Start again for a new code.");
}

waitForResult(sessionId, expiresAt);

The poll always returns every field; those that don't apply yet are null. A verified session:

response · json
{
  "sessionId": "b2f1c8e0-4a6d-4c3f-9b5e-2a1d7c9f4e6b",
  "status": "verified",
  "provider": "self",
  "client": { "kind": "self", "universalLink": "https://redirect.self.xyz?selfApp=…" },
  "duplicate": false,
  "reason": null,
  "mode": "sandbox",
  "expiresAt": "2026-09-27T21:10:00.000Z",
  "subjectId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
}
statusWhat to do
pendingKeep polling until expiresAt, then once more.
verifiedKey the player on subjectId. If duplicate is true, this passport already has a player in this game: it's the same player on a second account, not fraud. Link the account to their existing record (same subjectId) or block it. A verification in another game never sets it.
failedRead reason: under_min_age (didn't prove your game's minimum age), proof_invalid, expired (the proof arrived after the deadline), missing_nullifier or missing_unique_identifier, or document_not_allowed (a document type Nescio doesn't accept: only NFC-chip passports and biometric ID cards). ZKPassport sessions can also end with rejected_by_user (the player declined in the app), zkpassport_error:<detail> (the app reported an error), verify_error:<detail> (the proof couldn't be checked), mock_proof_rejected (a mock ID on a live key), malformed_callback (the app sent a broken proof set), or unknown_bridge_session (the proof reached a server that no longer holds the connection). Match on the part before the colon. Start a new session to retry.
expiredNobody finished in time. Start a new session.

6Read the player's claimsserver

Any time later, look up what a subjectId proved. You never get the underlying document.

# SUBJECT_ID is the subjectId from a verified session
curl https://api.nescio.dev/v1/game/players/$SUBJECT_ID \
  -H "Authorization: Bearer $NESCIO_API_KEY"
const r = await fetch(
  `${NESCIO}/v1/game/players/${encodeURIComponent(subjectId)}`,
  { headers: auth },
);
if (r.status === 404) throw new Error("not a player of this game");
const { claims } = await r.json(); // an array: one record per verification method

// The highest age proven, e.g. 18; 0 if no game ever asked.
const provenAge = Math.max(0, ...claims.map((c) => c.age_proven ?? 0));
const nationality = claims[0]?.issuing_country; // e.g. "FRA", or null
response · json
{
  "subjectId": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "claims": [
    {
      "method": "document_nfc",
      "assurance_level": "substantial",
      "verified_human": 1,
      "over_18": 1,
      "age_proven": 18,
      "issuing_country": "FRA",
      "verified_at": "2026-09-27T21:02:41.512Z"
    }
  ]
}
ClaimMeaning
methodHow the player verified: document_nfc (Self app) or zkpassport_nfc (ZKPassport app, with an on-device face match). One record per method the player used; see Verification apps.
verified_human1: a real human verified with a document.
age_provenThe highest minimum age this player has proven (13, 16, 18 or 21), or null if no game ever asked.
over_181 if 18+ was proven, otherwise null. Never 0: an under-age proof fails the session and isn't stored.
issuing_countryNationality as disclosed by the document (ISO 3166-1 alpha-3, e.g. FRA), or null. Despite the name, it isn't the issuing state.

Verification apps

Players verify in one of two apps, each reading the passport's NFC chip and proving on the phone: Self ("self") and ZKPassport ("zkpassport", which adds an on-device face match). Nescio decides which apps each environment offers and which one is the default; your game's settings in the dashboard show both, for sandbox and live keys. Today sandbox keys offer both apps, with Self as the default, and live keys offer Self.

  • Default: send no body and the session uses the default app for your key's environment.
  • Pick one: send {"provider": "zkpassport"} (or "self"). An app that isn't on for your key's environment returns 400 provider_not_enabled, listing the accepted values in enabled; no session is created and no quota is used.
  • Render it the same way: draw client.universalLink as the QR for either app, and use client.kind to name the app (ZKPassport on the App Store, on Google Play).
  • Each app is its own identity. The same person verifying with Self and with ZKPassport becomes two identities, with a different subjectId for each app in your game, and the second isn't flagged as a duplicate. Offering one app per game keeps one player per person.
  • Sandbox: each ZKPassport mock verification counts as a new player (see step 4). On a live key, ZKPassport mock IDs fail with mock_proof_rejected.

Minimum age

New games have no age check. To require one, open your game in the dashboard and pick a minimum age in its settings: 13, 16, 18 or 21. Only a studio admin can change it.

The dashboard saves it through the studio API (PATCH /v1/studio/games/{gameId}, signed in with your dashboard account). It isn't part of the game API: your game key can't change it, and your integration doesn't need to.

  • Every new session copies the setting as minAge. The QR asks the player to prove that age, and asks for no age at all when there's none.
  • A player who can't prove it gets status: "failed" with reason: "under_min_age".
  • Raising the age applies to sessions created afterwards; a QR already on screen keeps the age it asked for. Players who verified earlier aren't re-checked: compare their age_proven claim with the new age, and send anyone below it through a new session, which asks for the new age.

How uniqueness works across games

Who calls whom Your page asks your backend to start and poll. Your backend calls the Nescio API with the API key. Your page shows a QR code that the player scans with their verification app (Self or ZKPassport), and the app sends the proof to Nescio. Your pagebrowser Player's appSelf or ZKPassport Your backendholds the API key Nescio APIapi.nescio.dev shows the QR code player scans it start a session, poll the result sends the proof to Nescio create + poll (API key) gets subjectId, duplicate Who calls whom Your page asks your backend to start and poll. Your backend calls the Nescio API with the API key. Your page shows a QR code that the player scans with their verification app (Self or ZKPassport), and the app sends the proof to Nescio. shows the QR, player scans Your pagebrowser Player's appSelf/ZKPassport start, poll proof to Nescio Your backendholds the key Nescio APIapi.nescio.dev create + poll (API key)
The API key only travels between your backend and Nescio. The player's document never leaves their phone.
  • One subjectId per human, per game. The same person always gets the same subjectId in your game, and a different one in every other game. The ids are derived with a secret only Nescio holds, so two games comparing notes can't link a player.
  • duplicate is per game. It's true only when this passport already has a player in this game. It tells you nothing about other games: a player verified elsewhere arrives here as duplicate: false.
  • duplicate: true is a second account, not fraud. You get the existing subjectId back. Link the new account to that player's record, or block it, per your rules. To ban a person, ban the subjectId: it comes back on every account they make.

Sandbox & going live

The sandbox quota

Each test key can start 500 sessions per UTC day. Every start reports what's left in the RateLimit-Limit and RateLimit-Remaining headers; a start that fails with 502 doesn't count. Over the cap, the call returns 429 with a Retry-After header (seconds until UTC midnight) and this body:

429 · json
{
  "error": "quota_exceeded",
  "limit": 500,
  "retryAfterSeconds": 34200,
  "hint": "sandbox keys are capped per day — request a live key (operator-approved) for production volume"
}

Don't retry in a loop: the quota won't reset until Retry-After has passed. Tell the tester to try again later, or go live.

Going live

  1. In the dashboard, open your game and choose Request live access. You get a live key right away, but it stays inactive (calls return 401) until a Nescio operator approves it.
  2. Once approved, replace the test key on your server with the live key. Nothing else in your integration changes.
  3. Sessions now report mode: "live" and verify real passports only; mock documents stop working. Live keys aren't daily-capped (their RateLimit-* headers read unlimited).

Sandbox and live identities are separate: a player verified in the sandbox gets a new subjectId in live.

Errors

Errors are JSON with an error code, sometimes with a detail.

Game API (your API key)
StatuserrorMeaning
400provider_not_enabledThe provider you asked for isn't on for this key's environment. enabled lists the accepted values; omit provider for the default. No quota used.
401invalid_api_keyMissing, wrong, or revoked key, or a live key not yet approved.
403game_suspendedAn operator suspended this game.
404not_foundNo such session or player for this key's game: unknown, another game's, or a session purged 24 hours after expiresAt. Start a new session.
429quota_exceededSandbox daily quota used up. Wait Retry-After.
500internal_errorSomething failed on Nescio's side. Retry with backoff; quote detail if you contact us.
502provider_unavailableThe verification provider is down. Retry with backoff. It doesn't use sandbox quota.
Dashboard (studio API)
StatuserrorMeaning
400invalid_min_ageThe minimum age isn't one of none, 13, 16, 18 or 21.
403admin_requiredOnly a studio admin can change game settings or manage keys.
409live_key_existsThis game already has a live key, active or waiting for approval.

Troubleshooting mock passports

You seeWhy, and what to do
expiredNobody finished within the session's 10 minutes. (failed with reason: "expired" means the proof arrived just after the deadline.) Start a new session and scan the new QR.
proof_invalidThe proof didn't verify. Start a new session and scan again; if it keeps happening, generate a fresh mock document.
under_min_ageThe mock's age is below your game's minimum age. Generate a mock with a higher age, or check the game's setting under Minimum age.
rejected_by_user, zkpassport_error:…ZKPassport only: the tester declined the request, or the app hit an error before sending a proof. Start a new session; keep the app up to date and Developer Mode on for mock IDs.
The Self app shows an error; your poll stays pendingThe proof was rejected before it reached your session, so the session waits until it expires. Check the key and document match: a test key needs a mock document, a live key a real passport. Then start a new session.
The QR won't scanDraw it at 320 px or more with errorCorrectionLevel: "L" and a margin, and don't shrink it with CSS (step 3). On the phone itself, use the Open in the app link instead.
duplicate: true on a new testerThat mock document already verified in this game. Generate another mock document for each tester.

More on mock documents: Self's Using mock passports, and ZKPassport's Dev mode.

Next

Every endpoint, field, and status, with response samples for each state.