Guide

Mobile device API

The contract for the Android app and its USB fingerprint scanner: single-use pairing, Ed25519-signed requests, template-only enrolment, bounded matching and idempotent offline sync. Field-level detail is in the endpoint reference.

Availability#

Preview — not yet usable end to end in production
  • Pairing codes can be issued and validated.
  • Device activation cannot complete yet. Production runs Google Play Integrity in live mode and has no Play Console package or service account configured, so attestation fails closed and activation is refused. Simulator mode exists only for non-production deployments and is always labelled as such.
  • Evidence upload is not configured (501 EVIDENCE_UPLOAD_NOT_CONFIGURED).
  • No MINEX III-certified matcher or ISO 30107-3 presentation-attack provider is configured, and none is claimed. Biometric evidence is labelled accordingly.

Device lifecycle#

#StepEndpoint
1 Administrator creates a single-use pairing code (valid 15 minutes, shown once) POST /mobile/admin/pairing-codes
2 Device redeems the code and registers its Ed25519 public key POST /mobile/activation/start
3 Device signs the challenge and receives its tokens POST /mobile/activation/complete
4 Device reads its configuration, roster and sessions GET /mobile/config
5 Enrol learners; match for attendance POST /mobile/enrolments
6 Upload attendance events and offline batches POST /mobile/sync/batches
7 Rotate the refresh token before the lease ends POST /mobile/auth/refresh
8 Revoke (device or administrator) and acknowledge wipe POST /mobile/wipe/ack

Lifetimes (defaults): access token 1 hour, refresh token 90 days, device lease 14 days, activation challenge 5 minutes, pairing code 15 minutes.

Activation#

Activation is authenticated by the pairing code and a proof of key possession — not by a bearer token or request signature, because the device has neither yet. Both calls are rate limited (10 per minute per IP).

# 1 — redeem the pairing code (no token, no signature yet)
curl -s https://rr-api.medq.co.zw/api/v1/mobile/activation/start \
  -H 'content-type: application/json' \
  -d '{ "pairingCode": "ABCD-EFGH", "publicKeyPem": "<Ed25519 SPKI PEM>", "playIntegrityToken": "<token>", "device": { "label": "Trainer phone 3" } }'
# → { "success": true, "data": { "activationId", "deviceId", "challenge", "challengeExpiresAt", "attestationMessage" } }

# 2 — sign "<deviceId>.<challenge>.<tenantId>.<userId>" (this is attestationMessage) and complete
curl -s https://rr-api.medq.co.zw/api/v1/mobile/activation/complete \
  -H 'content-type: application/json' \
  -d '{ "activationId": "act_…", "signature": "<base64url Ed25519 signature>" }'
# → deviceToken, tokenExpiresAt, refreshToken, refreshExpiresAt, leaseExpiresAt, tenant, config, assignments

Signing requests#

Every request after activation carries a bearer token and an Ed25519 signature, so a stolen token alone is useless. The one exception is POST /mobile/auth/refresh, which needs the signature but no access token, because it is used after the access token has expired. GET /mobile/health needs neither.

HeaderValueRule
AuthorizationBearer <device access token>Not sent on activation or refresh.
X-RR-Device-Iddev_mob_…Must match the device the token was issued to.
X-RR-Timestampepoch seconds (recommended), epoch milliseconds or ISO 8601Must be within ±120 seconds of server time.
X-RR-Nonce16–128 URL-safe charactersSingle use; a repeat within 5 minutes is refused.
X-RR-Content-SHA256lowercase hex SHA-256 of the exact body bytesFor an empty body, the hash of the empty string.
X-RR-Device-Signaturebase64url Ed25519 signature of the canonical stringSee below.

The canonical string

METHOD            upper-case HTTP method
PATH              URL path (including /api/v1/mobile/…) plus the query string
                  with parameters sorted by key then value, each percent-encoded
X-RR-Timestamp    exactly as sent
X-RR-Nonce        exactly as sent
X-RR-Content-SHA256  exactly as sent
X-RR-Device-Id    exactly as sent

signature = base64url( Ed25519-sign( privateKey, join("\n", [the six values above]) ) )

Sign the exact bytes you send. If your HTTP client re-serialises the JSON after you hashed it, the server answers 401 SIGNATURE_INVALID.

Reference implementation (Node.js 22)

This file is tested in CI against the server's own verifier; it is the code below, unmodified.

import { createHash, createPrivateKey, randomBytes, sign } from 'node:crypto';

/**
 * Build the authentication headers for one RemoteRegister device request.
 *
 *   method        HTTP method
 *   url           full request URL, exactly as you will call it
 *   body          the exact string you will send ('' for GET/DELETE)
 *   deviceId      dev_mob_… from activation
 *   deviceToken   the access token from activation or refresh
 *   privateKeyPem the Ed25519 private key whose public half you registered
 */
export function signRequest({ method, url, body = '', deviceId, deviceToken, privateKeyPem }) {
  const u = new URL(url);

  // Canonical path: pathname plus query parameters sorted by key then value, each percent-encoded.
  const query = [...u.searchParams.entries()]
    .sort(([a, av], [b, bv]) => (a === b ? av.localeCompare(bv) : a.localeCompare(b)))
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join('&');
  const path = query ? `${u.pathname}?${query}` : u.pathname;

  const contentSha256 = createHash('sha256').update(body).digest('hex'); // lowercase hex
  const timestamp = String(Math.floor(Date.now() / 1000)); // epoch seconds
  const nonce = randomBytes(18).toString('base64url'); // 24 URL-safe chars, single use

  const canonical = [method.toUpperCase(), path, timestamp, nonce, contentSha256, deviceId].join('\n');
  const signature = sign(null, Buffer.from(canonical), createPrivateKey(privateKeyPem)).toString('base64url');

  return {
    Authorization: `Bearer ${deviceToken}`,
    'X-RR-Device-Id': deviceId,
    'X-RR-Timestamp': timestamp,
    'X-RR-Nonce': nonce,
    'X-RR-Content-SHA256': contentSha256,
    'X-RR-Device-Signature': signature
  };
}

Signing errors

StatusCodeCause
401TIMESTAMP_SKEWClock more than 120 seconds from server time. Use the serverTime returned by the API to correct your clock.
401NONCE_REPLAYEDThe nonce was already used in the last 5 minutes.
401SIGNATURE_INVALIDBad signature, header format, or the body hash does not match the body sent.

Enrolment & offline sync#

Limits & data handling#

ItemLimitError
Events per sync batch1–500400 PAYLOAD_TOO_LARGE
Fingerprint templatebase64, 64–4096 bytes400 TEMPLATE_TOO_LARGE
Finger positionA supported finger400 TEMPLATE_FORMAT_UNSUPPORTED
Capture quality (NFIQ 2)At least 50 by default422 QUALITY_TOO_LOW
Duplicate enrolmentA template already enrolled to another learner409 DUPLICATE_BIOMETRIC

Only ISO/IEC 19794-2 minutiae templates (never fingerprint images) cross this API, and the platform does not store images. Biometric data is special personal information under POPIA s26/s27: collect it only with explicit consent, and always offer a non-biometric attendance method.