Identity Backend V2 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": "identity-service" }
Kubernetes liveness probe — "should this pod be restarted".
{ "status": "alive", "service": "identity-service" }
Kubernetes readiness probe — checks db and chain subsystems.
{ "status": "ready", "service": "identity-service", "db": "up", "chain": "up" }
{ "status": "unavailable", "service": "identity-service", "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); 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. |
| 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 | Optional reserved-name override. |
{ "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", "dotns": { "signature": "0x...64 bytes...", "signedAt": 1780000000, "reservedUsername": "reservedname" } }
{ "registrationOutcome": "PAYMENT_REQUIRED", "paymentAddress": "5F...", "amountRequired": "10000000000" }
{ "base_username": "tallesx", "digits": "07", "username": "tallesx.07" }
{ "type": "https://problems-registry.smartbear.com/invalid-body-property-value", "title": "Invalid Body Property Value", "detail": "The request body contains an invalid body property value.", "status": 400, "errors": [ { "detail": "Invalid signature.", "pointer": "#/candidateSignature" } ] }
{ "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_LANE_ENABLED the body carries the deposit instructions (paymentAddress, amountRequired in planck as a string) and the claim is stored; registration proceeds automatically on the confirmed deposit (poll GET /api/v1/usernames/payment-status). Lane off: the bare outcome, no payment path.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".errors), malformed JSON, or a lifetimePoUDVoucher that is unknown, already used, or expired ({"error": "Voucher already used"} — a voucher failure rejects the claim outright).PAYMENT_LANE_ENABLED a missing token resolves to the 200 PAYMENT_REQUIRED outcome instead).Retry-After).Availability for each requested base.
Authorization header.
| Query | Type | Required | Description |
|---|---|---|---|
| version | string | no | Response version; v1 is the only (and default) option. The legacy flat v0 record was removed 2026-07-23. |
| 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 ] }, "abc": { "status": "INVALID" } } }
{ "type": "https://problems-registry.smartbear.com/invalid-body-property-value", "title": "Invalid Body Property Value", "detail": "The request body contains an invalid body property value.", "status": 400, "errors": [ { "detail": "Too big: expected array to have <=100 items", "pointer": "#/usernames" } ] }
{ "error": "Oops! Something went wrong." }
{_tag: "v1", value} with availableDigits.version (query dialect), invalid usernames (body dialect), or malformed JSON.Retry-After).Whether the deposit for the caller's PAYMENT_REQUIRED claim has been detected on-chain. Poll after receiving registrationOutcome: "PAYMENT_REQUIRED". This route exists only on deployments with the payment lane enabled (PAYMENT_LANE_ENABLED); disabled, the path serves the standard plain-text 404. An expired quote answers 404 — re-claim for fresh deposit instructions.
Authorization header.
{ "status": "PENDING" }
{ "error": "No active payment request" }
PENDING: not yet detected, keep polling. CONFIRMED: deposit detected, username registration is in progress (~15s).Retry-After).Public prefix search over the finalized username projection.
| Header | Type | Required | Description |
|---|---|---|---|
| Authorization | string | no | Optional Bearer <JWT> from identity-service. 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. |
| includeOnchainData | boolean | no | Compatibility flag; indexed rows have no truthful event position. |
{ "usernames": [], "nextCursor": null }
{ "type": "https://problems-registry.smartbear.com/invalid-request-parameter-value", "title": "Invalid Query Parameter Value", "detail": "The request query contains an invalid parameter value.", "status": 400, "errors": [ { "detail": "Prefix is required", "pointer": "#/prefix" } ] }
{ "type": "https://problems-registry.smartbear.com/payment-required", "title": "Payment Required", "detail": "Proof of compute required. Request a puzzle from POST /api/v1/poc/issue and present the solved proof in the Proof-Of-Compute header.", "status": 402 }
{ "type": "https://problems-registry.smartbear.com/too-many-requests", "title": "Too Many Requests", "detail": "Rate limit exceeded. Please retry after 60 seconds.", "status": 429 }
errors), invalid cursor ({"error":"Invalid cursor"}), or a malformed Proof-Of-Compute header (bad-request problem details).detail 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" }
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 identity-service JWT do not need a puzzle.POC_ENABLED=false): the route is not mounted and the service-wide plain-text 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.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).available ticket in the (dim, network) pool.Retry-After).Request a DIM ticket for an address (JWT-gated).
Authorization header.
| Field | Type | Required | Description |
|---|---|---|---|
| who | string | yes | SS58 address to request a ticket for. Only non-emptiness is validated here; SS58 validity is checked later (an invalid address is a 500 — legacy quirk). |
| dim | string | yes | DIM to request a ticket for: Game or ProofOfInk. |
{ "who": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "dim": "Game" }
{ "ticket": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "who": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "inviter": "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM", "network": "paseo", "dim": "Game", "status": "PENDING", "registered": false, "createdAt": "2026-07-01T10:20:30.400Z", "updatedAt": "2026-07-01T10:20:30.400Z" }
PENDING; the single-instance chain writer registers it on People Chain asynchronously (poll the status route).errors); or plain-text Malformed JSON in request body when the body is not JSON. Note SS58 validity is NOT checked here — a well-formed but invalid address is a 500 (legacy quirk).Authorization header or failed token verification (RFC 9457 problem details).Retry-After).who, which legacy validated in the shell, not the schema.Look up a DIM ticket's registration status (JWT-gated).
Authorization header.
| Path | Type | Required | Description |
|---|---|---|---|
| who | string | yes | SS58 address the ticket was requested for (not validated — an arbitrary string is looked up verbatim, exactly like legacy). |
{ "ticket": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "inviter": "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM", "network": "paseo", "dim": "Game", "status": "REGISTERED", "registered": true, "onchainData": { "blockIndex": "12345-7", "blockNumber": 12345, "blockHash": "0xabababababababababababababababababababababababababababababababab", "eventIndex": 7 }, "createdAt": "2026-07-01T10:20:30.400Z", "updatedAt": "2026-07-02T11:00:00.000Z" }
SUBMITTING reports as SUBMITTED on the wire; registered is the deprecated boolean mirror of status == "REGISTERED"; onchainData is null until registration.Authorization header or failed token verification (RFC 9457 problem details).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.example.com:3478", "turn:turn.example.com:3478?transport=udp" ], "username": "1784757652:0a79e3412921701a", "password": "qmg5g7d1bXzY0qZkRUqtIPEIKjA=", "ttl": 1800 }
username is {unixExpiry}:{hexId} (expiry = now + TTL), password is the base64 HMAC over username under the secret shared with the TURN relay (the coturn REST-API construction), servers echoes the configured ICE server list.errors); or plain-text Malformed JSON in request body when the body is present but not JSON. A request with no JSON content-type skips validation entirely and succeeds (legacy quirk).Authorization header or failed token verification (RFC 9457 problem details).Retry-After).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.example.com:3478", "turn:turn.example.com:3478?transport=udp" ], "username": "1784757652:0a79e3412921701a", "password": "qmg5g7d1bXzY0qZkRUqtIPEIKjA=", "ttl": 1800 }
ttl seconds after issuance; no alias appears in the response.(ringIndex, ringRevision) is outside the roots this deployment still holds (RFC 9457 problem details; 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.