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#
- Pairing codes can be issued and validated.
- Device activation cannot complete yet. Production runs Google Play Integrity in
livemode 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#
| # | Step | Endpoint |
|---|---|---|
| 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 - Only Ed25519 keys are accepted (
400 VALIDATION_ERRORotherwise). -
In live attestation mode the Play Integrity token must be at most 10 minutes old and carry a
requestHashequal to the lowercase hex SHA-256 of the exactpublicKeyPemstring in the same request, so a token cannot be replayed against another key. - A pairing code is consumed by its first successful redemption and expires after 15 minutes (
PAIRING_CODE_USED,PAIRING_CODE_EXPIRED,PAIRING_CODE_REVOKED). - The challenge expires after 5 minutes (
410 ACTIVATION_CHALLENGE_EXPIRED); start again with a new code.
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.
| Header | Value | Rule |
|---|---|---|
Authorization | Bearer <device access token> | Not sent on activation or refresh. |
X-RR-Device-Id | dev_mob_… | Must match the device the token was issued to. |
X-RR-Timestamp | epoch seconds (recommended), epoch milliseconds or ISO 8601 | Must be within ±120 seconds of server time. |
X-RR-Nonce | 16–128 URL-safe characters | Single use; a repeat within 5 minutes is refused. |
X-RR-Content-SHA256 | lowercase hex SHA-256 of the exact body bytes | For an empty body, the hash of the empty string. |
X-RR-Device-Signature | base64url Ed25519 signature of the canonical string | See 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
| Status | Code | Cause |
|---|---|---|
| 401 | TIMESTAMP_SKEW | Clock more than 120 seconds from server time. Use the serverTime returned by the API to correct your clock. |
| 401 | NONCE_REPLAYED | The nonce was already used in the last 5 minutes. |
| 401 | SIGNATURE_INVALID | Bad signature, header format, or the body hash does not match the body sent. |
Enrolment & offline sync#
- Idempotency. Mutating calls take a
clientRequestId(8–128 URL-safe characters). Replaying one returns200withdata.idempotentReplay = trueinstead of creating a duplicate, so it is always safe to retry after a timeout. - Batch order. Offline batches carry a
deviceSeqthat must exceed the last accepted value and apreviousBatchHashthat chains to the last accepted batch (409 BATCH_OUT_OF_ORDER,409 BATCH_HASH_MISMATCH). UseGET /mobile/sync/stateto resume after a gap. - Consent. Enrolment requires the learner's recorded POPIA consent and a
consentRefpointing at it (403/400 CONSENT_REQUIRED). Biometric enrolment without consent is refused. - Scope. A device only handles learners in its assigned cohorts and venues
(
ASSIGNMENT_REQUIRED,LEARNER_NOT_ASSIGNED). A failed match is a plain404 MATCH_NOT_FOUNDerror and returns no candidates.
Limits & data handling#
| Item | Limit | Error |
|---|---|---|
| Events per sync batch | 1–500 | 400 PAYLOAD_TOO_LARGE |
| Fingerprint template | base64, 64–4096 bytes | 400 TEMPLATE_TOO_LARGE |
| Finger position | A supported finger | 400 TEMPLATE_FORMAT_UNSUPPORTED |
| Capture quality (NFIQ 2) | At least 50 by default | 422 QUALITY_TOO_LOW |
| Duplicate enrolment | A template already enrolled to another learner | 409 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.