Mobile and device identity API
Verify mobile attestation and integrity, deliver app configuration, and manage passkeys, handoff, delegation, recovery, wallet state and offline capture.
These operations are called by your backend (typically the backend-for-frontend a mobile app authenticates to), never by the mobile app or the browser directly. Authenticate with an sk_ server API key: attestation, integrity and app configuration need read:device, passkeys need write:passkey, delegation, recovery, wallet and offline capture need write:identity, and handoff needs write:handoff. Most families are not enabled by default and answer the V1 endpoint not found 404 until they are enabled for your organisation. Base URL: https://collect.noxtica.com.
| Environment | Base URL |
|---|---|
| Production | https://collect.noxtica.com |
Credential: ApiKeyAuth. See Authentication.
- Android Play Integrity attestation (mint / verify)
- iOS App Attest attestation (mint / verify / assert)
- Verify a CPU/GPU attestation proof
- Mint a CPU/GPU attestation challenge
- Combined web/webview integrity verdict
- Forced-update and kill-switch decision for an app launch
- Mint a WebAuthn ceremony challenge
- Verify a WebAuthn attestation and register a passkey
- Verify a WebAuthn assertion
- Mint a browser-to-app handoff claim
- Consume a browser-to-app handoff claim
- Create a subject-scoped device delegation
- List a subject’s active device delegations
- Revoke a device delegation
- Open a recovery case (currently always refused)
- Read a recovery case’s status
- Open an appeal (currently always refused)
- Read an appeal’s status
- Read a subject’s device status and verification level
- Suspend a subject’s device-bound wallet state
- Mint a batch of offline-capture nonces
- Admit a deferred-upload offline capture bundle
Device Attestation
4 operations
- POST /v1/attest/android Android Play Integrity attestation (mint / verify)
- POST /v1/attest/ios iOS App Attest attestation (mint / verify / assert)
- POST /v1/attest/verify Verify a CPU/GPU attestation proof
- POST /v1/attest/challenge Mint a CPU/GPU attestation challenge
Web/App Integrity
1 operation
- POST /v1/integrity/verify Combined web/webview integrity verdict
Mobile App Config
1 operation
- POST /v1/app-config Forced-update and kill-switch decision for an app launch
Passkey
3 operations
- POST /v1/passkey/challenge Mint a WebAuthn ceremony challenge
- POST /v1/passkey/register Verify a WebAuthn attestation and register a passkey
- POST /v1/passkey/assert Verify a WebAuthn assertion
Device Handoff
2 operations
- POST /v1/handoff/mint Mint a browser-to-app handoff claim
- POST /v1/handoff/consume Consume a browser-to-app handoff claim
Device Delegation
3 operations
- POST /v1/device-delegation/create Create a subject-scoped device delegation
- POST /v1/device-delegation/list List a subject's active device delegations
- POST /v1/device-delegation/revoke Revoke a device delegation
Account Recovery
4 operations
- POST /v1/recovery/cases Open a recovery case (currently always refused)
- GET /v1/recovery/cases/{caseId} Read a recovery case's status
- POST /v1/recovery/appeals Open an appeal (currently always refused)
- GET /v1/recovery/appeals/{appealId} Read an appeal's status
Wallet Status
2 operations
- POST /v1/wallet/status Read a subject's device status and verification level
- POST /v1/wallet/suspend Suspend a subject's device-bound wallet state
Offline Capture
2 operations
- POST /v1/offline/nonce-pool Mint a batch of offline-capture nonces
- POST /v1/offline/capture-bundle Admit a deferred-upload offline capture bundle
Android Play Integrity attestation (mint / verify)
POST /v1/attest/android
Server-to-server relay for Android Play Integrity attestation. Your backend — typically the backend-for-frontend a mobile app authenticates to — calls this route with an sk_ key; the mobile app never calls it directly. Two actions are supported:
{"action":"mint"}returns a single-use nonce, valid for 300 seconds and bound to your organisation and domain, that the app binds a Play Integrity standard token to.{"action":"verify","nonce":"...","token":"..."}(the older field nameintegrityTokenis also accepted) claims the nonce exactly once, verifies the token with Google’s Play Integrity service, records the result and returns the verdict. Arisk.flaggedwebhook may be sent to subscribed endpoints.
{"action":"assert"} is iOS-only and is refused on this route with the GatedNotFound 404. When verification with Google cannot complete, the verifier fails closed to verificationLevel: "unverified": the result is stored but no verification credit is implied, and an unverified result is never silently upgraded.
Authentication: Bearer token (ApiKeyAuth), scope read:device.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
action | string | No | Allowed values: mint, verify. Default verify. |
domain_id | string | No | OPTIONAL tenant-owned domain the nonce/claim is bound to. |
nonce | string | No | REQUIRED for verify — the nonce returned by a prior mint. |
token | string | No | REQUIRED for verify — the Play Integrity STANDARD token (also accepted under the legacy key integrityToken). |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The mint or verify outcome. A mint returns {ok,nonce,ttlSeconds,platform}; a verify returns {ok,verdict,verificationLevel,platform,attestationId?} (attestationId present only once a PostgreSQL authority row is recorded; verdict:'error' signals a recording fault, not a failed proof). IMPORTANT: ok:true is only the envelope-level “request handled” flag for BOTH mint and verify — it is present, and true, even when a verify call correctly REJECTS a forged/invalid token (fail-closed discipline; this route never returns a 4xx for a bad token). It is never a statement about the attestation’s outcome. To determine pass/fail, check verdict (only ever 'pass' when verificationLevel is 'verified') and verificationLevel, never ok. | application/json | |
400 | Missing nonce / token, Invalid Content-Length header, or Invalid JSON body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Content-Type must be application/json. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
503 | Could not mint challenge — the tenant edge authority / single-use token store is unavailable. | invalid_request | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Envelope-only “request handled” flag — always true on this 200 path for BOTH mint and a correctly-refused verify. NOT a verdict; check verdict/verificationLevel for the actual attestation outcome. Allowed values: true. |
platform | string | Yes | Allowed values: android. |
nonce | string | No | |
ttlSeconds | integer | No | |
verdict | string | No | Allowed values: pass, fail, error, indeterminate. |
verificationLevel | string | No | e.g. verified / unverified. |
attestationId | string (uuid) | No |
Response example (200)
{
"ok": true,
"platform": "android",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"ttlSeconds": 3600,
"verdict": "pass",
"verificationLevel": "verified",
"attestationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/attest/android" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "mint",
"domain_id": "dom_3f2a8c91d6e5f470",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"token": "tok_9f2a1c3e5d7b4a6f8091c2d3e4f5a6b7"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/attest/android', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'mint',
domain_id: 'dom_3f2a8c91d6e5f470',
nonce: 'nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8',
token: 'tok_9f2a1c3e5d7b4a6f8091c2d3e4f5a6b7',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/attest/android",
data=json.dumps({
"action": "mint",
"domain_id": "dom_3f2a8c91d6e5f470",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"token": "tok_9f2a1c3e5d7b4a6f8091c2d3e4f5a6b7",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
iOS App Attest attestation (mint / verify / assert)
POST /v1/attest/ios
Server-to-server relay for iOS App Attest, with the same posture as POST /v1/attest/android: your backend calls it with an sk_ key and the app never does. Three actions are supported:
{"action":"mint"}returns a single-use nonce, valid for 300 seconds, that the app generates an App Attest key and attestation over.{"action":"verify","nonce":"...","token":"..."}(the older field nameattestationObjectis also accepted) parses the attestation object, checks the nonce binding, records the result, may send arisk.flaggedwebhook and returns the verdict. Verification currently covers structure,rpIdHashand nonce binding (verificationLevel: "structure_only"); promotion toverifiedadditionally requires certificate-chain validation against pinned Apple intermediate key material, which is available only when it is configured for the deployment.{"action":"assert","nonce":"...","assertion":"...","keyId":"..."}is a destructive account-deletion possession proof, available only when it is enabled for your organisation (otherwise theGatedNotFound404). It verifies an App Attest assertion against the stored enrollment binding forkeyId, enforces a monotonic sign counter (replay defence) and on success returns the boundsubjectIdand the fixedactionScope. The nonce is consumed even when the assertion fails.
Authentication: Bearer token (ApiKeyAuth), scope read:device.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
action | string | No | Allowed values: mint, verify, assert. Default verify. |
domain_id | string | No | |
nonce | string | No | REQUIRED for verify/assert — the nonce returned by a prior mint. |
token | string | No | REQUIRED for verify — the base64 CBOR App Attest attestation object (also accepted under the legacy key attestationObject). |
assertion | string | No | REQUIRED for assert — the base64url App Attest assertion CBOR. |
keyId | string | No | REQUIRED for assert — the App Attest key id from the prior enrollment (verify) response. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The mint / verify / assert outcome. A successful assert additionally carries subjectId (server-resolved from the stored binding — never request-derived). IMPORTANT: ok:true is only the envelope-level “request handled” flag for mint/verify/assert alike — it is present, and true, even when a verify call correctly REJECTS a forged/invalid attestation object (fail-closed discipline; this route never returns a 4xx for a bad token). It is never a statement about the attestation’s outcome. To determine pass/fail, check verdict (only ever 'pass' when verificationLevel is 'verified') and verificationLevel, never ok. | application/json | |
400 | Missing nonce / token, Missing nonce / assertion / keyId, Invalid Content-Length header, or Invalid JSON body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Content-Type must be application/json. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
503 | Could not mint challenge. | invalid_request | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Envelope-only “request handled” flag — always true on this 200 path for mint/verify/assert alike, including a correctly-refused verify. NOT a verdict; check verdict/verificationLevel for the actual attestation outcome. Allowed values: true. |
platform | string | Yes | Allowed values: ios. |
nonce | string | No | |
ttlSeconds | integer | No | |
verdict | string | No | Allowed values: pass, fail, error, indeterminate. |
verificationLevel | string | No | |
attestationId | string (uuid) | No |
Response example (200)
{
"ok": true,
"platform": "ios",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"ttlSeconds": 3600,
"verdict": "pass",
"verificationLevel": "verified",
"attestationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/attest/ios" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "mint",
"domain_id": "dom_3f2a8c91d6e5f470",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"token": "tok_9f2a1c3e5d7b4a6f8091c2d3e4f5a6b7",
"assertion": "example-assertion",
"keyId": "example-keyId"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/attest/ios', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'mint',
domain_id: 'dom_3f2a8c91d6e5f470',
nonce: 'nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8',
token: 'tok_9f2a1c3e5d7b4a6f8091c2d3e4f5a6b7',
assertion: 'example-assertion',
keyId: 'example-keyId',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/attest/ios",
data=json.dumps({
"action": "mint",
"domain_id": "dom_3f2a8c91d6e5f470",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"token": "tok_9f2a1c3e5d7b4a6f8091c2d3e4f5a6b7",
"assertion": "example-assertion",
"keyId": "example-keyId",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Verify a CPU/GPU attestation proof
POST /v1/attest/verify
Returns a synchronous verdict for a one-off CPU/GPU proof-of-work attestation, driven by your backend on behalf of its web client outside the normal /client/submit flow. {"action":"verify","challengeId":"...","proof":{...}} redeems a challenge minted by POST /v1/attest/challenge.
Not enabled by default: when the capability is not enabled for your organisation this route does not answer 404: it fails open to a neutral HTTP 200. When the capability is not enabled, or on any internal fault, the response is {"ok":true,"featureEnabled":false,"tier":"unavailable","confidence":50,"anchored":false}. “No signal” is always a pass-through, never an outage.
Absence is not a penalty: a proof that reports no CPU/GPU capability scores neutral. Only a proof that is present but rejected (after a successful single-use nonce claim) is a failed signal, and every rejected proof collapses to the same public reason verification_rejected. The literal tier hardware is published only when publication of the hardware attestation level is enabled for your organisation; otherwise hardware and software are both reported as attested.
Authentication: Bearer token (ApiKeyAuth), scope read:device.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
action | string | No | This path always defaults/forces verify; use POST /v1/attest/challenge to mint. Allowed values: verify. Default verify. |
domain_id | string | No | |
kind | string | No | Allowed values: gpu, cpu. Default gpu. |
challengeId | string | No | REQUIRED — the challengeId returned by a prior POST /v1/attest/challenge. |
proof | object | No | The CPU/GPU attestation proof payload narrowed by narrowProof against the obsidian VM’s engine-internal proof shape. Left open (additionalProperties: true) — the exact engine field set (adapterInfo, ULP samples, raw multichannel evidence, etc.) is internal wire format with no independent public contract and is not safely documented from the handler alone. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | Either the NEUTRAL feature-off/fail-open band, or a scored verdict with the M3-masked published tier. | application/json | |
400 | Invalid Content-Length header or Invalid JSON body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Content-Type must be application/json. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
featureEnabled | boolean | Yes | |
tier | string | Yes | Allowed values: hardware, software, attested, suspicious, failed, unavailable. |
confidence | integer | Yes | Minimum 0, maximum 100. |
anchored | boolean | Yes | |
reasons | array of string | Yes | |
verifiedAt | string (date-time) | Yes |
Response example (200)
{
"ok": true,
"featureEnabled": true,
"tier": "hardware",
"confidence": 0,
"anchored": true,
"reasons": [
"example-reasons"
],
"verifiedAt": "2026-09-13T12:00:00.000Z"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/attest/verify" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "verify",
"domain_id": "dom_3f2a8c91d6e5f470",
"kind": "gpu",
"challengeId": "chal_3c9b2f7e4a3d4c8e",
"proof": {}
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/attest/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'verify',
domain_id: 'dom_3f2a8c91d6e5f470',
kind: 'gpu',
challengeId: 'chal_3c9b2f7e4a3d4c8e',
proof: {},
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/attest/verify",
data=json.dumps({
"action": "verify",
"domain_id": "dom_3f2a8c91d6e5f470",
"kind": "gpu",
"challengeId": "chal_3c9b2f7e4a3d4c8e",
"proof": {},
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Mint a CPU/GPU attestation challenge
POST /v1/attest/challenge
Mints a single-use, organisation- and domain-bound, origin-anchored challenge and returns the directive your client feeds to the attestation runtime. The URL path forces action: "challenge" whatever the body says. Redeem the returned challengeId at POST /v1/attest/verify.
The availability and fail-open posture are the same as for POST /v1/attest/verify: a missing Origin header, an authority fault or a missing integrity pin all collapse to {"ok":true,"featureEnabled":true,"challenge":null,"reason":"challenge_unavailable"} with HTTP 200, never a 5xx.
Authentication: Bearer token (ApiKeyAuth), scope read:device.
Request body
Content type application/json, optional.
| Field | Type | Required | Description |
|---|---|---|---|
domain_id | string | No | |
kind | string | No | Allowed values: gpu, cpu. Default gpu. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | A minted challenge directive, or the neutral challenge_unavailable refusal. | application/json | |
400 | Invalid Content-Length header or Invalid JSON body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Content-Type must be application/json. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
featureEnabled | boolean | Yes | |
reason | string | No | Allowed values: challenge_unavailable, attest_verify_disabled, attest_verify_tenant_disabled. |
challenge | object, nullable | No | |
challenge.challengeId | string | No | |
challenge.nonce | string | No | |
challenge.loaderUrl | string (uri) | No | |
challenge.wasmUrl | string (uri) | No | |
challenge.wasmSha256 | string | No | |
challenge.seed | string | No | |
challenge.level | integer | No | |
challenge.rawEvidenceCommitmentVersion | integer | No | |
challenge.expiresAt | integer | No | Epoch milliseconds. |
verifiedAt | string (date-time) | Yes |
Response example (200)
{
"ok": true,
"featureEnabled": true,
"reason": "challenge_unavailable",
"challenge": {
"challengeId": "chal_3c9b2f7e4a3d4c8e",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"loaderUrl": "https://example.com",
"wasmUrl": "https://example.com",
"wasmSha256": "example-wasmSha256",
"seed": "seed-01",
"level": 1,
"rawEvidenceCommitmentVersion": 1,
"expiresAt": 1768435200
},
"verifiedAt": "2026-09-13T12:00:00.000Z"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/attest/challenge" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain_id": "dom_3f2a8c91d6e5f470",
"kind": "gpu"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/attest/challenge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain_id: 'dom_3f2a8c91d6e5f470',
kind: 'gpu',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/attest/challenge",
data=json.dumps({
"domain_id": "dom_3f2a8c91d6e5f470",
"kind": "gpu",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Combined web/webview integrity verdict
POST /v1/integrity/verify
Combines three existing signals into one integrity verdict for a web or webview session; it adds no new scoring path: (1) the CPU/GPU attestation verdict, from an optional challengeId / proof pair where absence is neutral, (2) the calibrated risk score over a caller-supplied signals payload, and (3) the virtualization-corroboration marker read from the score’s flags.
Not enabled by default. When the capability is not enabled for your organisation, or on any fault, the route fails open to a neutral HTTP 200 {"ok":true,"integrity":"unverified","signals":{"attestation":"unavailable","virtualization":"unavailable","score":"minimal"},"confidence":50}; it never answers 5xx or 404. The attestation label is masked exactly as on POST /v1/attest/verify.
Authentication: Bearer token (ApiKeyAuth), scope read:device.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
domain_id | string | No | |
kind | string | No | Allowed values: gpu, cpu. Default gpu. |
challengeId | string | No | OPTIONAL — a prior /v1/attest/challenge id; absence skips the attestation leg (neutral). |
proof | object | No | OPTIONAL CPU/GPU attestation proof, same internal wire shape as /v1/attest/verify’s proof — left open for the same reason. |
signals | object | No | The caller-supplied calibrated-score signals payload (same lenient envelope as /v1/score — accepts {signals:{...}} or a bare object). |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The combined integrity verdict, or the neutral feature-off/fail-open band. | application/json | |
400 | Invalid Content-Length header, Invalid JSON body, or Invalid body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Content-Type must be application/json. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
featureEnabled | boolean | Yes | |
integrity | string | Yes | Allowed values: trusted, suspicious, unverified. |
signals | object | Yes | |
signals.attestation | string | No | Allowed values: hardware, software, attested, suspicious, failed, unavailable. |
signals.virtualization | string | No | Allowed values: corroborated, unavailable. |
signals.score | string | No | The calibrated engine’s riskLevel band. |
confidence | integer | Yes | Minimum 0, maximum 100. |
reason | string | No | Present only on the neutral/feature-off band (e.g. noxtrust_disabled, noxtrust_tenant_disabled, tenant_db_unavailable, score_fault_neutral, internal_neutral). |
verifiedAt | string (date-time) | Yes |
Response example (200)
{
"ok": true,
"featureEnabled": true,
"integrity": "trusted",
"signals": {
"attestation": "hardware",
"virtualization": "corroborated",
"score": "example-score"
},
"confidence": 0,
"reason": "user_requested",
"verifiedAt": "2026-09-13T12:00:00.000Z"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/integrity/verify" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain_id": "dom_3f2a8c91d6e5f470",
"kind": "gpu",
"challengeId": "chal_3c9b2f7e4a3d4c8e",
"proof": {},
"signals": {}
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/integrity/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain_id: 'dom_3f2a8c91d6e5f470',
kind: 'gpu',
challengeId: 'chal_3c9b2f7e4a3d4c8e',
proof: {},
signals: {},
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/integrity/verify",
data=json.dumps({
"domain_id": "dom_3f2a8c91d6e5f470",
"kind": "gpu",
"challengeId": "chal_3c9b2f7e4a3d4c8e",
"proof": {},
"signals": {},
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Forced-update and kill-switch decision for an app launch
POST /v1/app-config
Server-to-server call from your backend (for example the backend-for-frontend a mobile app authenticates to) that decides one launch outcome for a mobile app. Send platform, appVersion and, on Android, the client-asserted Play Integrity appRecognitionVerdict. The first rule that applies wins:
reason: "killed"— an emergency kill switch is active.reason: "forced_update"—appVersionis below the minimum version configured for the platform. This is a forced-update nudge, not an anti-tamper control: a patched app can report any version.reason: "app_not_recognized"— Android only, whenappRecognitionVerdictis notPLAY_RECOGNIZED; a missing verdict counts as not recognised. This is advisory: the verdict is client-asserted and is not verified with Google, unlike/v1/attest/android. iOS is exempt.
Otherwise reason: "ok". Every decision is audited with coarse, non-personal facts. The request body is limited to 1024 bytes.
Authentication: Bearer token (ApiKeyAuth), scope read:device.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
platform | string | Yes | Allowed values: android, ios. |
appVersion | string | Yes | |
appRecognitionVerdict | string | No | Client-asserted Play Integrity app-recognition verdict (e.g. PLAY_RECOGNIZED); Android only, ADVISORY, never independently verified. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The resolved forced-update / kill-switch decision. | application/json | |
400 | Invalid body, Invalid or missing platform, Invalid or missing appVersion, or Invalid appRecognitionVerdict. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
413 | Payload too large — over the 1024-byte cap. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
allowed | boolean | Yes | |
reason | string | Yes | Allowed values: ok, killed, forced_update, app_not_recognized. |
minVersion | string, nullable | Yes | The operator-configured floor for this platform, or null when unset. |
Response example (200)
{
"ok": true,
"allowed": true,
"reason": "ok",
"minVersion": "example-minVersion"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/app-config" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "android",
"appVersion": "example-appVersion",
"appRecognitionVerdict": "example-appRecognitionVerdict"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/app-config', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
platform: 'android',
appVersion: 'example-appVersion',
appRecognitionVerdict: 'example-appRecognitionVerdict',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/app-config",
data=json.dumps({
"platform": "android",
"appVersion": "example-appVersion",
"appRecognitionVerdict": "example-appRecognitionVerdict",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Mint a WebAuthn ceremony challenge
POST /v1/passkey/challenge
Server-to-server (sk_ key, scope write:passkey). Mints a single-use, time-limited WebAuthn ceremony challenge. You supply an opaque subject_id, the exact domain_id of one of your active domains and the ceremony purpose (register or assert). The subject’s existence is deliberately not checked at mint time. The domain must resolve to an active route of your organisation, and its origin is bound into the challenge, which prevents a later cross-domain ceremony.
Not enabled by default: when the passkey capability is not enabled for your organisation, every passkey endpoint answers the GatedNotFound 404.
Authentication: Bearer token (ApiKeyAuth), scope write:passkey.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
subject_id | string | Yes | Opaque caller-generated subject correlation id. |
domain_id | string | Yes | The tenant’s exact registered domain id this ceremony is bound to. |
purpose | string | Yes | Allowed values: register, assert. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | A minted challenge plus the WebAuthn RP/ceremony parameters, or a closed ok:false refusal. | application/json | |
400 | {"ok":false,"reason":"invalid_request"} — malformed body, missing fields, or an unresolvable domain_id. | application/json | |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
Variant variant 1
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
challenge | string | Yes | |
purpose | string | Yes | Allowed values: register, assert. |
ttlSeconds | integer | Yes | |
rp | object | Yes | |
rp.id | string | No | The WebAuthn rpId derived from the tenant’s registered origin. |
rp.origins | array of string (uri) | No | |
pubKeyCredParams | array of object | Yes | |
pubKeyCredParams[] | object | No | |
pubKeyCredParams[].type | string | No | Allowed values: public-key. |
pubKeyCredParams[].alg | integer | No | COSE algorithm id (ES256 -7 or RS256 -257). |
Variant variant 2
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: false. |
reason | string | Yes | Allowed values: invalid_request, purpose_mismatch, client_data_invalid, challenge_invalid, challenge_mismatch, origin_not_allowed, rp_id_mismatch, user_not_present, user_not_verified, attestation_invalid, attestation_signature_invalid, unsupported_algorithm, assertion_signature_invalid, sign_count_regression, subject_unverified, credential_exists, credential_unknown, storage_error, step_up_unconfigured, step_up_transport_error, step_up_provider_error, step_up_no_match, step_up_reference_required, aaguid_blocked, attestation_required, attestation_untrusted, aaguid_status_rejected, step_up_reference_replayed, step_up_claim_unavailable. |
Response example (200)
{
"ok": true,
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
"purpose": "register",
"ttlSeconds": 3600,
"rp": {
"id": "example-id",
"origins": [
"https://example.com"
]
},
"pubKeyCredParams": [
{
"type": "public-key",
"alg": 1
}
]
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/passkey/challenge" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject_id": "subj_4821",
"domain_id": "dom_3f2a8c91d6e5f470",
"purpose": "register"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/passkey/challenge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subject_id: 'subj_4821',
domain_id: 'dom_3f2a8c91d6e5f470',
purpose: 'register',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/passkey/challenge",
data=json.dumps({
"subject_id": "subj_4821",
"domain_id": "dom_3f2a8c91d6e5f470",
"purpose": "register",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Verify a WebAuthn attestation and register a passkey
POST /v1/passkey/register
Server-to-server (sk_ key, scope write:passkey). Verifies a WebAuthn attestation and registers the credential. In order: the register challenge is consumed (single use); the bound subject must have an active, verified device or KYC proof; the attestation is verified against only the exact active domain bound into the challenge; an optional authenticator (AAGUID) trust policy may reject the credential before storage when it runs in enforce mode; the credential is stored against the opaque subject_id, with no personal data.
Every ceremony or policy refusal returns the same closed reason-code catalogue at a uniform HTTP 200 {"ok":false}, except invalid_request and purpose_mismatch, which answer 400, so a status code never reveals whether a credential exists. Availability is the same as for POST /v1/passkey/challenge.
Authentication: Bearer token (ApiKeyAuth), scope write:passkey.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
challenge | string | Yes | base64url — the challenge minted by POST /v1/passkey/challenge with purpose:'register'. |
attestationObject | string | Yes | base64url CBOR WebAuthn attestation object. |
clientDataJSON | string | Yes | base64url WebAuthn clientDataJSON. |
transports | array of string | No | OPTIONAL authenticator transports (e.g. usb, nfc, internal), persisted comma-joined. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The registered credential id + bound subject, or a closed ok:false refusal. | application/json | |
400 | {"ok":false,"reason":"invalid_request"} or {"ok":false,"reason":"purpose_mismatch"} — the only two reasons that map to HTTP 400. | application/json | |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
Variant variant 1
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
credentialId | string | Yes | |
subjectId | string | Yes |
Variant variant 2
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: false. |
reason | string | Yes | Allowed values: invalid_request, purpose_mismatch, client_data_invalid, challenge_invalid, challenge_mismatch, origin_not_allowed, rp_id_mismatch, user_not_present, user_not_verified, attestation_invalid, attestation_signature_invalid, unsupported_algorithm, assertion_signature_invalid, sign_count_regression, subject_unverified, credential_exists, credential_unknown, storage_error, step_up_unconfigured, step_up_transport_error, step_up_provider_error, step_up_no_match, step_up_reference_required, aaguid_blocked, attestation_required, attestation_untrusted, aaguid_status_rejected, step_up_reference_replayed, step_up_claim_unavailable. |
Response example (200)
{
"ok": true,
"credentialId": "example-credentialId",
"subjectId": "subj_4821"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/passkey/register" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
"attestationObject": "example-attestationObject",
"clientDataJSON": "example-clientDataJSON",
"transports": [
"example-transports"
]
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/passkey/register', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
challenge: 'Y2hhbGxlbmdlLW5vbmNlLTAx',
attestationObject: 'example-attestationObject',
clientDataJSON: 'example-clientDataJSON',
transports: [
'example-transports',
],
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/passkey/register",
data=json.dumps({
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
"attestationObject": "example-attestationObject",
"clientDataJSON": "example-clientDataJSON",
"transports": [
"example-transports",
],
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Verify a WebAuthn assertion
POST /v1/passkey/assert
Server-to-server (sk_ key, scope write:passkey). Verifies a WebAuthn assertion against a stored credential. In order: the assert challenge is consumed (single use) and yields the bound subject_id; the active credential is looked up by credential id (an unknown id gives the closed credential_unknown); the credential must belong to the challenge’s subject (otherwise challenge_mismatch, so a challenge minted for subject A can never assert subject B’s credential); the signature is verified over authenticatorData || SHA-256(clientDataJSON) with the stored public key, against only the domain bound into the challenge; the signature counter must strictly advance (otherwise sign_count_regression, a replay defence). On success the counter is advanced and the bound subjectId is returned.
Every failure returns the same uniform HTTP 200 {"ok":false}; credential-level reasons are an integrator diagnostic, not an enumeration oracle, because this surface is intra-organisation and credential ids are high-entropy. Availability is the same as for POST /v1/passkey/challenge.
Authentication: Bearer token (ApiKeyAuth), scope write:passkey.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
challenge | string | Yes | base64url — minted with purpose:'assert'. |
credentialId | string | Yes | |
authenticatorData | string | Yes | base64url WebAuthn authenticatorData. |
clientDataJSON | string | Yes | base64url WebAuthn clientDataJSON. |
signature | string | Yes | base64url WebAuthn assertion signature. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The verified bound subject, or a closed ok:false refusal. | application/json | |
400 | {"ok":false,"reason":"invalid_request"} or {"ok":false,"reason":"purpose_mismatch"}. | application/json | |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
Variant variant 1
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
verified | boolean | Yes | Allowed values: true. |
subjectId | string | Yes |
Variant variant 2
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: false. |
reason | string | Yes | Allowed values: invalid_request, purpose_mismatch, client_data_invalid, challenge_invalid, challenge_mismatch, origin_not_allowed, rp_id_mismatch, user_not_present, user_not_verified, attestation_invalid, attestation_signature_invalid, unsupported_algorithm, assertion_signature_invalid, sign_count_regression, subject_unverified, credential_exists, credential_unknown, storage_error, step_up_unconfigured, step_up_transport_error, step_up_provider_error, step_up_no_match, step_up_reference_required, aaguid_blocked, attestation_required, attestation_untrusted, aaguid_status_rejected, step_up_reference_replayed, step_up_claim_unavailable. |
Response example (200)
{
"ok": true,
"verified": true,
"subjectId": "subj_4821"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/passkey/assert" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
"credentialId": "example-credentialId",
"authenticatorData": "example-authenticatorData",
"clientDataJSON": "example-clientDataJSON",
"signature": "MEUCIQDx1F2a3B4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/passkey/assert', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
challenge: 'Y2hhbGxlbmdlLW5vbmNlLTAx',
credentialId: 'example-credentialId',
authenticatorData: 'example-authenticatorData',
clientDataJSON: 'example-clientDataJSON',
signature: 'MEUCIQDx1F2a3B4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/passkey/assert",
data=json.dumps({
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
"credentialId": "example-credentialId",
"authenticatorData": "example-authenticatorData",
"clientDataJSON": "example-clientDataJSON",
"signature": "MEUCIQDx1F2a3B4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Mint a browser-to-app handoff claim
POST /v1/handoff/mint
Server-to-server (sk_ key, scope write:handoff): the caller is your own backend, never a browser or a mobile device. Mints a session-bound, single-use claim that hands a verified browser session to a native app. It returns two independent 256-bit secrets, stored only as hashes: exchangeCode, the only value meant for the QR code or deep link, and challenge, a separate binding secret that you convey through your own out-of-band channel and never embed in the QR code. Both are required together at POST /v1/handoff/consume. Minting again for the same sessionId atomically supersedes any pending claim for that session, which supports cross-device rescans.
Not enabled by default: when the capability is not enabled for your organisation both handoff endpoints answer the GatedNotFound 404.
Key scope availability. At the time of writing the write:handoff scope is not among the scopes that can be selected when creating a server API key in the Backoffice, so a key created there cannot call the handoff endpoints.
Authentication: Bearer token (ApiKeyAuth), scope write:handoff.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
sessionId | string | Yes | Opaque caller-generated correlation id; no PII. At most 256 characters. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The minted claim secrets and TTL. | application/json | |
400 | Invalid or missing sessionId or Invalid body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | Payload too large — over the 1024-byte cap. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
exchangeCode | string | Yes | base64url(32 random bytes) — the ONLY secret meant for the QR/deep-link. |
challenge | string | Yes | base64url(32 random bytes) — conveyed OUT-OF-BAND, never in the QR. |
sessionId | string | Yes | |
ttlSeconds | integer | Yes | |
expiresAt | integer | Yes | Epoch milliseconds. |
Response example (200)
{
"ok": true,
"exchangeCode": "example-exchangeCode",
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
"sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z",
"ttlSeconds": 3600,
"expiresAt": 1768435200
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/handoff/mint" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/handoff/mint', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: '01J8Z3K7QN6R2S4T6V8W0X2Y4Z',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/handoff/mint",
data=json.dumps({
"sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Consume a browser-to-app handoff claim
POST /v1/handoff/consume
Server-to-server (sk_ key, scope write:handoff): called by your app’s own backend when it resolves the deep link, never by the mobile device. The caller must present all of sessionId, exchangeCode and challenge. An exact match on every bound field, a pending status and an unexpired claim are all required, and the claim is consumed atomically, so an incorrect attempt never burns the real claim.
Every mismatch — a replay, another organisation, another session, a swapped challenge or an expired claim — gives the same closed claim_invalid rejection at a uniform HTTP 200, never a 4xx, because revealing which field failed would expose the claim’s binding. Availability and the key-scope note are the same as for POST /v1/handoff/mint.
Authentication: Bearer token (ApiKeyAuth), scope write:handoff.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
sessionId | string | Yes | At most 256 characters. |
exchangeCode | string | Yes | The exchangeCode scanned from the QR/deep-link. At most 512 characters. |
challenge | string | Yes | The separately-conveyed binding secret. At most 512 characters. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The consume outcome — always HTTP 200 whether accepted or refused. | application/json | |
400 | Invalid or missing sessionId/exchangeCode/challenge or Invalid body. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | Payload too large — over the 2048-byte cap. | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
500 | Unexpected server fault. Retry with backoff and quote requestId to support if it persists. | Internal server error | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
Variant variant 1
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
sessionId | string | Yes |
Variant variant 2
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: false. |
reason | string | Yes | Allowed values: claim_invalid. |
Response example (200)
{
"ok": true,
"sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/handoff/consume" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z",
"exchangeCode": "example-exchangeCode",
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/handoff/consume', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: '01J8Z3K7QN6R2S4T6V8W0X2Y4Z',
exchangeCode: 'example-exchangeCode',
challenge: 'Y2hhbGxlbmdlLW5vbmNlLTAx',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/handoff/consume",
data=json.dumps({
"sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z",
"exchangeCode": "example-exchangeCode",
"challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Create a subject-scoped device delegation
POST /v1/device-delegation/create
Creates a delegation of device authority for a subject. The delegation is born active, subject to a cap on active delegations per subject (device_delegation_limit_exceeded when the cap is reached). The organisation is always the authenticated one: a tenant id in the body is never used and is omitted from the response. Every id field must match ^[a-zA-Z0-9._:-]{1,128}$, except the server-generated delegation id, which is a UUID.
Not enabled by default: when the device-registry capability is not enabled for your organisation the route answers the GatedNotFound 404. The three delegation operations share one rate-limit bucket per API key.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | The subject the delegation concerns (opaque, never raw PII). Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
delegatorId | string | Yes | The principal granting the delegation. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
delegateId | string | Yes | The device/principal receiving the delegation. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
scope | string | No | Defaults to device_use when omitted or not one of the closed values. Allowed values: device_use, device_manage. Default device_use. |
evidenceRef | string, nullable | No | Optional opaque id referencing the evidence/consent record justifying the delegation. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
expiresAt | integer (int64), nullable | No | Optional epoch-ms timestamp. Omitted ⇒ no expiry. When present MUST be finite and strictly within [now + 60000, now + 31536000000] (a 1-minute floor, 1-year ceiling) — rejected, never clamped, outside that window. |
createdBy | string, nullable | No | Optional opaque actor id for the audit trail. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The minted delegation, born active. tenantId is omitted (the caller already knows its own tenant). | application/json | |
400 | device_delegation_invalid_subject_id / _delegator_id / _delegate_id / _evidence_ref / _expiry / _created_by / _request / _limit_exceeded, or a generic invalid_request for a malformed body. | device_delegation_invalid_subject_id, invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Non-JSON Content-Type (invalid_request). | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | device_delegation_storage_unavailable — a D1 write fault. | device_delegation_storage_unavailable, invalid_request | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
delegation | object | Yes | |
delegation.id | string (uuid) | No | |
delegation.subjectId | string | No | |
delegation.delegatorId | string | No | |
delegation.delegateId | string | No | |
delegation.scope | string | No | Allowed values: device_use, device_manage. |
delegation.status | string | No | Allowed values: active. |
delegation.evidenceRef | string, nullable | No | |
delegation.expiresAt | integer (int64), nullable | No | |
delegation.revokedAt | integer (int64), nullable | No | |
delegation.createdBy | string, nullable | No | |
delegation.createdAt | integer (int64) | No | |
delegation.updatedAt | integer (int64) | No | |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"delegation": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subjectId": "subj_4821",
"delegatorId": "dev_7a2b9c4d1e6f3a8b",
"delegateId": "dev_2b9c4d1e6f3a8b7a",
"scope": "device_use",
"status": "active",
"evidenceRef": "obj-front",
"expiresAt": 1768435200,
"revokedAt": 1,
"createdBy": "tenant_42",
"createdAt": 1,
"updatedAt": 1
},
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/device-delegation/create" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subjectId": "subj_4821",
"delegatorId": "dev_7a2b9c4d1e6f3a8b",
"delegateId": "dev_2b9c4d1e6f3a8b7a",
"scope": "device_use",
"evidenceRef": "obj-front",
"expiresAt": 1768435200,
"createdBy": "tenant_42"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/device-delegation/create', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subjectId: 'subj_4821',
delegatorId: 'dev_7a2b9c4d1e6f3a8b',
delegateId: 'dev_2b9c4d1e6f3a8b7a',
scope: 'device_use',
evidenceRef: 'obj-front',
expiresAt: 1768435200,
createdBy: 'tenant_42',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/device-delegation/create",
data=json.dumps({
"subjectId": "subj_4821",
"delegatorId": "dev_7a2b9c4d1e6f3a8b",
"delegateId": "dev_2b9c4d1e6f3a8b7a",
"scope": "device_use",
"evidenceRef": "obj-front",
"expiresAt": 1768435200,
"createdBy": "tenant_42",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
List a subject’s active device delegations
POST /v1/device-delegation/list
Lists the active, non-expired delegations of a subject. A subject with no active delegations, or an unknown subject, returns an empty array, never a distinguishing status (no existence oracle). Availability and rate-limit bucket are the same as for POST /v1/device-delegation/create; a 401 never consumes the bucket.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | The subject whose active delegations to list. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The subject’s active, non-expired delegations (empty array if none / unknown subject). | application/json | |
400 | invalid_request — subjectId missing. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Non-JSON Content-Type (invalid_request). | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | device_delegation_storage_unavailable — a D1 read fault. | device_delegation_storage_unavailable, invalid_request | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
delegations | array of object | Yes | |
delegations[] | object | No | |
delegations[].id | string (uuid) | No | |
delegations[].subjectId | string | No | |
delegations[].delegatorId | string | No | |
delegations[].delegateId | string | No | |
delegations[].scope | string | No | Allowed values: device_use, device_manage. |
delegations[].status | string | No | Allowed values: active. |
delegations[].evidenceRef | string, nullable | No | |
delegations[].expiresAt | integer (int64), nullable | No | |
delegations[].revokedAt | integer (int64), nullable | No | |
delegations[].createdBy | string, nullable | No | |
delegations[].createdAt | integer (int64) | No | |
delegations[].updatedAt | integer (int64) | No | |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"delegations": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"subjectId": "subj_4821",
"delegatorId": "dev_7a2b9c4d1e6f3a8b",
"delegateId": "dev_2b9c4d1e6f3a8b7a",
"scope": "device_use",
"status": "active",
"evidenceRef": "obj-front",
"expiresAt": 1768435200,
"revokedAt": 1,
"createdBy": "tenant_42",
"createdAt": 1,
"updatedAt": 1
}
],
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/device-delegation/list" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subjectId": "subj_4821"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/device-delegation/list', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subjectId: 'subj_4821',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/device-delegation/list",
data=json.dumps({
"subjectId": "subj_4821",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Revoke a device delegation
POST /v1/device-delegation/revoke
Revokes a delegation. Revocation is terminal: a revoked delegation is never honoured again, and restoring access means creating a new delegation. A delegation id that belongs to another organisation answers device_delegation_not_found, byte-identical to an unknown id (no existence oracle). Availability and rate-limit bucket are the same as for POST /v1/device-delegation/create.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | The delegation’s own server-minted id (not the subject/delegator/delegate id). |
revokedBy | string, nullable | No | Optional opaque actor id for the audit trail; omitted ⇒ recorded as a SYSTEM actor. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The delegation was revoked. | application/json | |
400 | invalid_request — id missing. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | Either the capability is not enabled for your organisation (the GatedNotFound body), or the delegation id does not resolve for your organisation (device_delegation_not_found, byte-identical for an unknown id and an id of another organisation). | invalid_request | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Non-JSON Content-Type (invalid_request). | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | device_delegation_storage_unavailable — a D1 write fault. | device_delegation_storage_unavailable, invalid_request | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
revoked | boolean | Yes | |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"revoked": true,
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/device-delegation/revoke" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "00000000-0000-4000-8000-000000000000"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/device-delegation/revoke', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: '00000000-0000-4000-8000-000000000000',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/device-delegation/revoke",
data=json.dumps({
"id": "00000000-0000-4000-8000-000000000000",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Open a recovery case (currently always refused)
POST /v1/recovery/cases
Self-service recovery case opening is not available today. A well-formed request never creates a case: the route validates the shape of installId (a UUID, then discarded — it is never treated as a subject reference) and answers the same closed business rejection with HTTP 200, which is the status the mobile backend contract inspects. A request that carries a subjectId of any type is rejected with 400 invalid_request, so that no key holder can name a foreign subject.
Not enabled by default: when the device-registry capability is not enabled for your organisation the route answers the GatedNotFound 404. The four /v1/recovery/* operations share one rate-limit bucket per API key.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
installId | string (uuid) | Yes | Validated for shape only (UUID v1-5) and discarded — never a subject reference. |
tier | string | Yes | Allowed values: fresh_reverification, trusted_referee, alternate_evidence, device_history_attestation, dual_control_override. |
evidenceRef | string | No | Optional opaque id. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
subjectId | any | No | NOT part of the frozen contract. Any own-property subjectId (of any type, including null) is rejected with 400 invalid_request — never accepted-and-ignored. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | Always the closed business rejection today: no case is created. | application/json | |
400 | Missing/invalid installId or tier, an invalid evidenceRef shape, or any own-property subjectId present. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Non-JSON Content-Type (invalid_request). | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | registry_write_failed — unexpected handler error (never a raw exception). | registry_write_failed | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: false. |
reason | string | Yes | Allowed values: subject_resolution_required. |
requestId | string | Yes |
Response example (200)
{
"ok": false,
"reason": "subject_resolution_required",
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/recovery/cases" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"installId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tier": "fresh_reverification",
"evidenceRef": "obj-front",
"subjectId": "subj_4821"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/recovery/cases', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
installId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
tier: 'fresh_reverification',
evidenceRef: 'obj-front',
subjectId: 'subj_4821',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/recovery/cases",
data=json.dumps({
"installId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tier": "fresh_reverification",
"evidenceRef": "obj-front",
"subjectId": "subj_4821",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Read a recovery case’s status
GET /v1/recovery/cases/{caseId}
Polls the status of a recovery case. The stored case is redacted before it is returned: subjectId, evidenceRef, refereeId, fraudReviewVerdict, createdBy and resolutionReasonCode are never part of the response. A caseId that belongs to another organisation answers the same 404 as an unknown id (no existence oracle). Availability and rate-limit bucket are the same as for POST /v1/recovery/cases.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
caseId | path | string | Yes | Opaque case id, decoded by the router and re-validated inside the handler. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The redacted case projection. | application/json | |
400 | invalid_request (opaque-id shape violation), or Invalid caseId (router-level decodeURIComponent throw on malformed percent-encoding). | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | Either the capability is not enabled for your organisation (the GatedNotFound body), or recovery_case_not_found: the caseId is well formed but no case exists for your organisation (a case of another organisation resolves identically). | V1 endpoint not found | application/json |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | registry_write_failed — unexpected handler error. | registry_write_failed | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
case | object | Yes | |
case.id | string (uuid) | No | |
case.tenantId | string | No | |
case.tier | string | No | Allowed values: fresh_reverification, trusted_referee, alternate_evidence, device_history_attestation, dual_control_override. |
case.status | string | No | Allowed values: opened, pending_referee, pending_dual_control, pending_fraud_review, approved, denied, blocked_external, expired. |
case.resultingVerificationLevel | string, nullable | No | |
case.resultingStatus | string, nullable | No | Allowed values: active, active_limited, proof_restored. |
case.slaDueAt | integer (int64) | No | |
case.resolvedAt | integer (int64), nullable | No | |
case.createdAt | integer (int64) | No | |
case.updatedAt | integer (int64) | No | |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"case": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenantId": "tenant_42",
"tier": "fresh_reverification",
"status": "opened",
"resultingVerificationLevel": "example-resultingVerificationLevel",
"resultingStatus": "active",
"slaDueAt": 1,
"resolvedAt": 1,
"createdAt": 1,
"updatedAt": 1
},
"requestId": "req-test"
}
Example request
curl
curl "https://collect.noxtica.com/v1/recovery/cases/example-caseId" \
-H "Authorization: Bearer $NOXTICA_API_KEY"
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/recovery/cases/example-caseId', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
},
});
console.log(response.status, await response.text());
Python (standard library)
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/recovery/cases/example-caseId",
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
},
method="GET",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Open an appeal (currently always refused)
POST /v1/recovery/appeals
Opening an appeal is not available today. Because no case can be opened, the route refuses unconditionally with the same closed reason as POST /v1/recovery/cases, and it does not read or validate the request body: any body, or none, produces the identical response. Availability and rate-limit bucket are the same as for the other recovery routes.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, optional.
| Field | Type | Required | Description |
|---|---|---|---|
caseId | string | No | Nominal frozen-BFF-contract field. Never read or validated by this handler. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | Always the closed business rejection today: no appeal is created. | application/json | |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | registry_write_failed — unexpected handler error. | registry_write_failed | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: false. |
reason | string | Yes | Allowed values: subject_resolution_required. |
requestId | string | Yes |
Response example (200)
{
"ok": false,
"reason": "subject_resolution_required",
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/recovery/appeals" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/recovery/appeals', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/recovery/appeals",
data=json.dumps({}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Read an appeal’s status
GET /v1/recovery/appeals/{appealId}
Polls the status of an appeal. Unlike a case, no field of an appeal is redacted. A missing appeal reuses the case reason recovery_case_not_found; there is no separate appeal-not-found code. Availability and rate-limit bucket are the same as for the other recovery routes.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
appealId | path | string | Yes | Opaque appeal id, decoded by the router and re-validated inside the handler. Pattern ^[a-zA-Z0-9._:-]{1,128}$. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The full (unredacted) appeal projection. | application/json | |
400 | invalid_request (opaque-id shape violation), or Invalid appealId (router-level decodeURIComponent throw). | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | Either the capability is not enabled for your organisation (the GatedNotFound body), or recovery_case_not_found: no appeal with that id exists for your organisation. | V1 endpoint not found | application/json |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | registry_write_failed — unexpected handler error. | registry_write_failed | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
appeal | object | Yes | |
appeal.id | string (uuid) | No | |
appeal.tenantId | string | No | |
appeal.caseId | string (uuid) | No | |
appeal.openedAt | integer (int64) | No | |
appeal.slaDueAt | integer (int64) | No | |
appeal.resolvedAt | integer (int64), nullable | No | |
appeal.outcome | string | No | Allowed values: pending, upheld, overturned, expired. |
appeal.resolvedBy | string, nullable | No | |
appeal.createdAt | integer (int64) | No | |
appeal.updatedAt | integer (int64) | No | |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"appeal": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"tenantId": "tenant_42",
"caseId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"openedAt": 1,
"slaDueAt": 1,
"resolvedAt": 1,
"outcome": "pending",
"resolvedBy": "example-resolvedBy",
"createdAt": 1,
"updatedAt": 1
},
"requestId": "req-test"
}
Example request
curl
curl "https://collect.noxtica.com/v1/recovery/appeals/example-appealId" \
-H "Authorization: Bearer $NOXTICA_API_KEY"
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/recovery/appeals/example-appealId', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
},
});
console.log(response.status, await response.text());
Python (standard library)
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/recovery/appeals/example-appealId",
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
},
method="GET",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Read a subject’s device status and verification level
POST /v1/wallet/status
Read-only, organisation-scoped device status and verificationLevel for a subject. It is built on the same trust decision that POST /v1/kyc/reverify reads, plus relying-party log, consent-receipt and pairwise-pseudonym transparency reads, each independently fail-closed. This is the identity and device wallet, not a payment wallet.
verificationLevel is present only on a trusted decision, never on a proof-only link; proofOnly: true marks a proof-only partition (status proofed_unbound, level null). stepUpRequired is reported, never enforced here, only when the decision demands it. An unexpected internal fault answers 200 with every field null (fail-closed: never a 5xx, never a positive status).
Not enabled by default: when the device-registry capability is not enabled for your organisation the route answers the GatedNotFound 404.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
subjectId | string | Yes | The opaque FR-8 pseudonym identifying the device/subject. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The readout — ALWAYS 200, including on internal fault (every field nulled in that case; never a happy-path 5xx). | application/json | |
400 | invalid_request — malformed body or missing subjectId. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The API key is valid but does not carry the scope this operation requires; error names the missing scope. | API key does not have read:device scope | application/json ScopeForbiddenError |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
verificationLevel | string, nullable | Yes | Present only on a trusted decision. |
status | string, nullable | Yes | |
pairwisePseudonym | string, nullable | Yes | |
consent | object, nullable | Yes | |
consent.version | string | No | |
consent.grantedAt | string | No | |
consent.revoked | boolean | No | |
rpLog | array of object | Yes | |
rpLog[] | object | No | |
rpLog[].event | string | No | |
rpLog[].at | string | No | |
proofOnly | boolean | No | Present (true) only for the proof-only partition. |
stepUpRequired | boolean | No | Present (true) only when the authoritative decision demands step-up. |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"verificationLevel": "verified",
"status": "example-status",
"pairwisePseudonym": "example-pairwisePseudonym",
"consent": {
"version": "example-version",
"grantedAt": "example-grantedAt",
"revoked": true
},
"rpLog": [
{
"event": "example-event",
"at": "example-at"
}
],
"proofOnly": true,
"stepUpRequired": true,
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/wallet/status" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subjectId": "subj_4821"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/wallet/status', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subjectId: 'subj_4821',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/wallet/status",
data=json.dumps({
"subjectId": "subj_4821",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Suspend a subject’s device-bound wallet state
POST /v1/wallet/suspend
Executes the suspended transition for a subject’s device-bound wallet state. Name the subject in one of two ways: attestationId (preferred, and it takes precedence when both are present), resolved server-side from a fresh single-use device-attestation claim; or subjectId (a trusted-caller fallback), which additionally requires the write:identity:trusted-subject scope and otherwise answers a genuine 403.
Every refusal collapses to a closed result so that no response reveals whether a subject exists: an attestation that cannot be resolved gives status: "subject_not_attested", and any illegal transition or internal fault gives status: "suspend_failed". The route always answers 200, with suspended: true on a legal transition. It shares the write-family rate-limit bucket of POST /v1/kyc/verify. Availability is the same as for POST /v1/wallet/status.
Authentication: Bearer token (ApiKeyAuth), scope write:identity or Bearer token (ApiKeyAuth), scope write:identity, write:identity:trusted-subject.
Request body
EXACTLY one of attestationId or subjectId must be present (both may be sent; attestationId takes precedence and subjectId is then silently ignored).
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
attestationId | string | No | Preferred. A fresh device-attestation reference the collector resolves to a subject server-side; consumed single-use. |
subjectId | string | No | Trusted-caller fallback. Requires the additive write:identity:trusted-subject scope; the caller already knows the subject it names. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | ALWAYS 200. suspended:true on a legal transition; suspended:false with status:'subject_not_attested' on an attestation-resolution miss; suspended:false with status:'suspend_failed' on any illegal-transition reason or an internal fault. | application/json | |
400 | invalid_request — malformed body, or neither subjectId nor attestationId present. | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | The subjectId fallback was used WITHOUT the additive write:identity:trusted-subject scope (the base write:identity-scope-missing case uses the standard ScopeForbidden shape). | API key does not have write:identity:trusted-subject scope | application/json |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
suspended | boolean | Yes | |
status | string | Yes | Allowed values: suspended, subject_not_attested, suspend_failed. |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"suspended": true,
"status": "suspended",
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/wallet/suspend" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"attestationId": "att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
"subjectId": "subj_4821"
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/wallet/suspend', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
attestationId: 'att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70',
subjectId: 'subj_4821',
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/wallet/suspend",
data=json.dumps({
"attestationId": "att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
"subjectId": "subj_4821",
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Mint a batch of offline-capture nonces
POST /v1/offline/nonce-pool
Mints 1 to 5 (default 1) single-use, time-limited nonces while the caller has connectivity, for later redemption at POST /v1/offline/capture-bundle. This is a resumable-upload scope, not a truly offline start: the client must be online when it mints. Each nonce is stored only as a keyed digest and the raw nonce is returned exactly once. The default lifetime is 72 hours.
A caller classified as first-party capture on its API key must hold an active legal-basis receipt to mint; otherwise it is refused with 403 kyc_legal_basis_receipt_required. Ordinary tenant traffic is unaffected. Not enabled by default: when the capability is not enabled for your organisation the route answers the GatedNotFound 404.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Request body
Content type application/json, optional.
| Field | Type | Required | Description |
|---|---|---|---|
count | integer | No | Non-integer or out-of-range values fall back to the default (1) rather than erroring. Minimum 1, maximum 5. Default 1. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
200 | The minted nonce pool. Raw nonces are visible exactly once. | application/json | |
400 | Malformed body, bad Content-Length, or a non-JSON-object body (invalid_request). | invalid_request | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | kyc_legal_basis_receipt_required (legal-basis gate refusal), OR the standard scope-forbidden shape. | invalid_request, kyc_legal_basis_receipt_required | application/json ErrorEnvelope |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
413 | The request body exceeded this route’s byte cap. | body_too_large | application/json ErrorEnvelope |
415 | Non-JSON Content-Type (invalid_request). | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | offline_capture_storage_unavailable (digest secret unset / mint fault), or kyc_first_party_provenance_unavailable (legal-basis gate fault). | invalid_request, kyc_first_party_provenance_unavailable, offline_capture_storage_unavailable | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (200, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
nonces | array of object | Yes | |
nonces[] | object | No | |
nonces[].nonce | string | No | |
nonces[].expiresAt | integer (int64) | No | |
requestId | string | Yes |
Response example (200)
{
"ok": true,
"nonces": [
{
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"expiresAt": 1768435200
}
],
"requestId": "req-test"
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/offline/nonce-pool" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"count": 1
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/offline/nonce-pool', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
count: 1,
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/offline/nonce-pool",
data=json.dumps({
"count": 1,
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
Admit a deferred-upload offline capture bundle
POST /v1/offline/capture-bundle
Redeems a nonce minted by POST /v1/offline/nonce-pool to admit an on-device-encrypted, time-limited capture bundle for deferred upload. This endpoint never sees the bundle’s encryption key or media, only control-plane admission fields. It is capture-only: a body that carries any verdict- or attestation-shaped key (verdict, verified, attestationId, reasonCodes and similar) is rejected before any nonce lookup or write, because attestation stays online-only.
The call is idempotent on the Idempotency-Key header: a replay with an identical body returns the same stored response byte for byte, even long after the nonce’s lifetime has elapsed, and a replay with a different body under the same key is a 409 conflict. The request body is limited to 1 MiB. Availability and the legal-basis rule are the same as for POST /v1/offline/nonce-pool.
Authentication: Bearer token (ApiKeyAuth), scope write:identity.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key | header | string | Yes | A replayed key returns the byte-identical stored response — no second provider call, no double charge. At least 8 characters, at most 128 characters. |
Request body
Content type application/json, required.
| Field | Type | Required | Description |
|---|---|---|---|
bundleId | string | Yes | Caller-assigned bundle identifier (accepted as bundleId or bundle_id). At most 256 characters. |
nonce | string | Yes | A nonce previously minted by POST /v1/offline/nonce-pool. |
capturedAt | integer (int64) | Yes | Epoch-millisecond capture timestamp (accepted as capturedAt or captured_at); must be a finite, non-negative safe integer. Minimum 0. |
Responses
| Status | Meaning | Error codes | Content |
|---|---|---|---|
202 | The bundle admission was accepted (fresh claim or a byte-identical idempotent replay). | application/json | |
400 | invalid_request (missing/invalid Idempotency-Key or required fields, malformed body), offline_capture_verdict_claim_rejected (verdict-shaped key present), or offline_capture_nonce_invalid (unknown nonce). | invalid_request, offline_capture_nonce_invalid, offline_capture_verdict_claim_rejected | application/json ErrorEnvelope |
401 | The Authorization: Bearer sk_... header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | invalid_api_key | application/json InvalidApiKeyError |
403 | kyc_legal_basis_receipt_required, or the standard scope-forbidden shape. | invalid_request, kyc_legal_basis_receipt_required | application/json ErrorEnvelope |
404 | The byte-identical “V1 endpoint not found” response returned when the route’s module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | V1 endpoint not found | application/json |
409 | offline_capture_idempotency_conflict (same key, differing body), or offline_capture_nonce_replayed (nonce already claimed by a different bundle). | invalid_request, offline_capture_idempotency_conflict, offline_capture_nonce_replayed | application/json ErrorEnvelope |
410 | offline_capture_bundle_expired — the nonce is past its TTL; the nonce row is deleted and the bundle is never processed. | invalid_request, offline_capture_bundle_expired | application/json ErrorEnvelope |
413 | Body exceeds 1 MiB (body_too_large). | invalid_request | application/json ErrorEnvelope |
415 | Non-JSON Content-Type (invalid_request). | invalid_request | application/json ErrorEnvelope |
429 | Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers. | Rate limit exceeded | application/json |
503 | offline_capture_storage_unavailable (digest secret unset, or a storage/admission fault), or kyc_first_party_provenance_unavailable (legal-basis gate fault, or a ref-stamp fault after a fresh claim — the claimed row is then deleted). | invalid_request, kyc_first_party_provenance_unavailable, offline_capture_storage_unavailable | application/json ErrorEnvelope |
Response headers
| Status | Header | Type | Description |
|---|---|---|---|
429 | Retry-After | integer | Seconds until the bucket allows another request. |
Response fields (202, application/json)
| Field | Type | Always returned | Description |
|---|---|---|---|
ok | boolean | Yes | Allowed values: true. |
bundleId | string | Yes | |
status | string | Yes | Allowed values: accepted. |
capturedAt | integer (int64) | Yes |
Response example (202)
{
"ok": true,
"bundleId": "example-bundleId",
"status": "accepted",
"capturedAt": 1
}
Example request
curl
curl -X POST "https://collect.noxtica.com/v1/offline/capture-bundle" \
-H "Authorization: Bearer $NOXTICA_API_KEY" \
-H "Idempotency-Key: example-Idempotency-Key" \
-H "Content-Type: application/json" \
-d '{
"bundleId": "example-bundleId",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"capturedAt": 0
}'
Node.js (global fetch, Node 18 or later)
const response = await fetch('https://collect.noxtica.com/v1/offline/capture-bundle', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
'Idempotency-Key': 'example-Idempotency-Key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
bundleId: 'example-bundleId',
nonce: 'nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8',
capturedAt: 0,
}),
});
console.log(response.status, await response.text());
Python (standard library)
import json
import os
import urllib.request
request = urllib.request.Request(
"https://collect.noxtica.com/v1/offline/capture-bundle",
data=json.dumps({
"bundleId": "example-bundleId",
"nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
"capturedAt": 0,
}).encode(),
headers={
"Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
"Idempotency-Key": "example-Idempotency-Key",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as response:
print(response.status, response.read().decode())
This page is generated from the Noxtica OpenAPI specification release v0.4.0: v1.yaml version 1.3.0-2026-10-27 (SHA-256 3fd15257a022). Do not edit it by hand.