---
title: "Mobile and device identity API"
description: "Verify mobile attestation and integrity, deliver app configuration, and manage passkeys, handoff, delegation, recovery, wallet state and offline capture."
canonical_url: "https://noxtica.com/docs/api/reference/mobile/"
markdown_url: "https://noxtica.com/docs/api/reference/mobile/index.md"
last_updated: 2026-10-27
published: 2026-10-27
section: "server-apis"
section_title: "Server APIs"
openapi_release: "v0.4.0"
openapi_spec_version: "1.3.0-2026-10-27"
site: "Noxtica"
language: "en"
source: markdown
---

# Mobile and device identity API

Verify mobile attestation and integrity, deliver app configuration, and manage passkeys, handoff, delegation, recovery, wallet state and offline capture.

These operations are called by your backend (typically the backend-for-frontend a mobile app authenticates to), never by the mobile app or the browser directly. Authenticate with an `sk_` server API key: attestation, integrity and app configuration need `read:device`, passkeys need `write:passkey`, delegation, recovery, wallet and offline capture need `write:identity`, and handoff needs `write:handoff`. Most families are not enabled by default and answer the `V1 endpoint not found` 404 until they are enabled for your organisation. Base URL: `https://collect.noxtica.com`.

| Environment | Base URL |
| --- | --- |
| Production | `https://collect.noxtica.com` |

Credential: `ApiKeyAuth`. See [Authentication](https://noxtica.com/docs/api/authentication/).

## Android Play Integrity attestation (mint / verify) {#postAttestAndroid}

`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.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | No | Allowed values: `mint`, `verify`. Default `verify`. |
| `domain_id` | string | No | OPTIONAL tenant-owned domain the nonce/claim is bound to. |
| `nonce` | string | No | REQUIRED for `verify` — the nonce returned by a prior `mint`. |
| `token` | string | No | REQUIRED for `verify` — the Play Integrity STANDARD token (also accepted under the legacy key `integrityToken`). |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The mint or verify outcome. A mint returns `{ok,nonce,ttlSeconds,platform}`; a verify returns `{ok,verdict,verificationLevel,platform,attestationId?}` (`attestationId` present only once a PostgreSQL authority row is recorded; `verdict:'error'` signals a recording fault, not a failed proof). IMPORTANT: `ok:true` is only the envelope-level "request handled" flag for BOTH mint and verify — it is present, and true, even when a `verify` call correctly REJECTS a forged/invalid token (fail-closed discipline; this route never returns a 4xx for a bad token). It is never a statement about the attestation's outcome. To determine pass/fail, check `verdict` (only ever `'pass'` when `verificationLevel` is `'verified'`) and `verificationLevel`, never `ok`. |  | `application/json` |
| `400` | `Missing nonce / token`, `Invalid Content-Length header`, or `Invalid JSON body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | `Content-Type must be application/json`. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |
| `503` | `Could not mint challenge` — the tenant edge authority / single-use token store is unavailable. | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Envelope-only "request handled" flag — always true on this 200 path for BOTH mint and a correctly-refused verify. NOT a verdict; check `verdict`/`verificationLevel` for the actual attestation outcome. Allowed values: `true`. |
| `platform` | string | Yes | Allowed values: `android`. |
| `nonce` | string | No |  |
| `ttlSeconds` | integer | No |  |
| `verdict` | string | No | Allowed values: `pass`, `fail`, `error`, `indeterminate`. |
| `verificationLevel` | string | No | e.g. `verified` / `unverified`. |
| `attestationId` | string (uuid) | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "platform": "android",
  "nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
  "ttlSeconds": 3600,
  "verdict": "pass",
  "verificationLevel": "verified",
  "attestationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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) {#postAttestIos}

`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.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | No | Allowed values: `mint`, `verify`, `assert`. Default `verify`. |
| `domain_id` | string | No |  |
| `nonce` | string | No | REQUIRED for `verify`/`assert` — the nonce returned by a prior `mint`. |
| `token` | string | No | REQUIRED for `verify` — the base64 CBOR App Attest attestation object (also accepted under the legacy key `attestationObject`). |
| `assertion` | string | No | REQUIRED for `assert` — the base64url App Attest assertion CBOR. |
| `keyId` | string | No | REQUIRED for `assert` — the App Attest key id from the prior enrollment (`verify`) response. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The mint / verify / assert outcome. A successful `assert` additionally carries `subjectId` (server-resolved from the stored binding — never request-derived). IMPORTANT: `ok:true` is only the envelope-level "request handled" flag for mint/verify/assert alike — it is present, and true, even when a `verify` call correctly REJECTS a forged/invalid attestation object (fail-closed discipline; this route never returns a 4xx for a bad token). It is never a statement about the attestation's outcome. To determine pass/fail, check `verdict` (only ever `'pass'` when `verificationLevel` is `'verified'`) and `verificationLevel`, never `ok`. |  | `application/json` |
| `400` | `Missing nonce / token`, `Missing nonce / assertion / keyId`, `Invalid Content-Length header`, or `Invalid JSON body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | `Content-Type must be application/json`. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |
| `503` | `Could not mint challenge`. | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Envelope-only "request handled" flag — always true on this 200 path for mint/verify/assert alike, including a correctly-refused verify. NOT a verdict; check `verdict`/`verificationLevel` for the actual attestation outcome. Allowed values: `true`. |
| `platform` | string | Yes | Allowed values: `ios`. |
| `nonce` | string | No |  |
| `ttlSeconds` | integer | No |  |
| `verdict` | string | No | Allowed values: `pass`, `fail`, `error`, `indeterminate`. |
| `verificationLevel` | string | No |  |
| `attestationId` | string (uuid) | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "platform": "ios",
  "nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
  "ttlSeconds": 3600,
  "verdict": "pass",
  "verificationLevel": "verified",
  "attestationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#postAttestVerify}

`POST /v1/attest/verify`

Returns a synchronous verdict for a one-off CPU/GPU proof-of-work attestation, driven by your backend on behalf of its web client outside the normal `/client/submit` flow. `{"action":"verify","challengeId":"...","proof":{...}}` redeems a challenge minted by `POST /v1/attest/challenge`.

Not enabled by default: when the capability is not enabled for your organisation this route does not answer 404: it fails open to a neutral HTTP 200. When the capability is not enabled, or on any internal fault, the response is `{"ok":true,"featureEnabled":false,"tier":"unavailable","confidence":50,"anchored":false}`. "No signal" is always a pass-through, never an outage.

Absence is not a penalty: a proof that reports no CPU/GPU capability scores neutral. Only a proof that is present but rejected (after a successful single-use nonce claim) is a `failed` signal, and every rejected proof collapses to the same public reason `verification_rejected`. The literal tier `hardware` is published only when publication of the hardware attestation level is enabled for your organisation; otherwise `hardware` and `software` are both reported as `attested`.

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

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | No | This path always defaults/forces `verify`; use `POST /v1/attest/challenge` to mint. Allowed values: `verify`. Default `verify`. |
| `domain_id` | string | No |  |
| `kind` | string | No | Allowed values: `gpu`, `cpu`. Default `gpu`. |
| `challengeId` | string | No | REQUIRED — the `challengeId` returned by a prior `POST /v1/attest/challenge`. |
| `proof` | object | No | The CPU/GPU attestation proof payload narrowed by `narrowProof` against the obsidian VM's engine-internal proof shape. Left open (`additionalProperties: true`) — the exact engine field set (`adapterInfo`, ULP samples, raw multichannel evidence, etc.) is internal wire format with no independent public contract and is not safely documented from the handler alone. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Either the NEUTRAL feature-off/fail-open band, or a scored verdict with the M3-masked published tier. |  | `application/json` |
| `400` | `Invalid Content-Length header` or `Invalid JSON body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | `Content-Type must be application/json`. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `featureEnabled` | boolean | Yes |  |
| `tier` | string | Yes | Allowed values: `hardware`, `software`, `attested`, `suspicious`, `failed`, `unavailable`. |
| `confidence` | integer | Yes | Minimum 0, maximum 100. |
| `anchored` | boolean | Yes |  |
| `reasons` | array of string | Yes |  |
| `verifiedAt` | string (date-time) | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "featureEnabled": true,
  "tier": "hardware",
  "confidence": 0,
  "anchored": true,
  "reasons": [
    "example-reasons"
  ],
  "verifiedAt": "2026-09-13T12:00:00.000Z"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#postAttestChallenge}

`POST /v1/attest/challenge`

Mints a single-use, organisation- and domain-bound, origin-anchored challenge and returns the directive your client feeds to the attestation runtime. The URL path forces `action: "challenge"` whatever the body says. Redeem the returned `challengeId` at `POST /v1/attest/verify`.

The availability and fail-open posture are the same as for `POST /v1/attest/verify`: a missing `Origin` header, an authority fault or a missing integrity pin all collapse to `{"ok":true,"featureEnabled":true,"challenge":null,"reason":"challenge_unavailable"}` with HTTP 200, never a 5xx.

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

### Request body

Content type `application/json`, optional.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain_id` | string | No |  |
| `kind` | string | No | Allowed values: `gpu`, `cpu`. Default `gpu`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | A minted challenge directive, or the neutral `challenge_unavailable` refusal. |  | `application/json` |
| `400` | `Invalid Content-Length header` or `Invalid JSON body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | `Content-Type must be application/json`. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `featureEnabled` | boolean | Yes |  |
| `reason` | string | No | Allowed values: `challenge_unavailable`, `attest_verify_disabled`, `attest_verify_tenant_disabled`. |
| `challenge` | object, nullable | No |  |
| `challenge.challengeId` | string | No |  |
| `challenge.nonce` | string | No |  |
| `challenge.loaderUrl` | string (uri) | No |  |
| `challenge.wasmUrl` | string (uri) | No |  |
| `challenge.wasmSha256` | string | No |  |
| `challenge.seed` | string | No |  |
| `challenge.level` | integer | No |  |
| `challenge.rawEvidenceCommitmentVersion` | integer | No |  |
| `challenge.expiresAt` | integer | No | Epoch milliseconds. |
| `verifiedAt` | string (date-time) | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
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)

```js
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)

```python
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 {#postIntegrityVerify}

`POST /v1/integrity/verify`

Combines three existing signals into one integrity verdict for a web or webview session; it adds no new scoring path: (1) the CPU/GPU attestation verdict, from an optional `challengeId` / `proof` pair where absence is neutral, (2) the calibrated risk score over a caller-supplied `signals` payload, and (3) the virtualization-corroboration marker read from the score's flags.

Not enabled by default. When the capability is not enabled for your organisation, or on any fault, the route fails open to a neutral HTTP 200 `{"ok":true,"integrity":"unverified","signals":{"attestation":"unavailable","virtualization":"unavailable","score":"minimal"},"confidence":50}`; it never answers 5xx or 404. The `attestation` label is masked exactly as on `POST /v1/attest/verify`.

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

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain_id` | string | No |  |
| `kind` | string | No | Allowed values: `gpu`, `cpu`. Default `gpu`. |
| `challengeId` | string | No | OPTIONAL — a prior `/v1/attest/challenge` id; absence skips the attestation leg (neutral). |
| `proof` | object | No | OPTIONAL CPU/GPU attestation proof, same internal wire shape as `/v1/attest/verify`'s `proof` — left open for the same reason. |
| `signals` | object | No | The caller-supplied calibrated-score signals payload (same lenient envelope as `/v1/score` — accepts `{signals:{...}}` or a bare object). |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The combined integrity verdict, or the neutral feature-off/fail-open band. |  | `application/json` |
| `400` | `Invalid Content-Length header`, `Invalid JSON body`, or `Invalid body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | `Content-Type must be application/json`. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `featureEnabled` | boolean | Yes |  |
| `integrity` | string | Yes | Allowed values: `trusted`, `suspicious`, `unverified`. |
| `signals` | object | Yes |  |
| `signals.attestation` | string | No | Allowed values: `hardware`, `software`, `attested`, `suspicious`, `failed`, `unavailable`. |
| `signals.virtualization` | string | No | Allowed values: `corroborated`, `unavailable`. |
| `signals.score` | string | No | The calibrated engine's `riskLevel` band. |
| `confidence` | integer | Yes | Minimum 0, maximum 100. |
| `reason` | string | No | Present only on the neutral/feature-off band (e.g. noxtrust_disabled, noxtrust_tenant_disabled, tenant_db_unavailable, score_fault_neutral, internal_neutral). |
| `verifiedAt` | string (date-time) | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
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)

```js
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)

```python
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 {#postAppConfig}

`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.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `platform` | string | Yes | Allowed values: `android`, `ios`. |
| `appVersion` | string | Yes |  |
| `appRecognitionVerdict` | string | No | Client-asserted Play Integrity app-recognition verdict (e.g. `PLAY_RECOGNIZED`); Android only, ADVISORY, never independently verified. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The resolved forced-update / kill-switch decision. |  | `application/json` |
| `400` | `Invalid body`, `Invalid or missing platform`, `Invalid or missing appVersion`, or `Invalid appRecognitionVerdict`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `413` | `Payload too large` — over the 1024-byte cap. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `allowed` | boolean | Yes |  |
| `reason` | string | Yes | Allowed values: `ok`, `killed`, `forced_update`, `app_not_recognized`. |
| `minVersion` | string, nullable | Yes | The operator-configured floor for this platform, or `null` when unset. |

**Response example** (`200`)

```json
{
  "ok": true,
  "allowed": true,
  "reason": "ok",
  "minVersion": "example-minVersion"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#postPasskeyChallenge}

`POST /v1/passkey/challenge`

Server-to-server (`sk_` key, scope `write:passkey`). Mints a single-use, time-limited WebAuthn ceremony challenge. You supply an opaque `subject_id`, the exact `domain_id` of one of your active domains and the ceremony `purpose` (`register` or `assert`). The subject's existence is deliberately not checked at mint time. The domain must resolve to an active route of your organisation, and its origin is bound into the challenge, which prevents a later cross-domain ceremony.

Not enabled by default: when the passkey capability is not enabled for your organisation, every passkey endpoint answers the `GatedNotFound` 404.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:passkey`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject_id` | string | Yes | Opaque caller-generated subject correlation id. |
| `domain_id` | string | Yes | The tenant's exact registered domain id this ceremony is bound to. |
| `purpose` | string | Yes | Allowed values: `register`, `assert`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | A minted challenge plus the WebAuthn RP/ceremony parameters, or a closed `ok:false` refusal. |  | `application/json` |
| `400` | `{"ok":false,"reason":"invalid_request"}` — malformed body, missing fields, or an unresolvable `domain_id`. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

**Variant variant 1**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `challenge` | string | Yes |  |
| `purpose` | string | Yes | Allowed values: `register`, `assert`. |
| `ttlSeconds` | integer | Yes |  |
| `rp` | object | Yes |  |
| `rp.id` | string | No | The WebAuthn `rpId` derived from the tenant's registered origin. |
| `rp.origins` | array of string (uri) | No |  |
| `pubKeyCredParams` | array of object | Yes |  |
| `pubKeyCredParams[]` | object | No |  |
| `pubKeyCredParams[].type` | string | No | Allowed values: `public-key`. |
| `pubKeyCredParams[].alg` | integer | No | COSE algorithm id (ES256 -7 or RS256 -257). |

**Variant variant 2**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `false`. |
| `reason` | string | Yes | Allowed values: `invalid_request`, `purpose_mismatch`, `client_data_invalid`, `challenge_invalid`, `challenge_mismatch`, `origin_not_allowed`, `rp_id_mismatch`, `user_not_present`, `user_not_verified`, `attestation_invalid`, `attestation_signature_invalid`, `unsupported_algorithm`, `assertion_signature_invalid`, `sign_count_regression`, `subject_unverified`, `credential_exists`, `credential_unknown`, `storage_error`, `step_up_unconfigured`, `step_up_transport_error`, `step_up_provider_error`, `step_up_no_match`, `step_up_reference_required`, `aaguid_blocked`, `attestation_required`, `attestation_untrusted`, `aaguid_status_rejected`, `step_up_reference_replayed`, `step_up_claim_unavailable`. |

**Response example** (`200`)

```json
{
  "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**

```bash
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)

```js
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)

```python
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 {#postPasskeyRegister}

`POST /v1/passkey/register`

Server-to-server (`sk_` key, scope `write:passkey`). Verifies a WebAuthn attestation and registers the credential. In order: the `register` challenge is consumed (single use); the bound subject must have an active, verified device or KYC proof; the attestation is verified against only the exact active domain bound into the challenge; an optional authenticator (AAGUID) trust policy may reject the credential before storage when it runs in enforce mode; the credential is stored against the opaque `subject_id`, with no personal data.

Every ceremony or policy refusal returns the same closed reason-code catalogue at a uniform HTTP 200 `{"ok":false}`, except `invalid_request` and `purpose_mismatch`, which answer 400, so a status code never reveals whether a credential exists. Availability is the same as for `POST /v1/passkey/challenge`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:passkey`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `challenge` | string | Yes | base64url — the challenge minted by `POST /v1/passkey/challenge` with `purpose:'register'`. |
| `attestationObject` | string | Yes | base64url CBOR WebAuthn attestation object. |
| `clientDataJSON` | string | Yes | base64url WebAuthn `clientDataJSON`. |
| `transports` | array of string | No | OPTIONAL authenticator transports (e.g. `usb`, `nfc`, `internal`), persisted comma-joined. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The registered credential id + bound subject, or a closed `ok:false` refusal. |  | `application/json` |
| `400` | `{"ok":false,"reason":"invalid_request"}` or `{"ok":false,"reason":"purpose_mismatch"}` — the only two reasons that map to HTTP 400. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

**Variant variant 1**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `credentialId` | string | Yes |  |
| `subjectId` | string | Yes |  |

**Variant variant 2**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `false`. |
| `reason` | string | Yes | Allowed values: `invalid_request`, `purpose_mismatch`, `client_data_invalid`, `challenge_invalid`, `challenge_mismatch`, `origin_not_allowed`, `rp_id_mismatch`, `user_not_present`, `user_not_verified`, `attestation_invalid`, `attestation_signature_invalid`, `unsupported_algorithm`, `assertion_signature_invalid`, `sign_count_regression`, `subject_unverified`, `credential_exists`, `credential_unknown`, `storage_error`, `step_up_unconfigured`, `step_up_transport_error`, `step_up_provider_error`, `step_up_no_match`, `step_up_reference_required`, `aaguid_blocked`, `attestation_required`, `attestation_untrusted`, `aaguid_status_rejected`, `step_up_reference_replayed`, `step_up_claim_unavailable`. |

**Response example** (`200`)

```json
{
  "ok": true,
  "credentialId": "example-credentialId",
  "subjectId": "subj_4821"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#postPasskeyAssert}

`POST /v1/passkey/assert`

Server-to-server (`sk_` key, scope `write:passkey`). Verifies a WebAuthn assertion against a stored credential. In order: the `assert` challenge is consumed (single use) and yields the bound `subject_id`; the active credential is looked up by credential id (an unknown id gives the closed `credential_unknown`); the credential must belong to the challenge's subject (otherwise `challenge_mismatch`, so a challenge minted for subject A can never assert subject B's credential); the signature is verified over `authenticatorData || SHA-256(clientDataJSON)` with the stored public key, against only the domain bound into the challenge; the signature counter must strictly advance (otherwise `sign_count_regression`, a replay defence). On success the counter is advanced and the bound `subjectId` is returned.

Every failure returns the same uniform HTTP 200 `{"ok":false}`; credential-level reasons are an integrator diagnostic, not an enumeration oracle, because this surface is intra-organisation and credential ids are high-entropy. Availability is the same as for `POST /v1/passkey/challenge`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:passkey`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `challenge` | string | Yes | base64url — minted with `purpose:'assert'`. |
| `credentialId` | string | Yes |  |
| `authenticatorData` | string | Yes | base64url WebAuthn `authenticatorData`. |
| `clientDataJSON` | string | Yes | base64url WebAuthn `clientDataJSON`. |
| `signature` | string | Yes | base64url WebAuthn assertion signature. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The verified bound subject, or a closed `ok:false` refusal. |  | `application/json` |
| `400` | `{"ok":false,"reason":"invalid_request"}` or `{"ok":false,"reason":"purpose_mismatch"}`. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

**Variant variant 1**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `verified` | boolean | Yes | Allowed values: `true`. |
| `subjectId` | string | Yes |  |

**Variant variant 2**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `false`. |
| `reason` | string | Yes | Allowed values: `invalid_request`, `purpose_mismatch`, `client_data_invalid`, `challenge_invalid`, `challenge_mismatch`, `origin_not_allowed`, `rp_id_mismatch`, `user_not_present`, `user_not_verified`, `attestation_invalid`, `attestation_signature_invalid`, `unsupported_algorithm`, `assertion_signature_invalid`, `sign_count_regression`, `subject_unverified`, `credential_exists`, `credential_unknown`, `storage_error`, `step_up_unconfigured`, `step_up_transport_error`, `step_up_provider_error`, `step_up_no_match`, `step_up_reference_required`, `aaguid_blocked`, `attestation_required`, `attestation_untrusted`, `aaguid_status_rejected`, `step_up_reference_replayed`, `step_up_claim_unavailable`. |

**Response example** (`200`)

```json
{
  "ok": true,
  "verified": true,
  "subjectId": "subj_4821"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#postHandoffMint}

`POST /v1/handoff/mint`

Server-to-server (`sk_` key, scope `write:handoff`): the caller is your own backend, never a browser or a mobile device. Mints a session-bound, single-use claim that hands a verified browser session to a native app. It returns two independent 256-bit secrets, stored only as hashes: `exchangeCode`, the only value meant for the QR code or deep link, and `challenge`, a separate binding secret that you convey through your own out-of-band channel and never embed in the QR code. Both are required together at `POST /v1/handoff/consume`. Minting again for the same `sessionId` atomically supersedes any pending claim for that session, which supports cross-device rescans.

Not enabled by default: when the capability is not enabled for your organisation both handoff endpoints answer the `GatedNotFound` 404.

**Key scope availability.** At the time of writing the `write:handoff` scope is not among the scopes that can be selected when creating a server API key in the Backoffice, so a key created there cannot call the handoff endpoints.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:handoff`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sessionId` | string | Yes | Opaque caller-generated correlation id; no PII. At most 256 characters. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The minted claim secrets and TTL. |  | `application/json` |
| `400` | `Invalid or missing sessionId` or `Invalid body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | `Payload too large` — over the 1024-byte cap. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `exchangeCode` | string | Yes | base64url(32 random bytes) — the ONLY secret meant for the QR/deep-link. |
| `challenge` | string | Yes | base64url(32 random bytes) — conveyed OUT-OF-BAND, never in the QR. |
| `sessionId` | string | Yes |  |
| `ttlSeconds` | integer | Yes |  |
| `expiresAt` | integer | Yes | Epoch milliseconds. |

**Response example** (`200`)

```json
{
  "ok": true,
  "exchangeCode": "example-exchangeCode",
  "challenge": "Y2hhbGxlbmdlLW5vbmNlLTAx",
  "sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z",
  "ttlSeconds": 3600,
  "expiresAt": 1768435200
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#postHandoffConsume}

`POST /v1/handoff/consume`

Server-to-server (`sk_` key, scope `write:handoff`): called by your app's own backend when it resolves the deep link, never by the mobile device. The caller must present all of `sessionId`, `exchangeCode` and `challenge`. An exact match on every bound field, a pending status and an unexpired claim are all required, and the claim is consumed atomically, so an incorrect attempt never burns the real claim.

Every mismatch — a replay, another organisation, another session, a swapped challenge or an expired claim — gives the same closed `claim_invalid` rejection at a uniform HTTP 200, never a 4xx, because revealing which field failed would expose the claim's binding. Availability and the key-scope note are the same as for `POST /v1/handoff/mint`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:handoff`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sessionId` | string | Yes | At most 256 characters. |
| `exchangeCode` | string | Yes | The `exchangeCode` scanned from the QR/deep-link. At most 512 characters. |
| `challenge` | string | Yes | The separately-conveyed binding secret. At most 512 characters. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The consume outcome — always HTTP 200 whether accepted or refused. |  | `application/json` |
| `400` | `Invalid or missing sessionId/exchangeCode/challenge` or `Invalid body`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | `Payload too large` — over the 2048-byte cap. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | Unexpected server fault. Retry with backoff and quote `requestId` to support if it persists. | `Internal server error` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

**Variant variant 1**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `sessionId` | string | Yes |  |

**Variant variant 2**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `false`. |
| `reason` | string | Yes | Allowed values: `claim_invalid`. |

**Response example** (`200`)

```json
{
  "ok": true,
  "sessionId": "01J8Z3K7QN6R2S4T6V8W0X2Y4Z"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#deviceDelegationCreate}

`POST /v1/device-delegation/create`

Creates a delegation of device authority for a subject. The delegation is born `active`, subject to a cap on active delegations per subject (`device_delegation_limit_exceeded` when the cap is reached). The organisation is always the authenticated one: a tenant id in the body is never used and is omitted from the response. Every id field must match `^[a-zA-Z0-9._:-]{1,128}$`, except the server-generated delegation `id`, which is a UUID.

Not enabled by default: when the device-registry capability is not enabled for your organisation the route answers the `GatedNotFound` 404. The three delegation operations share one rate-limit bucket per API key.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subjectId` | string | Yes | The subject the delegation concerns (opaque, never raw PII). Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |
| `delegatorId` | string | Yes | The principal granting the delegation. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |
| `delegateId` | string | Yes | The device/principal receiving the delegation. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |
| `scope` | string | No | Defaults to `device_use` when omitted or not one of the closed values. Allowed values: `device_use`, `device_manage`. Default `device_use`. |
| `evidenceRef` | string, nullable | No | Optional opaque id referencing the evidence/consent record justifying the delegation. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |
| `expiresAt` | integer (int64), nullable | No | Optional epoch-ms timestamp. Omitted ⇒ no expiry. When present MUST be finite and strictly within `[now + 60000, now + 31536000000]` (a 1-minute floor, 1-year ceiling) — rejected, never clamped, outside that window. |
| `createdBy` | string, nullable | No | Optional opaque actor id for the audit trail. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The minted delegation, born `active`. `tenantId` is omitted (the caller already knows its own tenant). |  | `application/json` |
| `400` | `device_delegation_invalid_subject_id` / `_delegator_id` / `_delegate_id` / `_evidence_ref` / `_expiry` / `_created_by` / `_request` / `_limit_exceeded`, or a generic `invalid_request` for a malformed body. | `device_delegation_invalid_subject_id`, `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | Non-JSON `Content-Type` (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `device_delegation_storage_unavailable` — a D1 write fault. | `device_delegation_storage_unavailable`, `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `delegation` | object | Yes |  |
| `delegation.id` | string (uuid) | No |  |
| `delegation.subjectId` | string | No |  |
| `delegation.delegatorId` | string | No |  |
| `delegation.delegateId` | string | No |  |
| `delegation.scope` | string | No | Allowed values: `device_use`, `device_manage`. |
| `delegation.status` | string | No | Allowed values: `active`. |
| `delegation.evidenceRef` | string, nullable | No |  |
| `delegation.expiresAt` | integer (int64), nullable | No |  |
| `delegation.revokedAt` | integer (int64), nullable | No |  |
| `delegation.createdBy` | string, nullable | No |  |
| `delegation.createdAt` | integer (int64) | No |  |
| `delegation.updatedAt` | integer (int64) | No |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
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)

```js
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)

```python
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 {#deviceDelegationList}

`POST /v1/device-delegation/list`

Lists the active, non-expired delegations of a subject. A subject with no active delegations, or an unknown subject, returns an empty array, never a distinguishing status (no existence oracle). Availability and rate-limit bucket are the same as for `POST /v1/device-delegation/create`; a `401` never consumes the bucket.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subjectId` | string | Yes | The subject whose active delegations to list. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The subject's active, non-expired delegations (empty array if none / unknown subject). |  | `application/json` |
| `400` | `invalid_request` — `subjectId` missing. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | Non-JSON `Content-Type` (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `device_delegation_storage_unavailable` — a D1 read fault. | `device_delegation_storage_unavailable`, `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `delegations` | array of object | Yes |  |
| `delegations[]` | object | No |  |
| `delegations[].id` | string (uuid) | No |  |
| `delegations[].subjectId` | string | No |  |
| `delegations[].delegatorId` | string | No |  |
| `delegations[].delegateId` | string | No |  |
| `delegations[].scope` | string | No | Allowed values: `device_use`, `device_manage`. |
| `delegations[].status` | string | No | Allowed values: `active`. |
| `delegations[].evidenceRef` | string, nullable | No |  |
| `delegations[].expiresAt` | integer (int64), nullable | No |  |
| `delegations[].revokedAt` | integer (int64), nullable | No |  |
| `delegations[].createdBy` | string, nullable | No |  |
| `delegations[].createdAt` | integer (int64) | No |  |
| `delegations[].updatedAt` | integer (int64) | No |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
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)

```js
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)

```python
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 {#deviceDelegationRevoke}

`POST /v1/device-delegation/revoke`

Revokes a delegation. Revocation is terminal: a revoked delegation is never honoured again, and restoring access means creating a new delegation. A delegation id that belongs to another organisation answers `device_delegation_not_found`, byte-identical to an unknown id (no existence oracle). Availability and rate-limit bucket are the same as for `POST /v1/device-delegation/create`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (uuid) | Yes | The delegation's own server-minted id (not the subject/delegator/delegate id). |
| `revokedBy` | string, nullable | No | Optional opaque actor id for the audit trail; omitted ⇒ recorded as a SYSTEM actor. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The delegation was revoked. |  | `application/json` |
| `400` | `invalid_request` — `id` missing. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | Either the capability is not enabled for your organisation (the `GatedNotFound` body), or the delegation `id` does not resolve for your organisation (`device_delegation_not_found`, byte-identical for an unknown id and an id of another organisation). | `invalid_request` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | Non-JSON `Content-Type` (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `device_delegation_storage_unavailable` — a D1 write fault. | `device_delegation_storage_unavailable`, `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `revoked` | boolean | Yes |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "revoked": true,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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) {#recoveryOpenCase}

`POST /v1/recovery/cases`

Self-service recovery case opening is not available today. A well-formed request never creates a case: the route validates the shape of `installId` (a UUID, then discarded — it is never treated as a subject reference) and answers the same closed business rejection with HTTP 200, which is the status the mobile backend contract inspects. A request that carries a `subjectId` of any type is rejected with `400 invalid_request`, so that no key holder can name a foreign subject.

Not enabled by default: when the device-registry capability is not enabled for your organisation the route answers the `GatedNotFound` 404. The four `/v1/recovery/*` operations share one rate-limit bucket per API key.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `installId` | string (uuid) | Yes | Validated for shape only (UUID v1-5) and discarded — never a subject reference. |
| `tier` | string | Yes | Allowed values: `fresh_reverification`, `trusted_referee`, `alternate_evidence`, `device_history_attestation`, `dual_control_override`. |
| `evidenceRef` | string | No | Optional opaque id. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |
| `subjectId` | any | No | NOT part of the frozen contract. Any own-property `subjectId` (of any type, including `null`) is rejected with `400 invalid_request` — never accepted-and-ignored. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Always the closed business rejection today: no case is created. |  | `application/json` |
| `400` | Missing/invalid `installId` or `tier`, an invalid `evidenceRef` shape, or any own-property `subjectId` present. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | Non-JSON `Content-Type` (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `registry_write_failed` — unexpected handler error (never a raw exception). | `registry_write_failed` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `false`. |
| `reason` | string | Yes | Allowed values: `subject_resolution_required`. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": false,
  "reason": "subject_resolution_required",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#recoveryGetCase}

`GET /v1/recovery/cases/{caseId}`

Polls the status of a recovery case. The stored case is redacted before it is returned: `subjectId`, `evidenceRef`, `refereeId`, `fraudReviewVerdict`, `createdBy` and `resolutionReasonCode` are never part of the response. A `caseId` that belongs to another organisation answers the same `404` as an unknown id (no existence oracle). Availability and rate-limit bucket are the same as for `POST /v1/recovery/cases`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `caseId` | path | string | Yes | Opaque case id, decoded by the router and re-validated inside the handler. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The redacted case projection. |  | `application/json` |
| `400` | `invalid_request` (opaque-id shape violation), or `Invalid caseId` (router-level `decodeURIComponent` throw on malformed percent-encoding). | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | Either the capability is not enabled for your organisation (the `GatedNotFound` body), or `recovery_case_not_found`: the `caseId` is well formed but no case exists for your organisation (a case of another organisation resolves identically). | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `registry_write_failed` — unexpected handler error. | `registry_write_failed` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `case` | object | Yes |  |
| `case.id` | string (uuid) | No |  |
| `case.tenantId` | string | No |  |
| `case.tier` | string | No | Allowed values: `fresh_reverification`, `trusted_referee`, `alternate_evidence`, `device_history_attestation`, `dual_control_override`. |
| `case.status` | string | No | Allowed values: `opened`, `pending_referee`, `pending_dual_control`, `pending_fraud_review`, `approved`, `denied`, `blocked_external`, `expired`. |
| `case.resultingVerificationLevel` | string, nullable | No |  |
| `case.resultingStatus` | string, nullable | No | Allowed values: `active`, `active_limited`, `proof_restored`. |
| `case.slaDueAt` | integer (int64) | No |  |
| `case.resolvedAt` | integer (int64), nullable | No |  |
| `case.createdAt` | integer (int64) | No |  |
| `case.updatedAt` | integer (int64) | No |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
curl "https://collect.noxtica.com/v1/recovery/cases/example-caseId" \
  -H "Authorization: Bearer $NOXTICA_API_KEY"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
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)

```python
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) {#recoveryOpenAppeal}

`POST /v1/recovery/appeals`

Opening an appeal is not available today. Because no case can be opened, the route refuses unconditionally with the same closed reason as `POST /v1/recovery/cases`, and it does not read or validate the request body: any body, or none, produces the identical response. Availability and rate-limit bucket are the same as for the other recovery routes.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, optional.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `caseId` | string | No | Nominal frozen-BFF-contract field. Never read or validated by this handler. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Always the closed business rejection today: no appeal is created. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `registry_write_failed` — unexpected handler error. | `registry_write_failed` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `false`. |
| `reason` | string | Yes | Allowed values: `subject_resolution_required`. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": false,
  "reason": "subject_resolution_required",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#recoveryGetAppeal}

`GET /v1/recovery/appeals/{appealId}`

Polls the status of an appeal. Unlike a case, no field of an appeal is redacted. A missing appeal reuses the case reason `recovery_case_not_found`; there is no separate appeal-not-found code. Availability and rate-limit bucket are the same as for the other recovery routes.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `appealId` | path | string | Yes | Opaque appeal id, decoded by the router and re-validated inside the handler. Pattern `^[a-zA-Z0-9._:-]{1,128}$`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The full (unredacted) appeal projection. |  | `application/json` |
| `400` | `invalid_request` (opaque-id shape violation), or `Invalid appealId` (router-level `decodeURIComponent` throw). | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | Either the capability is not enabled for your organisation (the `GatedNotFound` body), or `recovery_case_not_found`: no appeal with that id exists for your organisation. | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `registry_write_failed` — unexpected handler error. | `registry_write_failed` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `appeal` | object | Yes |  |
| `appeal.id` | string (uuid) | No |  |
| `appeal.tenantId` | string | No |  |
| `appeal.caseId` | string (uuid) | No |  |
| `appeal.openedAt` | integer (int64) | No |  |
| `appeal.slaDueAt` | integer (int64) | No |  |
| `appeal.resolvedAt` | integer (int64), nullable | No |  |
| `appeal.outcome` | string | No | Allowed values: `pending`, `upheld`, `overturned`, `expired`. |
| `appeal.resolvedBy` | string, nullable | No |  |
| `appeal.createdAt` | integer (int64) | No |  |
| `appeal.updatedAt` | integer (int64) | No |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
curl "https://collect.noxtica.com/v1/recovery/appeals/example-appealId" \
  -H "Authorization: Bearer $NOXTICA_API_KEY"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
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)

```python
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 {#walletStatus}

`POST /v1/wallet/status`

Read-only, organisation-scoped device status and `verificationLevel` for a subject. It is built on the same trust decision that `POST /v1/kyc/reverify` reads, plus relying-party log, consent-receipt and pairwise-pseudonym transparency reads, each independently fail-closed. This is the identity and device wallet, not a payment wallet.

`verificationLevel` is present only on a trusted decision, never on a proof-only link; `proofOnly: true` marks a proof-only partition (status `proofed_unbound`, level `null`). `stepUpRequired` is reported, never enforced here, only when the decision demands it. An unexpected internal fault answers `200` with every field null (fail-closed: never a 5xx, never a positive status).

Not enabled by default: when the device-registry capability is not enabled for your organisation the route answers the `GatedNotFound` 404.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subjectId` | string | Yes | The opaque FR-8 pseudonym identifying the device/subject. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The readout — ALWAYS 200, including on internal fault (every field nulled in that case; never a happy-path 5xx). |  | `application/json` |
| `400` | `invalid_request` — malformed body or missing `subjectId`. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The API key is valid but does not carry the scope this operation requires; `error` names the missing scope. | `API key does not have read:device scope` | `application/json` ScopeForbiddenError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `verificationLevel` | string, nullable | Yes | Present only on a trusted decision. |
| `status` | string, nullable | Yes |  |
| `pairwisePseudonym` | string, nullable | Yes |  |
| `consent` | object, nullable | Yes |  |
| `consent.version` | string | No |  |
| `consent.grantedAt` | string | No |  |
| `consent.revoked` | boolean | No |  |
| `rpLog` | array of object | Yes |  |
| `rpLog[]` | object | No |  |
| `rpLog[].event` | string | No |  |
| `rpLog[].at` | string | No |  |
| `proofOnly` | boolean | No | Present (true) only for the proof-only partition. |
| `stepUpRequired` | boolean | No | Present (true) only when the authoritative decision demands step-up. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "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**

```bash
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)

```js
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)

```python
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 {#walletSuspend}

`POST /v1/wallet/suspend`

Executes the `suspended` transition for a subject's device-bound wallet state. Name the subject in one of two ways: `attestationId` (preferred, and it takes precedence when both are present), resolved server-side from a fresh single-use device-attestation claim; or `subjectId` (a trusted-caller fallback), which additionally requires the `write:identity:trusted-subject` scope and otherwise answers a genuine `403`.

Every refusal collapses to a closed result so that no response reveals whether a subject exists: an attestation that cannot be resolved gives `status: "subject_not_attested"`, and any illegal transition or internal fault gives `status: "suspend_failed"`. The route always answers `200`, with `suspended: true` on a legal transition. It shares the write-family rate-limit bucket of `POST /v1/kyc/verify`. Availability is the same as for `POST /v1/wallet/status`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity` or Bearer token (`ApiKeyAuth`), scope `write:identity`, `write:identity:trusted-subject`.

### Request body

EXACTLY one of `attestationId` or `subjectId` must be present (both may be sent; `attestationId` takes precedence and `subjectId` is then silently ignored).

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `attestationId` | string | No | Preferred. A fresh device-attestation reference the collector resolves to a subject server-side; consumed single-use. |
| `subjectId` | string | No | Trusted-caller fallback. Requires the additive `write:identity:trusted-subject` scope; the caller already knows the subject it names. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | ALWAYS 200. `suspended:true` on a legal transition; `suspended:false` with `status:'subject_not_attested'` on an attestation-resolution miss; `suspended:false` with `status:'suspend_failed'` on any illegal-transition reason or an internal fault. |  | `application/json` |
| `400` | `invalid_request` — malformed body, or neither `subjectId` nor `attestationId` present. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | The `subjectId` fallback was used WITHOUT the additive `write:identity:trusted-subject` scope (the base `write:identity`-scope-missing case uses the standard `ScopeForbidden` shape). | `API key does not have write:identity:trusted-subject scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `suspended` | boolean | Yes |  |
| `status` | string | Yes | Allowed values: `suspended`, `subject_not_attested`, `suspend_failed`. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "suspended": true,
  "status": "suspended",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#offlineNoncePoolMint}

`POST /v1/offline/nonce-pool`

Mints 1 to 5 (default 1) single-use, time-limited nonces while the caller has connectivity, for later redemption at `POST /v1/offline/capture-bundle`. This is a resumable-upload scope, not a truly offline start: the client must be online when it mints. Each nonce is stored only as a keyed digest and the raw nonce is returned exactly once. The default lifetime is 72 hours.

A caller classified as first-party capture on its API key must hold an active legal-basis receipt to mint; otherwise it is refused with `403 kyc_legal_basis_receipt_required`. Ordinary tenant traffic is unaffected. Not enabled by default: when the capability is not enabled for your organisation the route answers the `GatedNotFound` 404.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, optional.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `count` | integer | No | Non-integer or out-of-range values fall back to the default (1) rather than erroring. Minimum 1, maximum 5. Default `1`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The minted nonce pool. Raw nonces are visible exactly once. |  | `application/json` |
| `400` | Malformed body, bad `Content-Length`, or a non-JSON-object body (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | `kyc_legal_basis_receipt_required` (legal-basis gate refusal), OR the standard scope-forbidden shape. | `invalid_request`, `kyc_legal_basis_receipt_required` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `413` | The request body exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | Non-JSON `Content-Type` (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `offline_capture_storage_unavailable` (digest secret unset / mint fault), or `kyc_first_party_provenance_unavailable` (legal-basis gate fault). | `invalid_request`, `kyc_first_party_provenance_unavailable`, `offline_capture_storage_unavailable` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `nonces` | array of object | Yes |  |
| `nonces[]` | object | No |  |
| `nonces[].nonce` | string | No |  |
| `nonces[].expiresAt` | integer (int64) | No |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "nonces": [
    {
      "nonce": "nonce_5f3a8c1e2d4b6f709182a3b4c5d6e7f8",
      "expiresAt": 1768435200
    }
  ],
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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 {#offlineCaptureBundleAdmit}

`POST /v1/offline/capture-bundle`

Redeems a nonce minted by `POST /v1/offline/nonce-pool` to admit an on-device-encrypted, time-limited capture bundle for deferred upload. This endpoint never sees the bundle's encryption key or media, only control-plane admission fields. It is capture-only: a body that carries any verdict- or attestation-shaped key (`verdict`, `verified`, `attestationId`, `reasonCodes` and similar) is rejected before any nonce lookup or write, because attestation stays online-only.

The call is idempotent on the `Idempotency-Key` header: a replay with an identical body returns the same stored response byte for byte, even long after the nonce's lifetime has elapsed, and a replay with a different body under the same key is a `409` conflict. The request body is limited to 1 MiB. Availability and the legal-basis rule are the same as for `POST /v1/offline/nonce-pool`.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Yes | A replayed key returns the byte-identical stored response — no second provider call, no double charge. At least 8 characters, at most 128 characters. |

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `bundleId` | string | Yes | Caller-assigned bundle identifier (accepted as `bundleId` or `bundle_id`). At most 256 characters. |
| `nonce` | string | Yes | A nonce previously minted by `POST /v1/offline/nonce-pool`. |
| `capturedAt` | integer (int64) | Yes | Epoch-millisecond capture timestamp (accepted as `capturedAt` or `captured_at`); must be a finite, non-negative safe integer. Minimum 0. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `202` | The bundle admission was accepted (fresh claim or a byte-identical idempotent replay). |  | `application/json` |
| `400` | `invalid_request` (missing/invalid `Idempotency-Key` or required fields, malformed body), `offline_capture_verdict_claim_rejected` (verdict-shaped key present), or `offline_capture_nonce_invalid` (unknown nonce). | `invalid_request`, `offline_capture_nonce_invalid`, `offline_capture_verdict_claim_rejected` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown, revoked, or belongs to a suspended or deleted organisation. The body is deliberately identical for every cause. | `invalid_api_key` | `application/json` InvalidApiKeyError |
| `403` | `kyc_legal_basis_receipt_required`, or the standard scope-forbidden shape. | `invalid_request`, `kyc_legal_basis_receipt_required` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation, and also for a path that does not exist — indistinguishable by design (anti-enumeration), so do not treat it as proof the path is wrong. | `V1 endpoint not found` | `application/json` |
| `409` | `offline_capture_idempotency_conflict` (same key, differing body), or `offline_capture_nonce_replayed` (nonce already claimed by a different bundle). | `invalid_request`, `offline_capture_idempotency_conflict`, `offline_capture_nonce_replayed` | `application/json` ErrorEnvelope |
| `410` | `offline_capture_bundle_expired` — the nonce is past its TTL; the nonce row is deleted and the bundle is never processed. | `invalid_request`, `offline_capture_bundle_expired` | `application/json` ErrorEnvelope |
| `413` | Body exceeds 1 MiB (`body_too_large`). | `invalid_request` | `application/json` ErrorEnvelope |
| `415` | Non-JSON `Content-Type` (`invalid_request`). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `offline_capture_storage_unavailable` (digest secret unset, or a storage/admission fault), or `kyc_first_party_provenance_unavailable` (legal-basis gate fault, or a ref-stamp fault after a fresh claim — the claimed row is then deleted). | `invalid_request`, `kyc_first_party_provenance_unavailable`, `offline_capture_storage_unavailable` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`202`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `bundleId` | string | Yes |  |
| `status` | string | Yes | Allowed values: `accepted`. |
| `capturedAt` | integer (int64) | Yes |  |

**Response example** (`202`)

```json
{
  "ok": true,
  "bundleId": "example-bundleId",
  "status": "accepted",
  "capturedAt": 1
}
```

### Example request

**curl**

```bash
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)

```js
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)

```python
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._
