> Source: https://kaidn.io/docs/api
> Full documentation index: https://kaidn.io/llms.txt

Docs menuAll pages, quickstarts and this page’s contentsDocs
- [Introduction](https://kaidn.io/docs)
- [Quickstarts](https://kaidn.io/docs/quickstart)
- [Core concepts](https://kaidn.io/docs/concepts)
- [API reference](https://kaidn.io/docs/api)
- [Guides](https://kaidn.io/docs/guides)
- [Keys & dashboard](https://kaidn.io/docs/keys)
- [Glossary](https://kaidn.io/glossary)

Web
- [JavaScript](https://kaidn.io/docs/quickstart/javascript)
- [React](https://kaidn.io/docs/quickstart/react)
- [Next.js](https://kaidn.io/docs/quickstart/nextjs)
- [Preact](https://kaidn.io/docs/quickstart/preact)
- [Vue](https://kaidn.io/docs/quickstart/vue)
- [Nuxt](https://kaidn.io/docs/quickstart/nuxt)
- [Angular](https://kaidn.io/docs/quickstart/angular)
- [Svelte](https://kaidn.io/docs/quickstart/svelte)

Mobile
- Androidsoon
- iOSsoon
- React Nativesoon
- Fluttersoon

Server
- [Node.js](https://kaidn.io/docs#quickstart)
- [PHP](https://kaidn.io/docs/quickstart/php)
- C#/.NETsoon
- Gosoon
- Javasoon
- [Python](https://kaidn.io/docs/quickstart/python)

On this page
- [POST /v1/score](#score)
- [Reason codes](#reasons)
- [Data services](#services)
- [Events & stats](#events)
- [Custom rules](#rules)
- [Allow / blocklists](#lists)
- [Feedback loop](#feedback)
- [Erasure & audit](#privacy)
- [Errors](#errors)
- [Rate limits](#limits)

# API reference

The complete specification. This page assumes you know what a verdict is; if you do not, [core concepts](https://kaidn.io/docs/concepts) explains the model, and the [glossary](https://kaidn.io/glossary) defines every term.

Base URL `https://api.kaidn.io`. All requests and responses are JSON. Unknown request fields are rejected, not ignored.

## POST /v1/score

The call you will make most. Send what you know about one user action; get back a verdict you can branch on and the evidence behind it. Event names are yours to choose (`signup`, `trial_start`, `cashout`, `credit_redeem`, …). Device-reuse and velocity signals sharpen with every event you send, so the same call gets better the longer you use it.

#### Request body

| field | type | notes |
| --- | --- | --- |
| `event` | string | required: your event name, 1–64 chars |
| `user_id` | string | your user's id, enables device-reuse and velocity tracking |
| `ip` | string | IPv4 of the request, datacenter + velocity checks |
| `email` | string | checked for disposable domains, gibberish, plus-tags, MX; only a hash is stored |
| `device_id` | string | the raw browser fingerprint from @kaidn/fp. An input, not an identity: it collides across unrelated people, so we resolve it into device.resolved_id rather than scoring it directly. See Device identity below |
| `device_token` | string | the device_token this API returned on a previous visit, replayed from your own first-party cookie. Presenting it lifts the event to rung 1, where the identity is remembered rather than inferred |
| `event_country` | string | ISO-3166 alpha-2 the event should come from |
| `ip_country` | string | ISO country of the caller's IP, pass your CDN's header (CF-IPCountry etc.) |

#### Response

| field | type | notes |
| --- | --- | --- |
| `event_id` | uuid | persisted event id |
| `score` | 0–100 | summed check weights, clamped |
| `verdict` | enum | allow (

#### Device profile (`device`)

Returned when the event carries a `device_id`. Server-side only, it goes to your backend, never the browser. Populated from the `@kaidn/fp` beacon and cheap history queries.

**Two different fields identify a device here.** `id` is the raw browser fingerprint: useful for debugging, but it *collides* across unrelated people, so it is not an identity. `resolved_id` is the identity, and `resolution` plus `collision_risk` tell you how much to trust it. Store a `device_token` as a first-party cookie (see `@kaidn/sdk`) and the resolution becomes deterministic. [Device identity](#identity) explains the three rungs, what each one links, and the measured risk behind each.

| field | type | notes |
| --- | --- | --- |
| `resolved_id` | string | the resolved device identity. Use this to link visits, not `id` (below) |
| `resolution` | enum | deterministic (a device_token was presented, so we remembered) · probabilistic (inferred from the fingerprint) |
| `resolution_rung` | int | 1 = device token · 2 = fingerprint + network · 3 = neither available, so this identity links nothing |
| `collision_risk` | float | 0-1: measured probability this identity covers more than one person. Highest on iOS Safari, near zero with a token |
| `risk_measured` | boolean | whether collision_risk was measured for this platform, or is the all-traffic average |
| `token_rejected` | string? | set when a device_token was sent but could not be used (malformed · bad_signature · expired), usually a broken integration |
| `account_count` | int | accounts seen on this raw fingerprint. NOT a person count: fingerprints collide (2.31 users per fingerprint on iOS Safari in our production data), so weigh it with collision_risk |
| `account_count_same_network` | int | accounts on this fingerprint AND this network. The number you can defend: agreement on one network is corroboration, not proof |
| `distinct_ips` | int | distinct IPs this device has been scored from |
| `unique` | boolean | false once the device is seen on more than one IP (proxy-rotation / multi-account tell) |
| `connection_type` | enum | datacenter · residential (from the caller's IP) |
| `os / browser / mobile` | string?/bool? | coarse device context parsed from the end-user's UA (via the beacon) |
| `is_headless / ua_consistent` | bool? | automation + UA-spoofing signals from @kaidn/fp |
| `ja4` | boolean | whether an unspoofable JA4 TLS fingerprint was captured for this device |
| `ja4_known_tool` | string? | the non-browser client the beacon's TLS fingerprint matched (e.g. curl); null for essentially all traffic. NOT automation detection — a beacon only exists if the client ran our JavaScript, which these clients cannot, so a value here means the beacon endpoint was called with a CLI tool and a real publishable key. Reported as the forged_beacon reason |

POST /v1/score, 200

```
{
  "event_id": "719d4bb6-5083-41d9-852e-9331f8f703e4",
  "score": 95,
  "verdict": "block",
  "reasons": ["datacenter_ip", "device_reuse", "disposable_email"],
  "reason_text": "Strong indicators of abuse: the IP is a hosting provider
    address (amazon), this device is already linked to 3 accounts, and the
    email uses a disposable domain. Risk score 95/100 → block.",
  "checks": [
    {
      "check": "ipRisk",
      "weight": 45,
      "reason": "datacenter_ip",
      "message": "IP is a datacenter/hosting address, not a residential user",
      "evidence": { "asn": "amazon" }
    },
    {
      "check": "deviceReuse",
      "weight": 15,
      "reason": "device_reuse",
      "message": "This device is linked to 3 accounts, on different networks",
      "evidence": {
        "accountCount": 3,
        "sameAsnAccountCount": 1,
        "resolutionRung": 2,
        "networkCorroborated": false,
        "deterministic": false
      }
    },
    {
      "check": "emailRisk",
      "weight": 35,
      "reason": "disposable_email",
      "message": "Email uses a disposable/temporary domain",
      "evidence": {}
    }
  ],
  "device": {
    "id": "8f1c2ae9d4b7...",          // raw fingerprint. Collides. Debug only
    "resolved_id": "b31f09c7e2a4...", // the identity. Link visits on THIS
    "resolution": "probabilistic",    // no device_token was replayed
    "resolution_rung": 2,             // 1 token · 2 fingerprint+network · 3 neither
    "collision_risk": 0.05,           // measured for android/chrome
    "risk_measured": true,
    "account_count": 3,               // includes fingerprint collisions
    "account_count_same_network": 1,  // the number you can defend
    "distinct_ips": 4,
    "unique": false,
    "connection_type": "datacenter",
    "os": "Android",
    "browser": "Chrome",
    "ja4": true
  },
  // store as a first-party cookie, replay as device_token → rung 1
  "device_token": "kdt_v1.eyJpZCI6IjBmM2E..."
}
```

## Reason codes

Branch on these rather than on the score. Every code is stable and machine-matchable, and every firing carries an `evidence` object with the raw numbers behind it. No black boxes, no mystery scores.

#### Network and IP

Where the request came from. These read the caller's IP against offline datasets, so they cost nothing per request and cannot be rate-limited by a third party.

| code | default weight | fires when |
| --- | --- | --- |
| `datacenter_ip` | 45 | IP is a datacenter/hosting address (CIDR, hosting ASN, or org name) |
| `abuse_ip` | 40 | IP was caught by an abuse trap (known scanner/bot in the Kaidn network) |
| `abusive_asn` | 35 | IP is on a network (ASN) with a high fraud rate across the Kaidn network |
| `proxy_ip / vpn_ip` | 30 | IP flagged as proxy/VPN, including commercial proxy providers at the ASN level |
| `geo_mismatch` | 20 | IP country ≠ expected event country |

#### Device and browser integrity

Whether the browser is what it claims to be. Populated by the @kaidn/fp beacon plus the JA4 TLS fingerprint captured at our edge. Each one is high-precision but contributing: none reaches block on its own.

| code | default weight | fires when |
| --- | --- | --- |
| `headless_browser` | 45 | automated/headless browser detected by @kaidn/fp (webdriver, headless UA, no languages) |
| `device_inconsistent` | 25 | the browser's claimed OS doesn't match the real device (spoofing tell) |
| `emulated_environment` | 40 | software/VM GPU (SwiftShader, VMware…) or farm-grade hardware, an anti-detect / VM environment |
| `noise_injected` | 45 | canvas/WebGL readback is non-deterministic within the session, an anti-detect / canvas-defender browser actively spoofing its fingerprint |
| `device_tampered` | 45 | a native fingerprinting API (canvas/WebGL/navigator) was overridden, the signature of a session-stable anti-detect browser (Multilogin, GoLogin, AdsPower) |
| `context_mismatch` | 45 | the browser's identity differs between the main thread and a Web Worker, a spoofed navigator that missed the worker context |
| `engine_ua_mismatch` | 40 | the real JS engine (V8 vs Gecko/JSC, from the Error-stack format) contradicts the browser the User-Agent claims, spoofed UA / emulation |
| `os_mismatch` | 45 | an OS-truth signal (speech-synthesis voices, Client Hints, or the edge Sec-CH-UA-Platform header) reveals a different OS than the UA claims, a consistent OS spoof (e.g. macOS presenting as Windows) |
| `timezone_mismatch` | 35 | the browser's timezone country ≠ the IP's country, a cloaked anti-detect profile (proxy in one country, clock set for another) |
| `known_spoof_signature` | 35 | the edge JA4 TLS fingerprint is one Kaidn's own data shows is predominantly anti-detect / spoofing sessions (first-party, compounds) |

#### Reuse and velocity

How many accounts or events share one identity, and how fast. Velocity windows are 10 minutes.

| code | default weight | fires when |
| --- | --- | --- |
| `device_reuse` | 25 / 40 / 55 corroborated15 / 25 / 45 not | 2+ / 6+ / 10+ accounts share one device identity. **Corroborated** means we can defend the link: a replayed `device_token` (rung 1), or the same fingerprint on the same network with 3+ accounts. Otherwise the accounts sit on different networks, which is more often a fingerprint collision than a person, and the weight stays light. Neither path reaches `block` alone |
| `ip_velocity` | 25 / 40 | 5+ / 10+ accounts from one IP in 10 minutes |
| `device_velocity` | 25 | 20+ events from one device in 10 minutes |

#### Session behaviour over time

What changed mid-session. These need Kaidn.watch() on a logged-in page, which re-beacons the same device every ~60s so a connection change becomes visible.

| code | default weight | fires when |
| --- | --- | --- |
| `vpn_drop` | 40 | the device's VPN dropped mid-session, a residential IP followed a datacenter/proxy one (real IP leaked) |
| `ip_cloaking` | 30 | the device started masking mid-session, a datacenter/proxy IP followed a residential one |
| `device_ip_hopping` | 25 | one device seen from 5+ IPs in the window |

#### Email

Whether the address is real, reachable, and one inbox rather than several. See identity.email_canonical on the score response for the dedupe key.

| code | default weight | fires when |
| --- | --- | --- |
| `disposable_email` | 35 | email domain is on the disposable list (160,000 domains) |
| `email_no_mx` | 25 | email domain has no mail server |
| `gibberish_email` | 20 | mailbox name looks machine-generated |
| `plus_addressing` | 10 | email uses a +tag (duplicate-account tactic) |

#### The network graph and your own lists

Facts from outside this event: what other operators have seen, and what you have decided yourself. Your lists always win.

| code | default weight | fires when |
| --- | --- | --- |
| `seen_in_fraud_network` | up to 40 | an entity: incl. the edge-captured JA4, was flagged across the operator network |
| `blocklisted_*` | 100 | ip/email/device/user is on your blocklist: instant block |
| `allowlisted_*` | 0 | entity is on your allowlist: instant allow |

## Data services: /v1/check/{email,ip,phone}

Use these when you want to judge one identifier on its own, without recording an event: validating an address at the form, enriching a support ticket, cleaning a list. Send one field, get a `fraud_score` (0–100) and the raw intelligence behind it. Keyed and metered like `/v1/score`; only hashes of PII are stored.

#### POST /v1/check/email

Body: `{ "email": "user@domain.com" }`

| field | type | notes |
| --- | --- | --- |
| `fraud_score` | 0–100 | overall email risk |
| `is_disposable` | bool | domain is a known disposable/temp-mail provider |
| `mx_valid` | bool | domain can actually receive mail (has MX) |
| `catch_all` | bool | mail server accepts any address |
| `looks_gibberish` | bool | mailbox name looks machine-generated |
| `has_plus_tag` | bool | uses +addressing (duplicate-account tactic) |
| `canonical` | string\|null | the identity key: every alias of one mailbox collapses to this. Dedupe on it instead of the raw address. |
| `is_aliased` | bool | a re-spelling (gmail dots, googlemail.com) reaching an inbox you may already have |
| `alias_tricks` | string[] | which tricks were present: plus_tag, dot_trick, domain_alias, … |
| `is_malformed` | bool | not a usable address, it cannot receive mail |
| `reject_reason` | string\|null | why it is unusable (local_too_long, domain_invalid, …) |
| `recent_abuse` | bool | seen in the in-house abuse graph |

#### POST /v1/check/ip

Body: `{ "ip": "3.5.140.1" }`

| field | type | notes |
| --- | --- | --- |
| `fraud_score` | 0–100 | overall IP risk |
| `is_datacenter` | bool | sits in a cloud/hosting range (with datacenter_provider) |
| `is_proxy / is_tor` | bool | known proxy/VPN, or a Tor exit node |
| `country / city / isp` | string | offline geolocation (null if unavailable) |
| `asn` | number | autonomous-system number |
| `recent_abuse` | bool | seen in the in-house abuse graph / honeypot |

#### POST /v1/check/phone

Body: `{ "phone": "+14155550123", "country": "US" }` (country optional)

| field | type | notes |
| --- | --- | --- |
| `fraud_score` | 0–100 | overall phone risk |
| `valid` | bool | a real, dialable number |
| `is_voip` | bool | VOIP line, common for OTP farming |
| `is_disposable` | bool | known temp-SMS / disposable number |
| `line_type` | string | mobile / fixed_line / voip / … |
| `country` | string | ISO country from the number |

```
# each lookup is keyed (x-api-key) and counts toward your monthly usage
curl -X POST https://api.kaidn.io/v1/check/email \
  -H "x-api-key: $KAIDN_API_KEY" -H "content-type: application/json" \
  -d '{"email": "x9f2kq@mailinator.com"}'

curl -X POST https://api.kaidn.io/v1/check/ip \
  -H "x-api-key: $KAIDN_API_KEY" -H "content-type: application/json" \
  -d '{"ip": "3.5.140.1"}'

curl -X POST https://api.kaidn.io/v1/check/phone \
  -H "x-api-key: $KAIDN_API_KEY" -H "content-type: application/json" \
  -d '{"phone": "+14155550123"}'
```

## GET /v1/events · GET /v1/stats

Everything you score is queryable. `/v1/events` returns newest-first (query: `limit` ≤ 200, `offset`, `verdict`, `event`). `/v1/stats` aggregates a time window (query: `window_hours`, default 24): totals by verdict, average score, and the top reason codes. Raw email addresses are never stored, you get `emailHash` and `emailDomain`.

```
curl "https://api.kaidn.io/v1/events?limit=50&verdict=block" \
  -H "x-api-key: $KAIDN_API_KEY"

curl "https://api.kaidn.io/v1/stats?window_hours=168" \
  -H "x-api-key: $KAIDN_API_KEY"
```

## Custom rules: GET/PUT /v1/config

Make it strict where you bleed and relaxed where you do not. Every weight and threshold is overridable per tenant, and you store only what you change: everything else keeps inheriting Kaidn's tuned defaults. Unknown keys and out-of-range values (outside 0–100) are rejected.

```
# read current rules (your overrides + effective config)
curl https://api.kaidn.io/v1/config -H "x-api-key: $KAIDN_API_KEY"

# override any weight or threshold, everything else keeps the defaults
curl -X PUT https://api.kaidn.io/v1/config \
  -H "x-api-key: $KAIDN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "weights": { "emailDisposable": 80 },
    "thresholds": { "review": 30, "block": 60 }
  }'
```

#### Defaults, weights

| key | default | key | default |
| --- | --- | --- | --- |
| `datacenterIp` | 45 | `emailDisposable` | 35 |
| `proxyVpn` | 30 | `emailNoMx` | 25 |
| `deviceReuseLow` | 15 | `emailGibberish` | 20 |
| `deviceReuseMed` | 30 | `emailPlusTag` | 10 |
| `deviceReuseHigh` | 45 | `geoMismatch` | 20 |
| `ipVelocityMed` | 25 | `deviceVelocity` | 25 |
| `ipVelocityHigh` | 40 | | |

#### Defaults, thresholds

| key | default | meaning |
| --- | --- | --- |
| `review` | 40 | score ≥ this → verdict review |
| `block` | 70 | score ≥ this → verdict block |
| `deviceReuseMed / High` | 4 / 10 | account-count boundaries for device reuse tiers |
| `ipVelocityMed / High` | 5 / 10 | accounts-per-IP boundaries (10-minute window) |
| `deviceVelocity` | 20 | events-per-device boundary (10-minute window) |

## Allow / blocklists: /v1/lists

Blocklisted entities score 100/block instantly; allowlisted ones always pass. Blocks beat allows when both match. Types: `ip`, `email`, `device`, `user`. Endpoints: `GET /v1/lists`, `POST /v1/lists`, `DELETE /v1/lists/:id`.

```
# block a device everywhere, instantly (score 100 / block)
curl -X POST https://api.kaidn.io/v1/lists \
  -H "x-api-key: $KAIDN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"list": "block", "type": "device", "value": "fp_c0ffee"}'

# allowlist a trusted user (always passes)
curl -X POST https://api.kaidn.io/v1/lists \
  -H "x-api-key: $KAIDN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"list": "allow", "type": "user", "value": "u_vip_1"}'

curl https://api.kaidn.io/v1/lists -H "x-api-key: $KAIDN_API_KEY"
curl -X DELETE https://api.kaidn.io/v1/lists/42 -H "x-api-key: $KAIDN_API_KEY"
```

## Feedback loop: POST /v1/label

Report the real outcome of an event so scoring sharpens over time. Body: `{ label, event_id?, entities?, note? }` where `label` is `fraud`, `chargeback`, or `legit`.

- `fraud`/`chargeback` with an `event_id` contributes a *confirmed*flag to the shared fraud graph, but only if you've opted in, and only for an event you actually scored (proof-of-observation, so you can't flag entities you never saw).
- `legit` marks a false positive. It's a local suppression only, it stops *your* account acting on that entity and *never*lowers the shared graph's risk. Every `legit` is written to your audit trail (see below).

```
# report a real outcome, sharpens scoring. A fraud/chargeback label with an
# event_id contributes a CONFIRMED flag to the shared graph (if you've opted in).
curl -X POST https://api.kaidn.io/v1/label \
  -H "x-api-key: $KAIDN_API_KEY" -H "content-type: application/json" \
  -d '{"label": "chargeback", "event_id": "evt_..."}'

# mark a false positive, LOCAL suppression only, never lowers shared risk
curl -X POST https://api.kaidn.io/v1/label \
  -H "x-api-key: $KAIDN_API_KEY" -H "content-type: application/json" \
  -d '{"label": "legit", "event_id": "evt_..."}'
```

## Subject rights & audit: /v1/subject · /v1/forget · /v1/suppressions

You are the controller for the end-user data you send us; Kaidn is your processor. These two endpoints exist so you can answer a data-subject request inside the 30 days Art. 12(3) allows, without opening a ticket with us.

`POST /v1/subject` is access and portability (Art. 15 / 20): pass any of `email`, `phone`, `ip`, `device_id`, `user_id` and it returns everything your account holds about that person — scored events, checker-history searches, the device observation timeline and the device profile — with `counts` and an optional `limit` (≤ 5000).

`POST /v1/forget` is erasure (Art. 17). It takes the same identifiers and removes the person from every table that holds them, then stops your account acting on that entity going forward. The two use identical matching, so what `/v1/subject` shows is exactly what `/v1/forget` removes. Email matching is alias-aware: erasing `a@gmail.com` also reaches rows written for `a+tag@googlemail.com`.

Erasure reclaims the freed pages, so the data leaves the database file rather than merely disappearing from queries.

It is local to your account, it never scrubs the cross-operator graph (that would let a fraud ring erase its own network flags; only Kaidn can clear a shared flag). Because erasing also removes local velocity/device history, every `forget`, and every `legit` label, is recorded in a suppression audit so you can spot a socially-engineered erasure.

`GET /v1/suppressions` returns that audit (newest first, query `limit` ≤ 500): `action` (`forget`/`legit`), `entityType`, a one-way `valueHash` (never raw PII), `eventId`, and `createdMs`.

```
# GDPR erasure, delete a data subject from YOUR account, by any identifier
curl -X POST https://api.kaidn.io/v1/forget \
  -H "x-api-key: $KAIDN_API_KEY" -H "content-type: application/json" \
  -d '{"email": "user@domain.com", "device_id": "fp_..."}'

# review the suppression audit (forgets + false-positive labels; hashes only)
curl https://api.kaidn.io/v1/suppressions -H "x-api-key: $KAIDN_API_KEY"
```

## Errors

| status | meaning | typical cause |
| --- | --- | --- |
| 400 | invalid request | missing event, unknown fields (strict validation), bad config keys |
| 401 | unauthenticated | missing/wrong API key, expired session, rotated key still in use |
| 404 | not found | deleting a list entry or tenant that doesn't exist |
| 409 | conflict | registering an email that already has an account |
| 429 | rate limited | over the per-key request budget, back off and retry |

Error bodies are `{ "error": "human-readable message" }`. Scoring itself fails safe: if the AI narration layer is down you still get a templated `reason_text`; a score is never blocked on an LLM.

## Rate limits

240 requests/minute per API key (429 above that, with standard rate-limit headers). Monthly usage, `/v1/score` plus the `/v1/check/*` lookups, scales with your plan; Enterprise gets higher request limits.

[PreviousCore conceptsHow a verdict is made, in plain words](https://kaidn.io/docs/concepts)[NextGuidesWorking code for the common jobs](https://kaidn.io/docs/guides)
