Device Uniqueness Backend v2
API Reference for Leonardo's iOS smoke — client compatibility runs against the live server.
The iOS smoke drives this sequence end-to-end. Each step gates the next.
- POST
/api/v1/auth/challenges— challenge - POST
/api/v1/auth/app-attest/attestations— returns 202 {} no-op - POST
/api/v1/auth/token— sr25519 proof → JWT - POST
/api/v1/usernames/available?version=v1— JWT-gated chain read - POST
/api/v1/usernames— 202 outbox reservation
202, was drained by the chain-writer (proxy-signed PeopleLite.attest), and finalized on People Chain — confirmed in Resources.UsernameOwnerOf. Invalid or dummy attestation payloads are still rejected by the runtime.
Process health probe. No dependencies checked.
{ "status": "ok", "service": "device-attestation" }
Kubernetes liveness probe — "should this pod be restarted".
{ "status": "alive", "service": "device-attestation" }
Kubernetes readiness probe — checks db and chain subsystems.
{ "status": "ready", "service": "device-attestation", "db": "up", "chain": "up" }
{ "status": "unavailable", "service": "device-attestation", "db": "down" }
Publish the Ed25519 public key so siblings can verify tokens.
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "kid": "...", "x": "..." } ] }
GET /api/v1/attester — the attester authority public key (0x+hex, public).
{ "attester": "0xe4cd20d6d1e0e119d63a943afd2d7496fbbb0ac8e7cd99c3c5f16b63a68e7432" }
Register an App Attest key: verify the attestation object and persist the
credential key (no-op 202 {} while AUTH_ENABLED=false).
| Field | Type | Required | Description |
|---|---|---|---|
| keyId | string | yes | Base64 key identifier (SHA-256 of the credential public key). |
| challenge | string | yes | Base64 challenge previously returned from /auth/challenges. |
| attestation | string | yes | Base64 CBOR attestation object from the platform. |
{ "keyId": "s/134MbeEEZDZKCvOTf+jZgNhpoDwdXZ8cKfTym8FUg=", "challenge": "challenge-from-/auth/challenges", "attestation": "base64-attestation-object" }
{}
{ "_tag": "VERIFY_ATTESTATION_FAILED", "error": "attestation nonce mismatch" }
Issue a fresh single-use challenge (201).
{ "challenge": "base64-32-byte-challenge" }
Verify challenge + client proof and issue an access + refresh token pair.
| Header | Type | Required | Description |
|---|---|---|---|
| Auth-ClientId | string | yes | Base64 of the 32-byte sr25519 public key. |
| Auth-ClientProof | string | yes | Base64 of the 64-byte sr25519 signature over the raw body. |
| Auth-Challenge | string | yes | Challenge previously minted via /auth/challenges. |
| Auth-iOS-Package | string | no | iOS bundle id, e.g. io.pcf.polkadotapp. Selects the App Attest verification path. |
| Auth-Payload | string | no | Base64 App Attest assertion (iOS) or the raw classic Play Integrity token (Android play-integrity), verified when attestation is enabled. |
| Auth-iOS-KeyId | string | no | Base64 App Attest key id registered via /auth/app-attest/attestations. |
| Auth-Android-Package | string | no | Android package name; required for play-integrity and sets the JWT platform claim for Android clients. |
| Auth-Attestation-Type | string | no | Android attestation dispatch: key-attestation verifies the attestationChain field in the JSON body (base64 DER, leaf first), and also sets the Android platform claim when Auth-Android-Package is absent; play-integrity verifies the token in Auth-Payload with self-managed response keys. |
{}
{ "token": "jwt", "refreshToken": "opaque-base64-token" }
{ "error": "UNAUTHORIZED", "message": "unauthorized" }
{ "error": "INTEGRITY_FAILED", "message": "attestation rejected" }
{ "error": "ATTESTATION_CRL_UNAVAILABLE", "message": "attestation CRL unavailable" }
Rotate a refresh token, returning a fresh access + refresh pair.
| Field | Type | Required | Description |
|---|---|---|---|
| refreshToken | string | yes | Opaque refresh token issued by /auth/token or a prior refresh. |
{ "refreshToken": "opaque-base64-token" }
{ "token": "jwt", "refreshToken": "new-opaque-base64-token" }
{ "error": "UNAUTHORIZED", "message": "unauthorized" }
Queue standing for the caller's pending username claim. This route exists only on deployments with the registration queue enabled (QUEUE_ENABLED); with the queue disabled the path serves the standard plain-text 404. Clients should treat ANY 404 here as "not (or no longer) queued" and assume the registration is proceeding — queued claims keep draining even if the queue is later disabled. When an account has several queued claims, the response reports the earliest-enqueued one until it drains.
Authorization header.
{ "queuePosition": 17, "group": 2, "estimatedIterationsRemaining": 5 }
{ "error": "No queue entry found" }
Retry-After).Reserve a lite username and enqueue it for on-chain registration.
Authorization header.
| Field | Type | Required | Description |
|---|---|---|---|
| candidateAccountId | string | yes | SS58 beneficiary account that will own the username. |
| username | string | yes | Base username (lowercase ASCII letters, 6..=29 chars). |
| preferredDigits | string | no | Optional two-digit suffix, e.g. "07"; random-free if omitted. |
| candidateSignature | string | yes | 0x-hex 64-byte sr25519 signature proving control of the candidate. |
| ringVrfKey | string | yes | 0x-hex ring VRF key. |
| proofOfOwnership | string | yes | 0x-hex 64-byte ownership proof. |
| consumerRegistrationSignature | string | yes | 0x-hex 64-byte consumer registration signature. |
| identifierKey | string | yes | 0x-hex 65-byte identifier key. |
| lifetimePoUDVoucher | string | no | Optional single-use registration voucher (the INSTANT lane): bypasses
the PoUD gate and the registration queue. Ignored unless
REGISTRATION_VOUCHERS_ENABLED. |
| attestationChain | string[] | no | Optional Android device-uniqueness evidence (Widevine PoUD): leaf-first
base64 DER attestation chain, 2-10 entries, whose leaf key was created
with attestationChallenge = SHA-256(domain ‖ deviceChallenge ‖
accountKey ‖ deviceId). All three evidence fields are present
together or not at all; ignored unless WIDEVINE_DEDUP_ENABLED.
Sent only when the app measured Widevine L1. |
| deviceChallenge | string | no | Base64 32-byte single-use challenge from /auth/challenges, bound
into the leaf key's attestation challenge. |
| deviceId | string | no | Base64 32-byte device pseudonym:
SHA-256("dub/poud/widevine-id/v1" ‖ rawWidevineId), computed on the
device — the raw id never leaves it. |
| dotns | object | no |
| dotns.* | Type | Required | Description |
|---|---|---|---|
| signature | string | yes | 0x-hex 64-byte signature. |
| signedAt | number | yes | Unix timestamp the reservation was signed at. |
| reservedUsername | string | no | The bare personhood name to reserve. Its own name on chain — it need
not be username, and availability is checked against *this* name, not
against the base of the lite username in the same claim. |
{ "candidateAccountId": "5FbRAkhDvNVecNzHLFxBNXFXNwvBaV69S1W3nfBbnxYypkkT", "username": "tallesx", "preferredDigits": "07", "candidateSignature": "0x...64 bytes...", "ringVrfKey": "0x...", "proofOfOwnership": "0x...64 bytes...", "consumerRegistrationSignature": "0x...64 bytes...", "identifierKey": "0x...65 bytes...", "lifetimePoUDVoucher": "base64url-voucher-key", "attestationChain": [ "base64-der-leaf", "base64-der-root" ], "deviceChallenge": "base64-32-byte-challenge", "deviceId": "base64-32-byte-device-id", "dotns": { "signature": "0x...64 bytes...", "signedAt": 1780000000, "reservedUsername": "reservedname" } }
{ "registrationOutcome": "PAYMENT_REQUIRED" }
{ "base_username": "tallesx", "digits": "07", "username": "tallesx.07" }
{ "error": "The request body contains invalid values.", "fields": [ { "field": "candidateSignature", "message": "Invalid signature." } ] }
{ "error": "DEVICE_EVIDENCE_INVALID", "message": "device evidence invalid" }
{ "error": "Preferred digits 07 already taken for username tallesx" }
{ "error": "Failed to persist username registration" }
{ "error": "iOS DeviceCheck verification failed" }
{ "error": "Failed to mark iOS device as registered with Apple DeviceCheck" }
PAYMENT_REQUIRED is historical (the retired paid lane) and kept for client compatibility.QUEUE_ENABLED) and the queue service live, the claim waits in the balance-priority queue and the body additionally carries registrationOutcome: "QUEUED" plus the claim's queue standing (poll GET /api/v1/registration/queue for updates). A down queue service falls back to the direct registration path (no queue fields). With REGISTRATION_VOUCHERS_ENABLED, a valid lifetimePoUDVoucher bypasses the device gate and the queue: the reservation goes straight to the writer and the body carries registrationOutcome: "INSTANT".fields), malformed JSON, a lifetimePoUDVoucher that is unknown, already used, or expired ({"error": "Voucher already used"} — a voucher failure rejects the claim outright), or — with WIDEVINE_DEDUP_ENFORCE — structurally malformed device evidence ({"error": "DEVICE_EVIDENCE_MALFORMED"}: partial fields, bad base64, or wrong field sizes — the specific reason is logged server-side, never returned).WIDEVINE_DEDUP_ENFORCE: chain policy, the cert-bound evidence hash (challenge / account / deviceId), or a spent challenge. The specific reason is logged server-side, never returned. Retryable once with a fresh challenge; a claim retried without evidence gets the 200 ineligible outcome.dotns.reservedUsername for a personhood name that is already owned or whose reservation queue is full — checked against the reserved name itself, which attest takes as its own argument and which need not be username. The runtime checks that leg *before* it writes the lite username, and the consumer signature covers it, so submitting would cost the whole registration and no server-side retry could rescue it. Re-sign for another dotns.reservedUsername.Retry-After).DEVICE_EVIDENCE_UNAVAILABLE; retryable).Availability for each requested base.
Authorization header.
| Field | Type | Required | Description |
|---|---|---|---|
| usernames | string[] | yes | Candidate base usernames to validate and check (max 100). |
{ "usernames": [ "tallesx", "abc" ] }
{ "_tag": "v1", "value": { "tallesx": { "status": "AVAILABLE", "availableDigits": [ 1, 2, 3 ] }, "takenx": { "status": "EXHAUSTED" }, "abc": { "status": "INVALID" } } }
{ "error": "The request body contains invalid values.", "fields": [ { "field": "usernames", "message": "must contain at most 100 items" } ] }
{ "error": "Internal server error. Please try again." }
{_tag: "v1", value} with availableDigits. EXHAUSTED means nothing claimable under this base: no free discriminator, or the bare personhood name is owned or its reservation queue is full — the last two would make a claim carrying dotns.reservedUsername fail on chain and take the lite username with it.usernames, or malformed JSON.Retry-After).Public prefix search over the finalized username projection.
| Header | Type | Required | Description |
|---|---|---|---|
| Authorization | string | no | Optional Bearer <JWT> from device-attestation. When proof of compute is enabled, a valid token satisfies this route and no puzzle is needed. An unverifiable token is treated as anonymous — this route never answers 401. |
| Proof-Of-Compute | string | no | Solved puzzle, required only when proof of compute is enabled and no valid bearer token is presented: base64(sessionId:timestamp:difficulty:counter:checksum) for a puzzle from POST /api/v1/poc/issue. Single-use. |
| Query | Type | Required | Description |
|---|---|---|---|
| prefix | string | yes | Prefix of letters/digits, optionally followed by a dot and digits. |
| limit | number | no | Requested page size, defaulting to 100 and clamped to 1,000. |
| cursor | string | no | Opaque continuation cursor from an earlier response. |
{ "usernames": [], "nextCursor": null }
{ "error": "The request query contains invalid values.", "fields": [ { "field": "prefix", "message": "Prefix is required" } ] }
{ "error": "Proof of compute required. Request a puzzle from POST /api/v1/poc/issue and present the solved proof in the Proof-Of-Compute header." }
{ "error": "Rate limit exceeded. Please retry after 60 seconds." }
fields), invalid cursor ({"error":"Invalid cursor"}), or a malformed Proof-Of-Compute header.error names the reason: missing proof, checksum mismatch, expired puzzle, already-used puzzle, or insufficient difficulty.Retry-After).Issue a proof-of-compute puzzle for an unauthenticated caller.
{ "sessionId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", "timestamp": 1700000000000, "difficulty": 16, "checksum": "c8828951fd6c123fdbf6501f111d27dd3f260839344a7370e0dd8f20e2c40482" }
{ "error": "Not found" }
counter whose sha256(sessionId || timestamp || counter) has at least difficulty leading zero bits, then send Proof-Of-Compute: base64(sessionId:timestamp:difficulty:counter:checksum) on the search request. Callers holding an device-attestation JWT do not need a puzzle.POC_ENABLED=false): the route is not mounted and the service-wide 404 answers instead.Claim an invitation ticket for a DIM, returning a signature (JWT-gated).
Authorization header.
| Field | Type | Required | Description |
|---|---|---|---|
| who | string | yes | SS58 address to claim a ticket for (any valid SS58 prefix). |
| dim | string | yes | DIM to claim a ticket for: Game or ProofOfInk. |
{ "who": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "dim": "Game" }
{ "publicKey": "0xda8ab326da384dd49d5f12543b58acae730af7388b9348c51af6ee3a0962864d", "inviter": "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM", "dim": "Game", "network": "paseo", "claimedBy": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "createdAt": "2026-07-01T10:20:30.400Z", "claimedAt": "2026-07-02T11:00:00.000Z", "signature": "0xa4a506e96aff250724590ef9527d8117ea2e9d633e813d22d32ad17e0b5c253c406cb22baf62283c6987f81b9bb55ea75f3bdff0259d84e5c282728043f77c88", "remaining": 41 }
available ticket of the (dim, network) pool, atomically flipped to claimed, with an sr25519 signature by the ticket key over the claimant's decoded 32-byte account id.fields), or Malformed JSON in request body when the body is not JSON.Authorization header or failed token verification.available ticket in the (dim, network) pool.Retry-After).Issue short-lived TURN credentials for WebRTC ICE negotiation (JWT-gated).
Authorization header.
| Field | Type | Required | Description |
|---|---|---|---|
| regionHint | string | no | Optional region hint (reserved for future use; accepted and ignored). |
{ "regionHint": "eu-west" }
{ "servers": [ "stun:stun.cloudflare.com:3478", "turn:turn.cloudflare.com:3478?transport=udp", "turn:turn.cloudflare.com:3478?transport=tcp", "turns:turn.cloudflare.com:5349?transport=tcp" ], "username": "d2f4a1c6b8e05379", "password": "9f83b1e6c0a74d25b3f8e1a70c4d69b2", "ttl": 1800 }
username and password are an opaque pair from the deployment's configured TURN_PROVIDER — do not parse either — servers is the ICE server list to use them against, and ttl is the seconds of life remaining on the credential.fields), or Malformed JSON in request body when a body is present but not JSON. An empty body is accepted — every field is optional.Authorization header or failed token verification.Retry-After).Retry-After). Only reachable on the Cloudflare provider, which depends on an upstream call; a coturn deployment computes credentials locally and never returns this.Redeem a personhood proof for the same credentials as /turn/issue.
| Field | Type | Required | Description |
|---|---|---|---|
| productId | string | yes | The product the proof was made for. Its context is the one the proof is verified under, so a product proves under its own identifier. Must be one this deployment accepts. |
| collection | string | yes | Hex-encoded 32-byte collection id from the proof's TrUAPI
ringLocation. Only the canonical People Lite and People collections
are accepted. |
| proof | string | yes | Ring-VRF proof over the derived message (hex), exactly as the host returns it — raw signature bytes, with no SCALE length prefix. |
| ringIndex | number | yes | Ring containing the proving member, used to locate the root. |
| ringRevision | number | yes | Revision of that ring the proof was made against, as the host reports it. Only that revision's root is tried, so a revision this deployment no longer holds is refused without verifying anything. |
| timestamp | number | yes | Client Unix seconds, bound into the proved message. Must be within the server's accepted skew. |
{ "productId": "vox.dot", "collection": "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465", "proof": null, "ringIndex": 0, "ringRevision": 3, "timestamp": 1784757652 }
{ "servers": [ "stun:stun.cloudflare.com:3478", "turn:turn.cloudflare.com:3478?transport=udp", "turn:turn.cloudflare.com:3478?transport=tcp", "turns:turn.cloudflare.com:5349?transport=tcp" ], "username": "d2f4a1c6b8e05379", "password": "9f83b1e6c0a74d25b3f8e1a70c4d69b2", "ttl": 1800 }
TURN_PROVIDER, and ttl, the seconds of life remaining on them. No alias appears in the response, and nothing recoverable from the proof is derivable from the credential.(ringIndex, ringRevision) is outside the roots this deployment still holds (deliberately unspecific).Retry-After).Retry-After.Order mirrors the sibling services: verified JWT (401 problem details) →
body parse (400 plain text on malformed JSON) → body validation (400
problem details with per-field errors) → per-subject rate limit (429
with Retry-After). Beyond that the relay always answers 200: on provider
failure it preserves the legacy success: false body instead of an error
status.
Authorization header.
| Field | Type | Required | Description |
|---|---|---|---|
| deviceToken | string | yes | Recipient device token (APNs hex or FCM token); platform is auto-detected. |
| pushId | string | yes | Opaque push id echoed to the client app (32 or 64 hex chars). |
| message | string | yes | Hex-encoded, already-encrypted message body (optional 0x prefix). |
| platform | string | no | Explicit platform override; auto-detected from the token when omitted. |
| bundlerId | string | no | APNs topic override (the app bundle id); VoIP derives <topic>.voip. |
| voip | boolean | no | Enable the iOS VoIP push type for a call. |
{ "deviceToken": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "pushId": "5d41402abc4b2a76b9719d911017c592", "message": "0x1234567890abcdef", "platform": "ios", "bundlerId": "io.parity.brevity", "voip": false }
{ "success": true, "platform": "ios", "sent": 1, "failed": 0, "messageId": null, "errors": null }
200 with success: false on provider failure, so a non-200 status never signals a delivery failure.errors); or plain-text Malformed JSON in request body when the body is not JSON.Authorization header or failed token verification (RFC 9457 problem details).Retry-After).All non-2xx responses share a single envelope. error is a stable machine code; message is a human-readable detail that may vary.
{ "error": "WRONG_DATA", "message": "human-readable detail" }
candidateAccountId does not match JWT subject.Bare-bones curl sequence that exercises the public surface of the service. Safe to run from any shell.
BASE="https://identity.dotspark.app" curl -fsS "$BASE/healthcheck" curl -fsS "$BASE/readyz" curl -fsS -X POST "$BASE/api/v1/auth/challenges" curl -fsS "$BASE/api/v1/attester" curl -fsS "$BASE/.well-known/jwks.json"
sr25519 proof headers. The bundled repo example can drive the flow against a base URL.