# Signed check results

> Prove on your server that a check-in device really checked someone. Every result is signed by the device, and FacePing or your own code checks the signature.

Source: https://docs.faceping.ai/signed-results/

A check happens on the device, offline, and the result goes to your app. When your **server** needs to rely on that result (a staff or member check-in, a door that opens, a reward that's paid out), it needs proof that the device really returned it, and that nobody edited the app or replayed an old result. Every check-in device signs every result it returns, and your server checks the signature.

This works for check-in devices (an app started with a device token, `fpd_`), including devices on your practice list with a sandbox key. A sandbox app that enrols faces on the phone has no device identity, so its results aren't signed (`signedResult` is `null`).

## How it works

1. **A key for each device.** The first time an app runs as a check-in device, FacePing creates a signing key (P-256) on it. The private key stays on the device, encrypted in FacePing's store and protected by the platform's key store (Android Keystore, iOS Keychain). It is kept when you [move the device to another list](https://docs.faceping.ai/devices/#move-a-device-to-another-list-or-retire-it) or unpair it, and goes only when the app's data is cleared or the app is reinstalled.
2. **Registered at the first sync.** The device sends its public key with its first sync, and FacePing keeps it with that device. The first key is the one that counts: if the device later sends a different one, checks keep working but its new results won't verify (the sync tells the app, and the dashboard shows **Key mismatch**). Add the device again, with a new token, to register the new key.
3. **Every outcome is signed.** Each check on the device returns `signedResult`, a compact JWS (ES256), for a match and for every other outcome, so your server sees refusals too. It holds no face data, photos or scores.
4. **Your server verifies it.** Send it to [`POST /v1/check-results/verify`](#3-verify-it-on-your-server), which checks everything and tells you what the device found. Or [verify it yourself](#verify-it-yourself) with the device's public key.

## What a signed result says

The header is `{"alg":"ES256","typ":"faceping-check+jwt","kid":"…"}`, where `kid` names the device's key. The claims:

| Claim | Meaning |
| --- | --- |
| `v` | `1` |
| `iss` | `faceping-device` |
| `dev` | The device's id (the `id` from [adding the device](https://docs.faceping.ai/devices/#1-add-each-device-to-the-list)) |
| `lst` | The check-in list the device is on |
| `pid` | The `personId` that was checked |
| `out` | The outcome: `match`, `noMatch`, `notEnrolled`, `noFace`, `livenessFailed`, `challengeFailed`, `expired` or `leaseExpired` |
| `live` | `true` for a live check (with liveness), `false` for a single photo |
| `iat` | When the device checked, by its clock (Unix seconds) |
| `seq` | A counter on the device that goes up with every signed result |
| `jti` | A random id, unique to this result |
| `nonce` | Your nonce, when the app passed one |
| `sdk` | The SDK and version, for example `flutter/1.0.0-preview.6` |

## 1. Give the app a nonce for each check

When the device can reach your server as the check starts, issue a **nonce**: a random, single-use string (up to 128 characters) that your server remembers for a few minutes. The device signs it into the result, so a result can only answer that one request: an old result, or one copied from another device, won't carry it. The nonce is optional; without a connection, see [Offline and queued results](#offline-and-queued-results).

```js title="Node.js (your server)"
import { randomBytes } from 'node:crypto';

app.post('/checks/nonce', requireStaffDevice, async (req, res) => {
  const nonce = randomBytes(16).toString('base64url');
  await nonces.save(nonce, { device: req.device.id, expiresAt: Date.now() + 5 * 60_000 });
  res.json({ nonce });
});
```

## 2. Pass it to the check and send the result

Pass the nonce to the live check (or to a single-photo verify), then send `signedResult` to your server. The app can show the outcome straight away; your server decides what counts.

```csharp title=".NET MAUI"
var nonce = await backend.NewCheckNonceAsync();                       // your server, step 1
var result = await faceping.VerifyLiveAsync(scannedId, Camera, nonce);
if (result.SignedResult is { } signedResult)
    await backend.RecordCheckAsync(signedResult);                     // your server, step 3
```

```csharp title="Avalonia"
var nonce = await backend.NewCheckNonceAsync();
var result = await IFacePing.Current.VerifyLiveAsync(scannedId, Camera, nonce);
if (result.SignedResult is { } signedResult)
    await backend.RecordCheckAsync(signedResult);
```

```kotlin title="Android (Kotlin)"
val nonce = backend.newCheckNonce()
val result = FacePing.current.verifyLive(scannedId, camera, nonce = nonce)
result.signedResult?.let { backend.recordCheck(it) }
```

```swift title="iOS (Swift)"
let nonce = try await backend.newCheckNonce()
let result = try await faceping.verifyLive(id: scannedId, camera: camera, nonce: nonce)
if let signedResult = result.signedResult {
    try await backend.recordCheck(signedResult)
}
```

```dart title="Flutter"
final nonce = await backend.newCheckNonce();
final result = await FacePing.current.verifyLive(scannedId, camera, nonce: nonce);
final signedResult = result.signedResult;
if (signedResult != null) await backend.recordCheck(signedResult);
```

```ts title="React Native"
const nonce = await backend.newCheckNonce();
const result = await FacePing.verifyLive(scannedId, camera, { nonce });
if (result.signedResult) await backend.recordCheck(result.signedResult);
```

```ts title="Capacitor"
const nonce = await backend.newCheckNonce();
const result = await FacePing.verifyLive(scannedId, undefined, { nonce });   // camera screen options, then { nonce }
if (result.signedResult) await backend.recordCheck(result.signedResult);
```

`verify` (a single photo) takes a nonce the same way. A nonce is 1 to 128 characters with no control characters; anything else is refused before the check starts.

### Watch the signing state

Each sync says whether FacePing holds this device's key: `signing` is registered, mismatch, or unknown (before the server has said, and in sandbox-key apps). On a mismatch, show staff a warning: checks still work, but the server won't accept the device's results until it's added again.

```csharp title=".NET MAUI"
faceping.Synced += (_, sync) =>
{
    if (sync.Signing == Signing.Mismatch)
        MainThread.BeginInvokeOnMainThread(() => Warning.Text = "Add this device again: its signed results won't verify.");
};
```

```csharp title="Avalonia"
IFacePing.Current.Synced += (_, sync) =>
{
    if (sync.Signing == Signing.Mismatch)
        Dispatcher.UIThread.Post(() => Warning.Text = "Add this device again: its signed results won't verify.");
};
```

```kotlin title="Android (Kotlin)"
lifecycleScope.launch {
    FacePing.current.synced.collect { sync ->
        if (sync.signing == SigningState.MISMATCH) warning.text = "Add this device again: its signed results won't verify."
    }
}
```

```swift title="iOS (Swift)"
for await sync in faceping.synced where sync.signing == .mismatch {
    warning = "Add this device again: its signed results won't verify."
}
```

```dart title="Flutter"
FacePing.current.synced.listen((sync) {
  if (sync.signing == SigningState.mismatch) {
    setState(() => warning = 'Add this device again: its signed results won\'t verify.');
  }
});
```

```ts title="React Native"
const sub = FacePing.addListener('synced', (sync) => {
  if (sync.signing === 'mismatch') setWarning("Add this device again: its signed results won't verify.");
});
```

```ts title="Capacitor"
await FacePing.addListener('synced', (sync) => {
  if (sync.signing === 'mismatch') warning.textContent = "Add this device again: its signed results won't verify.";
});
```

## 3. Verify it on your server

Your server sends the signed result, and the nonce it issued, to FacePing with your API key (a sandbox key verifies results from your practice list):

[POST /v1/check-results/verify](https://docs.faceping.ai/reference/#tag/check-results/POST/v1/check-results/verify)

```bash
curl -X POST https://api-eu.faceping.ai/v1/check-results/verify \
  -H "X-Api-Key: $FACEPING_API_KEY" -H "Content-Type: application/json" \
  -d '{"signedResult":"eyJhbGciOiJFUzI1NiIs…","nonce":"q3Jb9tZ1VfS0a8d2Lr7xNw"}'
```

FacePing checks the signature against the key the device registered, that the device is on one of your lists, that the nonce matches (when you send one), that the result was signed less than 30 days ago and not in the future, and that it hasn't been verified before. Then it answers with what the device found:

```json
{
  "match": true,
  "outcome": "match",
  "personId": "STAFF-0042",
  "listId": "0c8e4d2a-1b3f-4e6a-8d9c-7f5e2a1b3c4d",
  "deviceId": "6f1c2b0e-8d4f-4a57-9c3e-2b7d1a0e5f42",
  "deviceLabel": "Staff entrance",
  "live": true,
  "checkedAt": "2026-11-12T08:59:41+00:00",
  "seq": 214,
  "seqGap": false,
  "jti": "Jkc11zB_nkx8NJBzKPSqvQ",
  "nonce": "q3Jb9tZ1VfS0a8d2Lr7xNw",
  "sdk": "flutter/1.0.0-preview.6",
  "keyId": "_WrIS4adv2RkkXPLm7zYYA",
  "deviceRevoked": false,
  "revokedAt": null,
  "verifiedAt": "2026-11-12T08:59:42+00:00"
}
```

`match` is `true` only for a match; `outcome` has the detail. Check `personId` is the person you expected, and decide what a result from a since-revoked device means for you (`deviceRevoked`: results it signed before still verify).

Each result verifies **once**. A second attempt gets `409` with `reason: "replayed"`. When a result doesn't verify, the answer is `422` with a `reason` and a `message`:

| `reason` | Meaning |
| --- | --- |
| `malformed` | Not a FacePing signed check result. Send `signedResult` unchanged. |
| `badSignature` | The result was altered, or wasn't signed by this device. |
| `unknownKey` | Signed by a key FacePing doesn't hold for the device, usually after the app was reinstalled or its data cleared. Add the device again. |
| `unknownDevice` | No device with that id on that list (it may have gone with its list). |
| `wrongAccount` | The device is on another account (or, with a sandbox key, not on your practice list). |
| `nonceMismatch` | The result's nonce isn't the one you sent, or it has none. |
| `tooOld` | Signed more than 30 days ago. |
| `future` | Signed more than 5 minutes in the future: check the device's clock. |
| `replayed` (`409`) | Already verified once. |

## Verify it yourself

If your server would rather not call FacePing for each check, verify the signature with the device's public key. [`GET /v1/lists/{listId}/devices`](https://docs.faceping.ai/devices/) gives each device's `keyId`, `publicKey` (a JSON Web Key, P-256) and `keyRegisteredAt` (`null` until its first sync), plus `keyMismatchAt` if it has sent a different key since. Cache the keys and look one up by the result's `dev` (or `kid`).

```csharp title="C#"
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

// jwk: the device's publicKey from GET /v1/lists/{listId}/devices. Returns the claims, or throws.
static JsonElement VerifyCheckResult(string signedResult, JsonElement jwk, string? nonce = null)
{
    var parts = signedResult.Split('.');
    if (parts.Length != 3) throw new InvalidDataException("Not a signed check result.");
    var header = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[0])).RootElement;
    if (header.GetProperty("alg").GetString() != "ES256" || header.GetProperty("typ").GetString() != "faceping-check+jwt"
        || header.GetProperty("kid").GetString() != jwk.GetProperty("kid").GetString())
        throw new InvalidDataException("Not signed by this device's key.");

    using var key = ECDsa.Create(new ECParameters
    {
        Curve = ECCurve.NamedCurves.nistP256,
        Q = new ECPoint
        {
            X = Base64Url.DecodeFromChars(jwk.GetProperty("x").GetString()),
            Y = Base64Url.DecodeFromChars(jwk.GetProperty("y").GetString())
        }
    });
    var signed = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
    if (!key.VerifyData(signed, Base64Url.DecodeFromChars(parts[2]), HashAlgorithmName.SHA256))   // r || s, as JWS uses
        throw new InvalidDataException("Bad signature.");

    var claims = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[1])).RootElement;
    if (claims.GetProperty("iss").GetString() != "faceping-device" || claims.GetProperty("v").GetInt32() != 1)
        throw new InvalidDataException("Not a FacePing check result.");
    if (nonce != null && (!claims.TryGetProperty("nonce", out var n) || n.GetString() != nonce))
        throw new InvalidDataException("Nonce mismatch.");
    return claims;
}
```

```js title="Node.js"
import { createPublicKey, verify } from 'node:crypto';

// jwk: the device's publicKey from GET /v1/lists/{listId}/devices. Returns the claims, or throws.
export function verifyCheckResult(signedResult, jwk, nonce) {
  const [h, p, s] = signedResult.split('.');
  if (!s) throw new Error('Not a signed check result.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg !== 'ES256' || header.typ !== 'faceping-check+jwt' || header.kid !== jwk.kid) {
    throw new Error("Not signed by this device's key.");
  }
  const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: 'jwk' });
  const ok = verify('sha256', Buffer.from(`${h}.${p}`), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url'));
  if (!ok) throw new Error('Bad signature.');
  const claims = JSON.parse(Buffer.from(p, 'base64url'));
  if (claims.iss !== 'faceping-device' || claims.v !== 1) throw new Error('Not a FacePing check result.');
  if (nonce !== undefined && claims.nonce !== nonce) throw new Error('Nonce mismatch.');
  return claims;
}
```

```python title="Python"
# pip install "pyjwt[crypto]"
import json
import jwt
from jwt.algorithms import ECAlgorithm

def verify_check_result(signed_result: str, jwk: dict, nonce: str | None = None) -> dict:
    """jwk: the device's publicKey from GET /v1/lists/{listId}/devices. Returns the claims, or raises."""
    header = jwt.get_unverified_header(signed_result)
    if header.get("typ") != "faceping-check+jwt" or header.get("kid") != jwk["kid"]:
        raise ValueError("Not signed by this device's key.")
    key = ECAlgorithm.from_jwk(json.dumps(jwk))
    claims = jwt.decode(signed_result, key, algorithms=["ES256"], issuer="faceping-device",
                        options={"require": ["iat", "jti", "dev", "lst", "pid", "out"]})
    if claims.get("v") != 1:
        raise ValueError("Not a FacePing check result.")
    if nonce is not None and claims.get("nonce") != nonce:
        raise ValueError("Nonce mismatch.")
    return claims
```

The signature proves the device signed the result. The rest is then up to your server, as FacePing's endpoint does it:

- **Replays:** store each `jti` you accept (for at least 30 days) and refuse one you've seen.
- **Whose device:** `dev` and `lst` must be a device and a list on your account, and the key must be the one you cached for that device.
- **Time:** refuse an `iat` too far in the past or ahead of your clock.

## Offline and queued results

A signed result is self-contained, so a device without a connection can queue it and send it when it's back online. Without a nonce, rely on:

- **`jti`**: each result verifies once, so a queued result can't be counted twice.
- **`iat`**: when the device checked. FacePing accepts results up to 30 days old.
- **The lease:** a device stops checking people 24 hours after its last sync, so a signed result never comes from a device that's been out of touch for longer than that.
- **`seq`** counts up on each device. Queued results can arrive out of order, so FacePing reports `seqGap: true` (for information) when a result's `seq` isn't above every one it has verified for that device. A run of gaps from one device is worth a look.

## Nonces

Issue a fresh nonce for each check, keep it for a few minutes, and accept it once. Send it to FacePing with the result (or compare it yourself): a result that doesn't carry it is `nonceMismatch`. Don't reuse nonces across devices or checks, and don't put personal data in them; any random string up to 128 characters works.

## Limits

- **The key isn't held in secure hardware.** It is encrypted on the device and its encryption key is protected by the platform's key store, but the signing itself runs in FacePing's code inside your app. On a phone that's been rooted or jailbroken, a determined attacker who can run code inside your app could sign results with it. To make that much harder, check the app itself: use [Play Integrity](https://developer.android.com/google/play/integrity) on Android and [App Attest](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity) on iOS, with your server's nonce, and only accept results from devices that pass.
- **It proves the device's answer, not who stood in front of it.** Liveness and the head-turn check are what make that hard; see [Anti-spoofing](https://docs.faceping.ai/devices/#anti-spoofing). Keep staff in view of unattended devices.
- **Reinstalling the app makes a new key.** Add the device again (a new token) after a reinstall or after its data is cleared.

## Example: staff or member check-in, or a door

1. Each person's `personId` is your staff or member number, enrolled on an ongoing list. Not everyone has to use their face: offer a non-face way in (a badge, a PIN, a member of staff), as FacePing requires for every list.
2. A device at the entrance is paired with the list's device token.
3. Someone scans their badge. The app asks your server for a nonce, runs the live check against the badge's `personId` with that nonce, and sends `signedResult` to your server.
4. Your server verifies it (FacePing's endpoint, or your own code), checks `personId` is the badge holder, and records the check-in or opens the door. Anything other than a match goes to your non-face route.

Staff and member uses of face verification carry their own legal duties (for example, a data protection impact assessment and a clear alternative). See [Privacy and compliance](https://docs.faceping.ai/compliance/).
