Docs menuAll pages, quickstarts and this page’s contents
- Androidsoon
- iOSsoon
- React Nativesoon
- Fluttersoon
API reference
The complete specification. This page assumes you know what a verdict is; if you do not, core concepts explains the model, and the 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 (<40) · review (40–69) · block (≥70), thresholds tunable per tenant |
reasons | string[] | machine-readable reason codes, in firing order |
reason_text | string | plain-English explanation (AI-written when a provider key is set, templated otherwise) |
checks | object[] | full detail per fired check: weight, reason, message, raw evidence |
device | object? | device profile when a device_id is present: see below |
device_token | string | opaque, signed, scoped to your tenant. Store it as a first-party cookie and send it back as device_token on this browser's next event. Always returned, re-signed on every call, valid 400 days |
identity | object? | dedupe keys for the event: identity.email_canonical collapses every alias of one mailbox (+tags, gmail dots, googlemail.com) to one string. Store it and one inbox can’t register as several people, with no second call needed. |
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 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 |
{
"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 corroborated 15 / 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 |
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/chargebackwith anevent_idcontributes a confirmedflag 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).legitmarks a false positive. It's a local suppression only, it stops your account acting on that entity and neverlowers the shared graph's risk. Everylegitis 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.