---
title: "API webhooks"
description: "Noxtica webhook deliveries: request headers, the signed envelope, how to verify the signature in Node.js, Python or a shell, and the event catalog."
canonical_url: "https://noxtica.com/docs/api/webhooks/"
markdown_url: "https://noxtica.com/docs/api/webhooks/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: "0.4.0"
site: "Noxtica"
language: "en"
source: markdown
---

# API webhooks

Noxtica sends signed HTTPS requests, called deliveries, to the endpoints you register in the Backoffice under Integrations & API, Webhooks. A delivery tells your system that something happened. Treat it as a notification and read the current state from the API before you act on it.

The signing secret of an endpoint is shown once when the endpoint is created or rotated. Store it like an API key.

## Delivery request

Each delivery is a `POST` with a JSON body. These headers accompany it:

| Header | Required | Description |
| --- | --- | --- |
| `X-Noxtica-Signature` | Yes | `sha256=` followed by the lowercase hex HMAC-SHA256 of the string `<X-Noxtica-Timestamp>.<raw request body>`, keyed with the endpoint's signing secret (the secret string's UTF-8 bytes). Verify against the raw body bytes before parsing, with a constant-time comparison. |
| `X-Noxtica-Timestamp` | Yes | Unix time in milliseconds at which this attempt was signed. Every retry is re-signed with a fresh timestamp, so compare it with your own clock (a 300 second tolerance is recommended; Noxtica itself enforces none), not with the body's `created_at`. |
| `X-Noxtica-Event` | Yes | The event type; equals `type` in the body. |
| `X-Noxtica-Delivery` | Yes | The delivery id; equals `id` in the body and `data.deliveryId`. It is identical on every retry of the same delivery: use it as the deduplication key. |

Every delivery body is a JSON object with the top-level fields `id`, `type`, `created_at`, `tenant_id`, `data`. The content of `data` depends on the event and is described with each event below.

## Verify the signature

The signature header holds `sha256=` followed by the lowercase hexadecimal HMAC-SHA256 of the string `<timestamp header>.<raw request body>`, keyed with the signing secret of the endpoint. Compute it over the raw body bytes before you parse the JSON, compare it in constant time, and reject a delivery whose timestamp is outside a tolerance you choose. The samples use five minutes. The timestamp is in milliseconds since the Unix epoch.

**Node.js**

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_MS = 5 * 60 * 1000;

export function verifyWebhook({ secret, rawBody, signature, timestamp }) {
  const age = Math.abs(Date.now() - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_MS) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
  const received = String(signature).replace(/^sha256=/, '').toLowerCase();
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**Python**

```python
import hashlib
import hmac
import time

TOLERANCE_MS = 5 * 60 * 1000

def verify_webhook(secret: str, raw_body: bytes, signature: str, timestamp: str) -> bool:
    try:
        sent = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() * 1000 - sent) > TOLERANCE_MS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    received = signature.removeprefix("sha256=").lower()
    return hmac.compare_digest(expected, received)
```

**Shell (openssl)**

```bash
printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"
```

Compare the printed hexadecimal digest with the part of the signature header after `sha256=`.

## Delivery, retries and deduplication

Answer every delivery with a `2xx` status as soon as you have stored it, and process it afterwards. The delivery header is the same on every retry of one delivery, so use it as the deduplication key.

Noxtica sends events to the HTTPS endpoints your organisation registers in the Backoffice
(Integrations & API, Webhooks); an endpoint receives only the event types ticked on it. An
endpoint URL must be HTTPS without embedded credentials and must not point at a private,
loopback or internal address, and an organisation can register at most 25 endpoints. The
signing secret is shown once when the endpoint is created or rotated; a rotation invalidates
the old secret at once.

**Request.** Each delivery is `POST` with `Content-Type: application/json` and the headers
`X-Noxtica-Signature`, `X-Noxtica-Timestamp` (Unix **milliseconds**), `X-Noxtica-Event` and
`X-Noxtica-Delivery`. The body is the envelope `{"id","type","created_at","tenant_id","data"}`:
`id` equals `X-Noxtica-Delivery`, `created_at` is Unix milliseconds and `tenant_id` is your
organisation. Verdict payloads carry no raw IP address and no personal data.

**Signature.** `X-Noxtica-Signature` is `sha256=` plus the lowercase hex HMAC-SHA256, keyed with
the endpoint's secret (its UTF-8 bytes), of the string `<X-Noxtica-Timestamp>.<raw body>`.
Recompute it over the raw body bytes before parsing the JSON, compare in constant time, and
reject a stale timestamp (Noxtica enforces no tolerance of its own; 300 seconds is
recommended). Every retry is re-signed with a fresh timestamp.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, timestamp, rawBody, signatureHeader) {
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const given = signatureHeader.replace(/^sha256=/, '').toLowerCase();
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
```

**Delivery and retries.** Delivery is at-least-once, never exactly-once: deduplicate on
`X-Noxtica-Delivery`, which is identical on every retry of the same delivery. Answer within
5 seconds; redirects are never followed. Any `2xx` marks the delivery delivered. **Any `4xx`
(including `408` and `429`) is final: it is never retried.** A `3xx`, a `5xx`, a timeout or a
network error is retried, up to 5 retries after the first attempt, after which the delivery is
recorded as dead and is not retried again. The spacing between retries is not published.
Respond `2xx` first and process asynchronously. Events can arrive out of order. A webhook is a
notification, not authority: read the resource it refers to before taking an irreversible
action. The Backoffice "Send test" button sends only a sample `submission.scored` event.

## Event catalog

| Event | Sent when | API |
| --- | --- | --- |
| [`kyc.session.completed`](https://noxtica.com/docs/api/webhooks/#kycWebhookSessionCompleted) | A hosted verification session reached a business outcome | Identity verification API |
| [`kyc.session.cancelled`](https://noxtica.com/docs/api/webhooks/#kycWebhookSessionCancelled) | The end customer cancelled a hosted verification session | Identity verification API |
| [`kyc.verification.completed`](https://noxtica.com/docs/api/webhooks/#kycWebhookVerificationCompleted) | A KYC verification request reached a terminal state | Identity verification API |
| [`kyc.erasure.completed`](https://noxtica.com/docs/api/webhooks/#kycWebhookErasureCompleted) | A KYC erasure request finished successfully | Identity verification API |
| [`kyc.document.expiry_warning`](https://noxtica.com/docs/api/webhooks/#kycWebhookDocumentExpiryWarning) | A verified identity document is approaching or past its expiry | Identity verification API |
| [`kyc.reverification.due`](https://noxtica.com/docs/api/webhooks/#kycWebhookReverificationDue) | A previously verified identity is due for re-verification | Identity verification API |
| [`submission.scored`](https://noxtica.com/docs/api/webhooks/#webhookSubmissionScored) | A submission was scored | Device intelligence API |
| [`risk.high`](https://noxtica.com/docs/api/webhooks/#webhookRiskHigh) | A submission was scored high or critical | Device intelligence API |
| [`risk.flagged`](https://noxtica.com/docs/api/webhooks/#webhookRiskFlagged) | A risk flag fired | Device intelligence API |
| [`risk.cluster_flagged`](https://noxtica.com/docs/api/webhooks/#webhookRiskClusterFlagged) | A device cluster was flagged as bad | Device intelligence API |
| [`account.agent_linked`](https://noxtica.com/docs/api/webhooks/#webhookAccountAgentLinked) | A verified agent was linked to an account for the first time | Device intelligence API |

## kyc.session.completed {#kycWebhookSessionCompleted}

A hosted verification session reached a business outcome

Sent by Noxtica to every endpoint you registered for `kyc.session.completed`
(Backoffice, Integrations & API, Webhooks). See "Webhooks (KYC)" in the API description
for the headers, the signature, retries and deduplication; they are common to all six
KYC events.

**When it fires.** A hosted session reached `completed`, `unverified` or `error`.
**It does not fire when a session lands in `review_pending`.** With the identity provider
configured today, a successful hosted capture ends in `review_pending` (the provider
routes every success to manual review, reason code `provider_manual_review`); the event
is sent later, when a Backoffice reviewer approves or overrides the case and the session
moves `review_pending` to `completed`. Allow up to about 5 minutes between the reviewer's
decision and the delivery (the relay runs on the platform's 5-minute tick). A
`review_pending` session never expires, so your system must keep waiting for the
decision (or poll `GET /v1/kyc/sessions/{sessionId}`). It never fires for `expired`
or `cancelled_by_user` sessions.

This is a notification, not authority: before you act on it, read
`GET /v1/kyc/sessions/{sessionId}`. The payload carries no subject reference; correlate
by `data.sessionId`, the id you stored when you minted the session.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id, `<sessionId>:<webhookId>`. Identical on every retry; equals `X-Noxtica-Delivery` and `data.deliveryId`. |
| `type` | string | Yes | Allowed values: `kyc.session.completed`. |
| `created_at` | integer (int64) | Yes | Unix ms the event occurred. |
| `tenant_id` | string | Yes |  |
| `data` | object (KycSessionCompletedWebhookData) | Yes | The `data` member of `kyc.session.completed` (`schemaVersion` `"1"`). Delivery is at-least-once: deduplicate by `deliveryId`. A webhook is a notification, not authority; read `GET /v1/kyc/sessions/{sessionId}` for the outcome. There is no subject reference, digest, evidence id or scorecard. The event does not fire when a session lands in `review_pending`; it fires when a reviewer's decision moves it to `completed`. `email` and `emailVerified` (added 2026-10-01, additive) are present only when hosted email verification is enabled for the platform AND the session has a stored address; otherwise both keys are absent. |
| `data.schemaVersion` | string | Yes | Allowed values: `1`. |
| `data.sessionId` | string (uuid) | Yes | The id the mint call returned. |
| `data.status` | string | Yes | The terminal session status. `expired` and `cancelled_by_user` sessions never emit this event. Allowed values: `completed`, `unverified`, `error`. |
| `data.verificationId` | string, nullable | Yes |  |
| `data.reasonCodes` | array of string | Yes |  |
| `data.occurredAt` | integer (int64) | Yes | Unix ms the session reached this status (its `completedAt`). |
| `data.deliveryId` | string | Yes |  |
| `data.nvid` | string | No | The session's Noxtica Verification ID (`NVID-XXXX-XXXX-XXXX-XXXX`). Present only when the session has one (the feature is enabled for your organisation and the outcome was positive); otherwise absent. Never accompanied by the address the person may have given to receive it. |
| `data.email` | string | No | Present only when the session has a stored address (and hosted email verification is enabled for the platform). The normalised address the end customer entered in the hosted flow. Unverified unless `emailVerified` is `true`. |
| `data.emailVerified` | boolean | No | Present together with `email`. `true` only if the customer clicked the confirmation link for `email`. |

### Example payload

```json
{
  "id": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21:wh_7f3a2c",
  "type": "kyc.session.completed",
  "created_at": 1790762445000,
  "tenant_id": "tenant_42",
  "data": {
    "schemaVersion": "1",
    "sessionId": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21",
    "status": "completed",
    "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "reasonCodes": [],
    "occurredAt": 1790762445000,
    "deliveryId": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21:wh_7f3a2c"
  }
}
```

## kyc.session.cancelled {#kycWebhookSessionCancelled}

The end customer cancelled a hosted verification session

Sent when the customer confirms on the hosted page that they do not want to continue and
the session actually moves to `cancelled_by_user`. Available only when session
cancellation is enabled for your organisation. Subscriptions are opt-in per event: an
endpoint registered before this event existed does not receive it until you add it to the
endpoint's event list.

`data.cancelReason` and `data.cancelledAt` are delivered only in this event.
`GET /v1/kyc/sessions/{sessionId}` reports the terminal status `cancelled_by_user` but does
not return the cancel reason or the cancel time. An expiry emits no event; observe it by
reading the session status.

See "Webhooks (KYC)" in the API description for headers, signature, retries and
deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id, `<sessionId>:<webhookId>`. |
| `type` | string | Yes | Allowed values: `kyc.session.cancelled`. |
| `created_at` | integer (int64) | Yes | Unix ms the event occurred. |
| `tenant_id` | string | Yes |  |
| `data` | object (KycSessionCancelledWebhookData) | Yes | The `data` member of `kyc.session.cancelled` (`schemaVersion` `"1"`). Same delivery semantics as `KycSessionCompletedWebhookData`. `email` and `emailVerified` (added 2026-10-01, additive) are present only when hosted email verification is enabled for the platform AND the session has a stored address; otherwise both keys are absent. |
| `data.schemaVersion` | string | Yes | Allowed values: `1`. |
| `data.sessionId` | string (uuid) | Yes |  |
| `data.status` | string | Yes | Allowed values: `cancelled_by_user`. |
| `data.cancelReason` | one of KycSessionCancelReason or any, nullable | Yes |  |
| `data.cancelledAt` | integer (int64) | Yes | Unix ms. |
| `data.occurredAt` | integer (int64) | Yes | Unix ms; equal to `cancelledAt`. |
| `data.deliveryId` | string | Yes |  |
| `data.email` | string | No | Present only when the session has a stored address (and hosted email verification is enabled for the platform). The normalised address the end customer entered before cancelling. Unverified unless `emailVerified` is `true`. |
| `data.emailVerified` | boolean | No | Present together with `email`. `true` only if the customer clicked the confirmation link for `email`. |

### Example payload

```json
{
  "id": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21:wh_7f3a2c",
  "type": "kyc.session.cancelled",
  "created_at": 1790762300000,
  "tenant_id": "tenant_42",
  "data": {
    "schemaVersion": "1",
    "sessionId": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21",
    "status": "cancelled_by_user",
    "cancelReason": "not_now",
    "cancelledAt": 1790762300000,
    "occurredAt": 1790762300000,
    "deliveryId": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21:wh_7f3a2c"
  }
}
```

## kyc.verification.completed {#kycWebhookVerificationCompleted}

A KYC verification request reached a terminal state

Sent when a verification request (`POST /v1/kyc/verify`, or the verification a hosted
session runs when the customer submits) reaches a terminal state: `completed`,
`terminal_error`, `expired` or `cancelled`. The non-terminal states `retryable_error`
and `provider_unknown` emit nothing. `data.verdict` is the stored canonical verdict
(`verified`, `unverified` or `error`; `error` for an infrastructure-faulted terminal
request that has no verdict).

A hosted session's submit runs the same pipeline, so an endpoint subscribed to both events
can receive `kyc.verification.completed` for the same attempt as
`kyc.session.*`; with the identity provider configured today the verdict of a successful
hosted capture is `unverified` with the reason code `provider_manual_review`. Delivery
order between the two events is not guaranteed. `data.verificationId` equals
`session.verificationId` on the session status read.

See "Webhooks (KYC)" in the API description for headers, signature, retries and
deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id, `<verificationId>:<webhookId>`. |
| `type` | string | Yes | Allowed values: `kyc.verification.completed`. |
| `created_at` | integer (int64) | Yes | Unix ms the event occurred. |
| `tenant_id` | string | Yes |  |
| `data` | object (KycVerificationCompletedWebhookData) | Yes | The `data` member of `kyc.verification.completed` (`schemaVersion` `"1"`). A closed, non-personal field set: no subject id, digest or document value. Deduplicate by `deliveryId`; read the verification through the API before acting. |
| `data.schemaVersion` | string | Yes | Allowed values: `1`. |
| `data.verificationId` | string | Yes |  |
| `data.verdict` | string | Yes | Allowed values: `verified`, `unverified`, `error`. |
| `data.reasonCodes` | array of string | Yes | Closed reason-code catalog; may be empty. |
| `data.idType` | string | Yes | The document type asserted on the request, e.g. `passport`. |
| `data.occurredAt` | integer (int64) | Yes | Unix ms the verification reached its terminal state. |
| `data.deliveryId` | string | Yes |  |

### Example payload

```json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6:wh_7f3a2c",
  "type": "kyc.verification.completed",
  "created_at": 1790762440000,
  "tenant_id": "tenant_42",
  "data": {
    "schemaVersion": "1",
    "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "verdict": "unverified",
    "reasonCodes": [
      "provider_manual_review"
    ],
    "idType": "passport",
    "occurredAt": 1790762440000,
    "deliveryId": "3fa85f64-5717-4562-b3fc-2c963f66afa6:wh_7f3a2c"
  }
}
```

## kyc.erasure.completed {#kycWebhookErasureCompleted}

A KYC erasure request finished successfully

Sent when an erasure request reaches `completed`. A `partial` erasure never emits this
event. The payload deliberately carries no subject identifier or subject digest: an erasure
notification must not re-establish a link to the person whose linkage was just erased.
`data.requestId` is the erasure request's own id.

See "Webhooks (KYC)" in the API description for headers, signature, retries and
deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `data.deliveryId`. |
| `type` | string | Yes | Allowed values: `kyc.erasure.completed`. |
| `created_at` | integer (int64) | Yes | Unix ms the event occurred. |
| `tenant_id` | string | Yes |  |
| `data` | object (KycErasureCompletedWebhookData) | Yes | The `data` member of `kyc.erasure.completed` (`schemaVersion` `"1"`). Emitted only when the erasure request reaches `completed`; a `partial` erasure emits nothing. No subject identifier is carried. |
| `data.schemaVersion` | string | Yes | Allowed values: `1`. |
| `data.requestId` | string | Yes | The erasure request's own id; not a subject id. |
| `data.outcome` | string | Yes | Allowed values: `completed`. |
| `data.occurredAt` | integer (int64) | Yes | Unix ms the erasure request reached its terminal state. |
| `data.deliveryId` | string | Yes |  |

### Example payload

```json
{
  "id": "er_4b81d2:wh_7f3a2c",
  "type": "kyc.erasure.completed",
  "created_at": 1790900000000,
  "tenant_id": "tenant_42",
  "data": {
    "schemaVersion": "1",
    "requestId": "er_4b81d2",
    "outcome": "completed",
    "occurredAt": 1790900000000,
    "deliveryId": "er_4b81d2:wh_7f3a2c"
  }
}
```

## kyc.document.expiry_warning {#kycWebhookDocumentExpiryWarning}

A verified identity document is approaching or past its expiry

Sent by the scheduled document-expiry monitoring pass when a monitored verification's
document crosses the 90, 60 or 30 day threshold or has expired (`data.alertType`). Only
available when document-expiry monitoring is enabled for your organisation. Each
threshold fires once per monitored verification.

Unlike the other KYC events, this event is not read from the delivery ledger: the
scheduled pass makes a single signed delivery attempt to each subscribed endpoint and
does not retry a failed attempt. Deduplicate on the delivery id anyway, and use
`data.verificationId` to correlate with the original `kyc.verification.completed`.
See "Webhooks (KYC)" for headers and signature.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `data.deliveryId`. |
| `type` | string | Yes | Allowed values: `kyc.document.expiry_warning`. |
| `created_at` | integer (int64) | Yes | Unix ms the event occurred. |
| `tenant_id` | string | Yes |  |
| `data` | object (KycDocumentExpiryWarningWebhookData) | Yes | The `data` member of `kyc.document.expiry_warning` (`schemaVersion` `"1"`). No subject id and no document number: correlate by `verificationId`. |
| `data.schemaVersion` | string | Yes | Allowed values: `1`. |
| `data.verificationId` | string | Yes |  |
| `data.alertType` | string | Yes | Allowed values: `expiry_90d`, `expiry_60d`, `expiry_30d`, `expiry_expired`. |
| `data.documentExpiresAt` | integer (int64) | Yes | Unix ms the monitored document expires (or expired). |
| `data.occurredAt` | integer (int64) | Yes | Unix ms the threshold crossing was detected. |
| `data.deliveryId` | string | Yes |  |

### Example payload

```json
{
  "id": "sched_91c0:wh_7f3a2c",
  "type": "kyc.document.expiry_warning",
  "created_at": 1798538000000,
  "tenant_id": "tenant_42",
  "data": {
    "schemaVersion": "1",
    "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "alertType": "expiry_30d",
    "documentExpiresAt": 1801130000000,
    "occurredAt": 1798538000000,
    "deliveryId": "sched_91c0:wh_7f3a2c"
  }
}
```

## kyc.reverification.due {#kycWebhookReverificationDue}

A previously verified identity is due for re-verification

Sent by the same scheduled monitoring pass as `kyc.document.expiry_warning` when a
re-verification schedule entry reaches its due time. Only available when document-expiry
monitoring is enabled for your organisation, and only for a verification that has a
re-verification schedule entry. Single signed delivery attempt, no retry, as for
`kyc.document.expiry_warning`.

See "Webhooks (KYC)" in the API description for headers and signature.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `data.deliveryId`. |
| `type` | string | Yes | Allowed values: `kyc.reverification.due`. |
| `created_at` | integer (int64) | Yes | Unix ms the event occurred. |
| `tenant_id` | string | Yes |  |
| `data` | object (KycReverificationDueWebhookData) | Yes | The `data` member of `kyc.reverification.due` (`schemaVersion` `"1"`). No subject id. |
| `data.schemaVersion` | string | Yes | Allowed values: `1`. |
| `data.verificationId` | string | Yes |  |
| `data.dueAt` | integer (int64) | Yes | Unix ms the re-verification became due. |
| `data.occurredAt` | integer (int64) | Yes |  |
| `data.deliveryId` | string | Yes |  |

### Example payload

```json
{
  "id": "sched_92d1:wh_7f3a2c",
  "type": "kyc.reverification.due",
  "created_at": 1801130000000,
  "tenant_id": "tenant_42",
  "data": {
    "schemaVersion": "1",
    "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "dueAt": 1801130000000,
    "occurredAt": 1801130000000,
    "deliveryId": "sched_92d1:wh_7f3a2c"
  }
}
```

## submission.scored {#webhookSubmissionScored}

A submission was scored

Sent for every scored browser or app submission of your organisation (the broadest
subscription: use `risk.high` or `risk.flagged` if you only need the notable ones). The
Backoffice "Send test" button sends a sample of this event type, with placeholder verdict
data and a valid signature.

`data.event_id` is the request id of the submission: correlate it with the `X-Request-Id`
your browser integration received. `data.device_id` is the opaque device id
(`fingerprintId`). This is a notification, not authority; read the device
(`GET /v1/device/{deviceId}`) before taking an irreversible action.

See "Webhook delivery and retries" in the API description for the headers, the signature,
retries and deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `X-Noxtica-Delivery`. |
| `type` | string | Yes | Allowed values: `submission.scored`, `risk.high`, `risk.flagged`, `risk.cluster_flagged`. |
| `created_at` | integer (int64) | Yes | Unix ms the event was produced. |
| `tenant_id` | string | Yes |  |
| `data` | object (VerdictWebhookData) | Yes | The verdict of one scored submission. Never carries a raw IP address or personal data. |
| `data.score` | number | No | The numeric risk score. |
| `data.risk_level` | string | No | The verdict band, for example `low`, `medium`, `high` or `critical`. |
| `data.confidence` | number | No | Model confidence from 0 to 1. |
| `data.flags` | array of string | No | The risk flags that fired. |
| `data.reason_codes` | array of string | No | The published reason-code projection of `flags`; empty unless reason-code output is enabled for your organisation. |
| `data.device_id` | string | Yes | The opaque device id. An empty string means that no stable per-device identity is available for this event. |
| `data.domain_id` | string | Yes | Your domain id. |
| `data.event_id` | string | Yes | The request id of the submission or attestation; always present. |

### Example payload

```json
{
  "id": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21",
  "type": "submission.scored",
  "created_at": 1790762445000,
  "tenant_id": "YOUR_TENANT_ID",
  "data": {
    "score": 12,
    "risk_level": "low",
    "confidence": 0.91,
    "flags": [],
    "reason_codes": [],
    "device_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "domain_id": "dom_42",
    "event_id": "8f1c2a52-6a6e-4b0e-9d52-2a7c1d9e4b10"
  }
}
```

## risk.high {#webhookRiskHigh}

A submission was scored high or critical

Sent when a scored submission lands in the `high` or `critical` risk band. The payload is
the same verdict shape as `submission.scored`. A submission in that band also produces
`submission.scored` (and `risk.flagged` when a flag fired) for endpoints subscribed to
those types.

See "Webhook delivery and retries" in the API description for the headers, the signature,
retries and deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `X-Noxtica-Delivery`. |
| `type` | string | Yes | Allowed values: `submission.scored`, `risk.high`, `risk.flagged`, `risk.cluster_flagged`. |
| `created_at` | integer (int64) | Yes | Unix ms the event was produced. |
| `tenant_id` | string | Yes |  |
| `data` | object (VerdictWebhookData) | Yes | The verdict of one scored submission. Never carries a raw IP address or personal data. |
| `data.score` | number | No | The numeric risk score. |
| `data.risk_level` | string | No | The verdict band, for example `low`, `medium`, `high` or `critical`. |
| `data.confidence` | number | No | Model confidence from 0 to 1. |
| `data.flags` | array of string | No | The risk flags that fired. |
| `data.reason_codes` | array of string | No | The published reason-code projection of `flags`; empty unless reason-code output is enabled for your organisation. |
| `data.device_id` | string | Yes | The opaque device id. An empty string means that no stable per-device identity is available for this event. |
| `data.domain_id` | string | Yes | Your domain id. |
| `data.event_id` | string | Yes | The request id of the submission or attestation; always present. |

### Example payload

```json
{
  "id": "0b9d7b1c-2f0e-4c8b-9a52-7c1b3a1f9e11",
  "type": "risk.high",
  "created_at": 1790762446000,
  "tenant_id": "YOUR_TENANT_ID",
  "data": {
    "score": 87,
    "risk_level": "high",
    "confidence": 0.88,
    "flags": [
      "example_flag"
    ],
    "reason_codes": [],
    "device_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "domain_id": "dom_42",
    "event_id": "3a7d1e52-6a6e-4b0e-9d52-2a7c1d9e4b11"
  }
}
```

## risk.flagged {#webhookRiskFlagged}

A risk flag fired

Sent when at least one risk flag fired on a scored submission, whatever its risk band.

The same event type is also sent after each mobile attestation `verify` (see
`POST /v1/attest/android` and `POST /v1/attest/ios`), with `flags` of the form
`mobile_attest:<platform>`, `mobile_attest_level:<level>` and `mobile_attest_verdict:<verdict>`.
There `data.device_id` can be an empty string, which means that no stable per-device
identity is available for the event: do not group events on an empty `device_id`, and use
`data.event_id`, which is always present, as the per-event key.

See "Webhook delivery and retries" in the API description for the headers, the signature,
retries and deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `X-Noxtica-Delivery`. |
| `type` | string | Yes | Allowed values: `submission.scored`, `risk.high`, `risk.flagged`, `risk.cluster_flagged`. |
| `created_at` | integer (int64) | Yes | Unix ms the event was produced. |
| `tenant_id` | string | Yes |  |
| `data` | object (VerdictWebhookData) | Yes | The verdict of one scored submission. Never carries a raw IP address or personal data. |
| `data.score` | number | No | The numeric risk score. |
| `data.risk_level` | string | No | The verdict band, for example `low`, `medium`, `high` or `critical`. |
| `data.confidence` | number | No | Model confidence from 0 to 1. |
| `data.flags` | array of string | No | The risk flags that fired. |
| `data.reason_codes` | array of string | No | The published reason-code projection of `flags`; empty unless reason-code output is enabled for your organisation. |
| `data.device_id` | string | Yes | The opaque device id. An empty string means that no stable per-device identity is available for this event. |
| `data.domain_id` | string | Yes | Your domain id. |
| `data.event_id` | string | Yes | The request id of the submission or attestation; always present. |

### Example payload

```json
{
  "id": "9c1e5d2a-3b4f-4a6c-8d7e-1f2a3b4c5d6e",
  "type": "risk.flagged",
  "created_at": 1790762447000,
  "tenant_id": "YOUR_TENANT_ID",
  "data": {
    "score": 40,
    "risk_level": "medium",
    "confidence": 0.7,
    "flags": [
      "example_flag"
    ],
    "reason_codes": [],
    "device_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "domain_id": "dom_42",
    "event_id": "5e8f2a52-6a6e-4b0e-9d52-2a7c1d9e4b12"
  }
}
```

## risk.cluster_flagged {#webhookRiskClusterFlagged}

A device cluster was flagged as bad

Sent once, on the first escalation, when a cluster of devices (devices sharing an
observational sub-fingerprint) is flagged bad in real time because a high or critical
submission came with strong cluster evidence. It lets you react to an emerging cluster
before slower offline feeds catch up. A cluster that is already flagged does not produce
a new delivery for every further submission.

The payload has the same verdict shape as `submission.scored`; `data.risk_level` is the
cluster's band. It carries no raw IP address and no personal data. Available only when
real-time cluster propagation is enabled for the deployment.

See "Webhook delivery and retries" in the API description for the headers, the signature,
retries and deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `X-Noxtica-Delivery`. |
| `type` | string | Yes | Allowed values: `submission.scored`, `risk.high`, `risk.flagged`, `risk.cluster_flagged`. |
| `created_at` | integer (int64) | Yes | Unix ms the event was produced. |
| `tenant_id` | string | Yes |  |
| `data` | object (VerdictWebhookData) | Yes | The verdict of one scored submission. Never carries a raw IP address or personal data. |
| `data.score` | number | No | The numeric risk score. |
| `data.risk_level` | string | No | The verdict band, for example `low`, `medium`, `high` or `critical`. |
| `data.confidence` | number | No | Model confidence from 0 to 1. |
| `data.flags` | array of string | No | The risk flags that fired. |
| `data.reason_codes` | array of string | No | The published reason-code projection of `flags`; empty unless reason-code output is enabled for your organisation. |
| `data.device_id` | string | Yes | The opaque device id. An empty string means that no stable per-device identity is available for this event. |
| `data.domain_id` | string | Yes | Your domain id. |
| `data.event_id` | string | Yes | The request id of the submission or attestation; always present. |

### Example payload

```json
{
  "id": "2a4c6e8f-1b3d-4f5a-9c7e-0d2b4f6a8c1e",
  "type": "risk.cluster_flagged",
  "created_at": 1790762448000,
  "tenant_id": "YOUR_TENANT_ID",
  "data": {
    "score": 91,
    "risk_level": "critical",
    "confidence": 0.93,
    "flags": [
      "example_flag"
    ],
    "reason_codes": [],
    "device_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "domain_id": "dom_42",
    "event_id": "7a9b1c52-6a6e-4b0e-9d52-2a7c1d9e4b13"
  }
}
```

## account.agent_linked {#webhookAccountAgentLinked}

A verified agent was linked to an account for the first time

Sent once, when a cryptographically verified signed agent (Web Bot Auth, RFC 9421) is linked
to one of your end-user accounts for the first time, that is when the (agent, account)
pair had no earlier link. It lets you react when a new agent starts acting on an account.

The payload carries the keyed `account_hash` (a 16-character lowercase-hex pseudonym; the
raw account id is never stored or sent), the verified agent's `agent_keyid` and
`agent_host`, and the usual `device_id`, `domain_id` and `event_id`. It carries no raw IP
address and no personal data. Use `GET /v1/accounts/{accountHash}/devices` to list the
devices and agents linked to the account.

See "Webhook delivery and retries" in the API description for the headers, the signature,
retries and deduplication.

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | The delivery id; equals `X-Noxtica-Delivery`. |
| `type` | string | Yes | Allowed values: `account.agent_linked`. |
| `created_at` | integer (int64) | Yes | Unix ms the event was produced. |
| `tenant_id` | string | Yes |  |
| `data` | object (AgentLinkedWebhookData) | Yes | The first link between a verified agent and one of your accounts. Never carries a raw account id, IP address or personal data. |
| `data.account_hash` | string | Yes | The keyed 16-character lowercase-hex account pseudonym. |
| `data.agent_keyid` | string | Yes | The verified agent's `Signature-Input` key id. |
| `data.agent_host` | string, nullable | Yes | The verified agent's directory host |
| `data.device_id` | string | Yes |  |
| `data.domain_id` | string | Yes |  |
| `data.event_id` | string | Yes |  |

### Example payload

```json
{
  "id": "4d6f8a1c-5e7b-4d9f-8a2c-3b5d7f9a1c2e",
  "type": "account.agent_linked",
  "created_at": 1790762449000,
  "tenant_id": "YOUR_TENANT_ID",
  "data": {
    "account_hash": "0a1b2c3d4e5f6071",
    "agent_keyid": "agent-key-1",
    "agent_host": "agent.example.com",
    "device_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
    "domain_id": "dom_42",
    "event_id": "9b1d3f52-6a6e-4b0e-9d52-2a7c1d9e4b14"
  }
}
```

_This page is generated from the Noxtica OpenAPI specification release `v0.4.0`: `kyc.yaml` version `1.2.0-2026-10-27` (SHA-256 `c91a695cfde0`), `mcp.yaml` version `0.1.0-2026-10-27` (SHA-256 `1a4875efa736`), `pulse.yaml` version `0.9.0-2026-10-27` (SHA-256 `95196a58bb5b`), `v1.yaml` version `1.3.0-2026-10-27` (SHA-256 `3fd15257a022`). Do not edit it by hand._
