On this page

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.

EnvironmentBase URL
Productionhttps://collect.noxtica.com

Credential: ApiKeyAuth. See Authentication.

How this page is organisedBar length shows each section’s share of the text. Select a section to jump to it.

Device Attestation

4 operations

Web/App Integrity

1 operation

Mobile App Config

1 operation

Passkey

3 operations

Device Handoff

2 operations

Device Delegation

3 operations

Account Recovery

4 operations

Wallet Status

2 operations

Offline Capture

2 operations

Operations in Mobile and device identity API

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:

  1. {"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.
  2. {"action":"verify","nonce":"...","token":"..."} (the older field name integrityToken is also accepted) claims the nonce exactly once, verifies the token with Google’s Play Integrity service, records the result and returns the verdict. A risk.flagged webhook 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.

FieldTypeRequiredDescription
actionstringNoAllowed values: mint, verify. Default verify.
domain_idstringNoOPTIONAL tenant-owned domain the nonce/claim is bound to.
noncestringNoREQUIRED for verify — the nonce returned by a prior mint.
tokenstringNoREQUIRED for verify — the Play Integrity STANDARD token (also accepted under the legacy key integrityToken).

Responses

StatusMeaningError codesContent
200The 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
400Missing nonce / token, Invalid Content-Length header, or Invalid JSON body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Content-Type must be application/json.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope
503Could not mint challenge — the tenant edge authority / single-use token store is unavailable.invalid_requestapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesEnvelope-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.
platformstringYesAllowed values: android.
noncestringNo
ttlSecondsintegerNo
verdictstringNoAllowed values: pass, fail, error, indeterminate.
verificationLevelstringNoe.g. verified / unverified.
attestationIdstring (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:

  1. {"action":"mint"} returns a single-use nonce, valid for 300 seconds, that the app generates an App Attest key and attestation over.
  2. {"action":"verify","nonce":"...","token":"..."} (the older field name attestationObject is also accepted) parses the attestation object, checks the nonce binding, records the result, may send a risk.flagged webhook and returns the verdict. Verification currently covers structure, rpIdHash and nonce binding (verificationLevel: "structure_only"); promotion to verified additionally requires certificate-chain validation against pinned Apple intermediate key material, which is available only when it is configured for the deployment.
  3. {"action":"assert","nonce":"...","assertion":"...","keyId":"..."} is a destructive account-deletion possession proof, available only when it is enabled for your organisation (otherwise the GatedNotFound 404). It verifies an App Attest assertion against the stored enrollment binding for keyId, enforces a monotonic sign counter (replay defence) and on success returns the bound subjectId and the fixed actionScope. The nonce is consumed even when the assertion fails.

Authentication: Bearer token (ApiKeyAuth), scope read:device.

Request body

Content type application/json, required.

FieldTypeRequiredDescription
actionstringNoAllowed values: mint, verify, assert. Default verify.
domain_idstringNo
noncestringNoREQUIRED for verify/assert — the nonce returned by a prior mint.
tokenstringNoREQUIRED for verify — the base64 CBOR App Attest attestation object (also accepted under the legacy key attestationObject).
assertionstringNoREQUIRED for assert — the base64url App Attest assertion CBOR.
keyIdstringNoREQUIRED for assert — the App Attest key id from the prior enrollment (verify) response.

Responses

StatusMeaningError codesContent
200The 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
400Missing nonce / token, Missing nonce / assertion / keyId, Invalid Content-Length header, or Invalid JSON body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Content-Type must be application/json.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope
503Could not mint challenge.invalid_requestapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesEnvelope-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.
platformstringYesAllowed values: ios.
noncestringNo
ttlSecondsintegerNo
verdictstringNoAllowed values: pass, fail, error, indeterminate.
verificationLevelstringNo
attestationIdstring (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.

FieldTypeRequiredDescription
actionstringNoThis path always defaults/forces verify; use POST /v1/attest/challenge to mint. Allowed values: verify. Default verify.
domain_idstringNo
kindstringNoAllowed values: gpu, cpu. Default gpu.
challengeIdstringNoREQUIRED — the challengeId returned by a prior POST /v1/attest/challenge.
proofobjectNoThe 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

StatusMeaningError codesContent
200Either the NEUTRAL feature-off/fail-open band, or a scored verdict with the M3-masked published tier.application/json
400Invalid Content-Length header or Invalid JSON body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Content-Type must be application/json.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
featureEnabledbooleanYes
tierstringYesAllowed values: hardware, software, attested, suspicious, failed, unavailable.
confidenceintegerYesMinimum 0, maximum 100.
anchoredbooleanYes
reasonsarray of stringYes
verifiedAtstring (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.

FieldTypeRequiredDescription
domain_idstringNo
kindstringNoAllowed values: gpu, cpu. Default gpu.

Responses

StatusMeaningError codesContent
200A minted challenge directive, or the neutral challenge_unavailable refusal.application/json
400Invalid Content-Length header or Invalid JSON body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Content-Type must be application/json.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
featureEnabledbooleanYes
reasonstringNoAllowed values: challenge_unavailable, attest_verify_disabled, attest_verify_tenant_disabled.
challengeobject, nullableNo
challenge.challengeIdstringNo
challenge.noncestringNo
challenge.loaderUrlstring (uri)No
challenge.wasmUrlstring (uri)No
challenge.wasmSha256stringNo
challenge.seedstringNo
challenge.levelintegerNo
challenge.rawEvidenceCommitmentVersionintegerNo
challenge.expiresAtintegerNoEpoch milliseconds.
verifiedAtstring (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.

FieldTypeRequiredDescription
domain_idstringNo
kindstringNoAllowed values: gpu, cpu. Default gpu.
challengeIdstringNoOPTIONAL — a prior /v1/attest/challenge id; absence skips the attestation leg (neutral).
proofobjectNoOPTIONAL CPU/GPU attestation proof, same internal wire shape as /v1/attest/verify’s proof — left open for the same reason.
signalsobjectNoThe caller-supplied calibrated-score signals payload (same lenient envelope as /v1/score — accepts {signals:{...}} or a bare object).

Responses

StatusMeaningError codesContent
200The combined integrity verdict, or the neutral feature-off/fail-open band.application/json
400Invalid Content-Length header, Invalid JSON body, or Invalid body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Content-Type must be application/json.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
featureEnabledbooleanYes
integritystringYesAllowed values: trusted, suspicious, unverified.
signalsobjectYes
signals.attestationstringNoAllowed values: hardware, software, attested, suspicious, failed, unavailable.
signals.virtualizationstringNoAllowed values: corroborated, unavailable.
signals.scorestringNoThe calibrated engine’s riskLevel band.
confidenceintegerYesMinimum 0, maximum 100.
reasonstringNoPresent only on the neutral/feature-off band (e.g. noxtrust_disabled, noxtrust_tenant_disabled, tenant_db_unavailable, score_fault_neutral, internal_neutral).
verifiedAtstring (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:

  1. reason: "killed" — an emergency kill switch is active.
  2. reason: "forced_update" — appVersion is 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.
  3. reason: "app_not_recognized" — Android only, when appRecognitionVerdict is not PLAY_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.

FieldTypeRequiredDescription
platformstringYesAllowed values: android, ios.
appVersionstringYes
appRecognitionVerdictstringNoClient-asserted Play Integrity app-recognition verdict (e.g. PLAY_RECOGNIZED); Android only, ADVISORY, never independently verified.

Responses

StatusMeaningError codesContent
200The resolved forced-update / kill-switch decision.application/json
400Invalid body, Invalid or missing platform, Invalid or missing appVersion, or Invalid appRecognitionVerdict.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
413Payload too large — over the 1024-byte cap.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
allowedbooleanYes
reasonstringYesAllowed values: ok, killed, forced_update, app_not_recognized.
minVersionstring, nullableYesThe 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.

FieldTypeRequiredDescription
subject_idstringYesOpaque caller-generated subject correlation id.
domain_idstringYesThe tenant’s exact registered domain id this ceremony is bound to.
purposestringYesAllowed values: register, assert.

Responses

StatusMeaningError codesContent
200A 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
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

Variant variant 1

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
challengestringYes
purposestringYesAllowed values: register, assert.
ttlSecondsintegerYes
rpobjectYes
rp.idstringNoThe WebAuthn rpId derived from the tenant’s registered origin.
rp.originsarray of string (uri)No
pubKeyCredParamsarray of objectYes
pubKeyCredParams[]objectNo
pubKeyCredParams[].typestringNoAllowed values: public-key.
pubKeyCredParams[].algintegerNoCOSE algorithm id (ES256 -7 or RS256 -257).

Variant variant 2

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: false.
reasonstringYesAllowed 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.

FieldTypeRequiredDescription
challengestringYesbase64url — the challenge minted by POST /v1/passkey/challenge with purpose:'register'.
attestationObjectstringYesbase64url CBOR WebAuthn attestation object.
clientDataJSONstringYesbase64url WebAuthn clientDataJSON.
transportsarray of stringNoOPTIONAL authenticator transports (e.g. usb, nfc, internal), persisted comma-joined.

Responses

StatusMeaningError codesContent
200The 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
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

Variant variant 1

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
credentialIdstringYes
subjectIdstringYes

Variant variant 2

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: false.
reasonstringYesAllowed 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.

FieldTypeRequiredDescription
challengestringYesbase64url — minted with purpose:'assert'.
credentialIdstringYes
authenticatorDatastringYesbase64url WebAuthn authenticatorData.
clientDataJSONstringYesbase64url WebAuthn clientDataJSON.
signaturestringYesbase64url WebAuthn assertion signature.

Responses

StatusMeaningError codesContent
200The 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
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

Variant variant 1

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
verifiedbooleanYesAllowed values: true.
subjectIdstringYes

Variant variant 2

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: false.
reasonstringYesAllowed 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.

FieldTypeRequiredDescription
sessionIdstringYesOpaque caller-generated correlation id; no PII. At most 256 characters.

Responses

StatusMeaningError codesContent
200The minted claim secrets and TTL.application/json
400Invalid or missing sessionId or Invalid body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413Payload too large — over the 1024-byte cap.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
exchangeCodestringYesbase64url(32 random bytes) — the ONLY secret meant for the QR/deep-link.
challengestringYesbase64url(32 random bytes) — conveyed OUT-OF-BAND, never in the QR.
sessionIdstringYes
ttlSecondsintegerYes
expiresAtintegerYesEpoch 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.

FieldTypeRequiredDescription
sessionIdstringYesAt most 256 characters.
exchangeCodestringYesThe exchangeCode scanned from the QR/deep-link. At most 512 characters.
challengestringYesThe separately-conveyed binding secret. At most 512 characters.

Responses

StatusMeaningError codesContent
200The consume outcome — always HTTP 200 whether accepted or refused.application/json
400Invalid or missing sessionId/exchangeCode/challenge or Invalid body.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413Payload too large — over the 2048-byte cap.invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
500Unexpected server fault. Retry with backoff and quote requestId to support if it persists.Internal server errorapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

Variant variant 1

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
sessionIdstringYes

Variant variant 2

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: false.
reasonstringYesAllowed 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.

FieldTypeRequiredDescription
subjectIdstringYesThe subject the delegation concerns (opaque, never raw PII). Pattern ^[a-zA-Z0-9._:-]{1,128}$.
delegatorIdstringYesThe principal granting the delegation. Pattern ^[a-zA-Z0-9._:-]{1,128}$.
delegateIdstringYesThe device/principal receiving the delegation. Pattern ^[a-zA-Z0-9._:-]{1,128}$.
scopestringNoDefaults to device_use when omitted or not one of the closed values. Allowed values: device_use, device_manage. Default device_use.
evidenceRefstring, nullableNoOptional opaque id referencing the evidence/consent record justifying the delegation. Pattern ^[a-zA-Z0-9._:-]{1,128}$.
expiresAtinteger (int64), nullableNoOptional 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.
createdBystring, nullableNoOptional opaque actor id for the audit trail. Pattern ^[a-zA-Z0-9._:-]{1,128}$.

Responses

StatusMeaningError codesContent
200The minted delegation, born active. tenantId is omitted (the caller already knows its own tenant).application/json
400device_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_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Non-JSON Content-Type (invalid_request).invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503device_delegation_storage_unavailable — a D1 write fault.device_delegation_storage_unavailable, invalid_requestapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
delegationobjectYes
delegation.idstring (uuid)No
delegation.subjectIdstringNo
delegation.delegatorIdstringNo
delegation.delegateIdstringNo
delegation.scopestringNoAllowed values: device_use, device_manage.
delegation.statusstringNoAllowed values: active.
delegation.evidenceRefstring, nullableNo
delegation.expiresAtinteger (int64), nullableNo
delegation.revokedAtinteger (int64), nullableNo
delegation.createdBystring, nullableNo
delegation.createdAtinteger (int64)No
delegation.updatedAtinteger (int64)No
requestIdstringYes

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.

FieldTypeRequiredDescription
subjectIdstringYesThe subject whose active delegations to list.

Responses

StatusMeaningError codesContent
200The subject’s active, non-expired delegations (empty array if none / unknown subject).application/json
400invalid_request — subjectId missing.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Non-JSON Content-Type (invalid_request).invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503device_delegation_storage_unavailable — a D1 read fault.device_delegation_storage_unavailable, invalid_requestapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
delegationsarray of objectYes
delegations[]objectNo
delegations[].idstring (uuid)No
delegations[].subjectIdstringNo
delegations[].delegatorIdstringNo
delegations[].delegateIdstringNo
delegations[].scopestringNoAllowed values: device_use, device_manage.
delegations[].statusstringNoAllowed values: active.
delegations[].evidenceRefstring, nullableNo
delegations[].expiresAtinteger (int64), nullableNo
delegations[].revokedAtinteger (int64), nullableNo
delegations[].createdBystring, nullableNo
delegations[].createdAtinteger (int64)No
delegations[].updatedAtinteger (int64)No
requestIdstringYes

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.

FieldTypeRequiredDescription
idstring (uuid)YesThe delegation’s own server-minted id (not the subject/delegator/delegate id).
revokedBystring, nullableNoOptional opaque actor id for the audit trail; omitted ⇒ recorded as a SYSTEM actor.

Responses

StatusMeaningError codesContent
200The delegation was revoked.application/json
400invalid_request — id missing.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404Either 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_requestapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Non-JSON Content-Type (invalid_request).invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503device_delegation_storage_unavailable — a D1 write fault.device_delegation_storage_unavailable, invalid_requestapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
revokedbooleanYes
requestIdstringYes

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.

FieldTypeRequiredDescription
installIdstring (uuid)YesValidated for shape only (UUID v1-5) and discarded — never a subject reference.
tierstringYesAllowed values: fresh_reverification, trusted_referee, alternate_evidence, device_history_attestation, dual_control_override.
evidenceRefstringNoOptional opaque id. Pattern ^[a-zA-Z0-9._:-]{1,128}$.
subjectIdanyNoNOT 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

StatusMeaningError codesContent
200Always the closed business rejection today: no case is created.application/json
400Missing/invalid installId or tier, an invalid evidenceRef shape, or any own-property subjectId present.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Non-JSON Content-Type (invalid_request).invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503registry_write_failed — unexpected handler error (never a raw exception).registry_write_failedapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: false.
reasonstringYesAllowed values: subject_resolution_required.
requestIdstringYes

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

NameInTypeRequiredDescription
caseIdpathstringYesOpaque case id, decoded by the router and re-validated inside the handler. Pattern ^[a-zA-Z0-9._:-]{1,128}$.

Responses

StatusMeaningError codesContent
200The redacted case projection.application/json
400invalid_request (opaque-id shape violation), or Invalid caseId (router-level decodeURIComponent throw on malformed percent-encoding).invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404Either 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 foundapplication/json
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503registry_write_failed — unexpected handler error.registry_write_failedapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
caseobjectYes
case.idstring (uuid)No
case.tenantIdstringNo
case.tierstringNoAllowed values: fresh_reverification, trusted_referee, alternate_evidence, device_history_attestation, dual_control_override.
case.statusstringNoAllowed values: opened, pending_referee, pending_dual_control, pending_fraud_review, approved, denied, blocked_external, expired.
case.resultingVerificationLevelstring, nullableNo
case.resultingStatusstring, nullableNoAllowed values: active, active_limited, proof_restored.
case.slaDueAtinteger (int64)No
case.resolvedAtinteger (int64), nullableNo
case.createdAtinteger (int64)No
case.updatedAtinteger (int64)No
requestIdstringYes

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.

FieldTypeRequiredDescription
caseIdstringNoNominal frozen-BFF-contract field. Never read or validated by this handler.

Responses

StatusMeaningError codesContent
200Always the closed business rejection today: no appeal is created.application/json
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503registry_write_failed — unexpected handler error.registry_write_failedapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: false.
reasonstringYesAllowed values: subject_resolution_required.
requestIdstringYes

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

NameInTypeRequiredDescription
appealIdpathstringYesOpaque appeal id, decoded by the router and re-validated inside the handler. Pattern ^[a-zA-Z0-9._:-]{1,128}$.

Responses

StatusMeaningError codesContent
200The full (unredacted) appeal projection.application/json
400invalid_request (opaque-id shape violation), or Invalid appealId (router-level decodeURIComponent throw).invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404Either 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 foundapplication/json
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503registry_write_failed — unexpected handler error.registry_write_failedapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
appealobjectYes
appeal.idstring (uuid)No
appeal.tenantIdstringNo
appeal.caseIdstring (uuid)No
appeal.openedAtinteger (int64)No
appeal.slaDueAtinteger (int64)No
appeal.resolvedAtinteger (int64), nullableNo
appeal.outcomestringNoAllowed values: pending, upheld, overturned, expired.
appeal.resolvedBystring, nullableNo
appeal.createdAtinteger (int64)No
appeal.updatedAtinteger (int64)No
requestIdstringYes

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.

FieldTypeRequiredDescription
subjectIdstringYesThe opaque FR-8 pseudonym identifying the device/subject.

Responses

StatusMeaningError codesContent
200The readout — ALWAYS 200, including on internal fault (every field nulled in that case; never a happy-path 5xx).application/json
400invalid_request — malformed body or missing subjectId.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json ScopeForbiddenError
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
verificationLevelstring, nullableYesPresent only on a trusted decision.
statusstring, nullableYes
pairwisePseudonymstring, nullableYes
consentobject, nullableYes
consent.versionstringNo
consent.grantedAtstringNo
consent.revokedbooleanNo
rpLogarray of objectYes
rpLog[]objectNo
rpLog[].eventstringNo
rpLog[].atstringNo
proofOnlybooleanNoPresent (true) only for the proof-only partition.
stepUpRequiredbooleanNoPresent (true) only when the authoritative decision demands step-up.
requestIdstringYes

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.

FieldTypeRequiredDescription
attestationIdstringNoPreferred. A fresh device-attestation reference the collector resolves to a subject server-side; consumed single-use.
subjectIdstringNoTrusted-caller fallback. Requires the additive write:identity:trusted-subject scope; the caller already knows the subject it names.

Responses

StatusMeaningError codesContent
200ALWAYS 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
400invalid_request — malformed body, or neither subjectId nor attestationId present.invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403The 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 scopeapplication/json
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
suspendedbooleanYes
statusstringYesAllowed values: suspended, subject_not_attested, suspend_failed.
requestIdstringYes

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.

FieldTypeRequiredDescription
countintegerNoNon-integer or out-of-range values fall back to the default (1) rather than erroring. Minimum 1, maximum 5. Default 1.

Responses

StatusMeaningError codesContent
200The minted nonce pool. Raw nonces are visible exactly once.application/json
400Malformed body, bad Content-Length, or a non-JSON-object body (invalid_request).invalid_requestapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403kyc_legal_basis_receipt_required (legal-basis gate refusal), OR the standard scope-forbidden shape.invalid_request, kyc_legal_basis_receipt_requiredapplication/json ErrorEnvelope
404The 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 foundapplication/json
413The request body exceeded this route’s byte cap.body_too_largeapplication/json ErrorEnvelope
415Non-JSON Content-Type (invalid_request).invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503offline_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_unavailableapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (200, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
noncesarray of objectYes
nonces[]objectNo
nonces[].noncestringNo
nonces[].expiresAtinteger (int64)No
requestIdstringYes

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

NameInTypeRequiredDescription
Idempotency-KeyheaderstringYesA 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.

FieldTypeRequiredDescription
bundleIdstringYesCaller-assigned bundle identifier (accepted as bundleId or bundle_id). At most 256 characters.
noncestringYesA nonce previously minted by POST /v1/offline/nonce-pool.
capturedAtinteger (int64)YesEpoch-millisecond capture timestamp (accepted as capturedAt or captured_at); must be a finite, non-negative safe integer. Minimum 0.

Responses

StatusMeaningError codesContent
202The bundle admission was accepted (fresh claim or a byte-identical idempotent replay).application/json
400invalid_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_rejectedapplication/json ErrorEnvelope
401The 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_keyapplication/json InvalidApiKeyError
403kyc_legal_basis_receipt_required, or the standard scope-forbidden shape.invalid_request, kyc_legal_basis_receipt_requiredapplication/json ErrorEnvelope
404The 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 foundapplication/json
409offline_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_replayedapplication/json ErrorEnvelope
410offline_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_expiredapplication/json ErrorEnvelope
413Body exceeds 1 MiB (body_too_large).invalid_requestapplication/json ErrorEnvelope
415Non-JSON Content-Type (invalid_request).invalid_requestapplication/json ErrorEnvelope
429Rate limit exceeded. Wait Retry-After seconds (also retryAfter in the body) before retrying; the response carries no other rate-limit headers.Rate limit exceededapplication/json
503offline_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_unavailableapplication/json ErrorEnvelope

Response headers

StatusHeaderTypeDescription
429Retry-AfterintegerSeconds until the bucket allows another request.

Response fields (202, application/json)

FieldTypeAlways returnedDescription
okbooleanYesAllowed values: true.
bundleIdstringYes
statusstringYesAllowed values: accepted.
capturedAtinteger (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.