Docs menuAll pages, quickstarts and this page’s contents

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

fieldtypenotes
eventstringrequired: your event name, 1–64 chars
user_idstringyour user's id, enables device-reuse and velocity tracking
ipstringIPv4 of the request, datacenter + velocity checks
emailstringchecked for disposable domains, gibberish, plus-tags, MX; only a hash is stored
device_idstringthe 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_tokenstringthe 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_countrystringISO-3166 alpha-2 the event should come from
ip_countrystringISO country of the caller's IP, pass your CDN's header (CF-IPCountry etc.)

Response

fieldtypenotes
event_iduuidpersisted event id
score0–100summed check weights, clamped
verdictenumallow (<40) · review (40–69) · block (≥70), thresholds tunable per tenant
reasonsstring[]machine-readable reason codes, in firing order
reason_textstringplain-English explanation (AI-written when a provider key is set, templated otherwise)
checksobject[]full detail per fired check: weight, reason, message, raw evidence
deviceobject?device profile when a device_id is present: see below
device_tokenstringopaque, 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
identityobject?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.

fieldtypenotes
resolved_idstringthe resolved device identity. Use this to link visits, not `id` (below)
resolutionenumdeterministic (a device_token was presented, so we remembered) · probabilistic (inferred from the fingerprint)
resolution_rungint1 = device token · 2 = fingerprint + network · 3 = neither available, so this identity links nothing
collision_riskfloat0-1: measured probability this identity covers more than one person. Highest on iOS Safari, near zero with a token
risk_measuredbooleanwhether collision_risk was measured for this platform, or is the all-traffic average
token_rejectedstring?set when a device_token was sent but could not be used (malformed · bad_signature · expired), usually a broken integration
account_countintaccounts 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_networkintaccounts on this fingerprint AND this network. The number you can defend: agreement on one network is corroboration, not proof
distinct_ipsintdistinct IPs this device has been scored from
uniquebooleanfalse once the device is seen on more than one IP (proxy-rotation / multi-account tell)
connection_typeenumdatacenter · residential (from the caller's IP)
os / browser / mobilestring?/bool?coarse device context parsed from the end-user's UA (via the beacon)
is_headless / ua_consistentbool?automation + UA-spoofing signals from @kaidn/fp
ja4booleanwhether an unspoofable JA4 TLS fingerprint was captured for this device
ja4_known_toolstring?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.

codedefault weightfires when
datacenter_ip45IP is a datacenter/hosting address (CIDR, hosting ASN, or org name)
abuse_ip40IP was caught by an abuse trap (known scanner/bot in the Kaidn network)
abusive_asn35IP is on a network (ASN) with a high fraud rate across the Kaidn network
proxy_ip / vpn_ip30IP flagged as proxy/VPN, including commercial proxy providers at the ASN level
geo_mismatch20IP 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.

codedefault weightfires when
headless_browser45automated/headless browser detected by @kaidn/fp (webdriver, headless UA, no languages)
device_inconsistent25the browser's claimed OS doesn't match the real device (spoofing tell)
emulated_environment40software/VM GPU (SwiftShader, VMware…) or farm-grade hardware, an anti-detect / VM environment
noise_injected45canvas/WebGL readback is non-deterministic within the session, an anti-detect / canvas-defender browser actively spoofing its fingerprint
device_tampered45a native fingerprinting API (canvas/WebGL/navigator) was overridden, the signature of a session-stable anti-detect browser (Multilogin, GoLogin, AdsPower)
context_mismatch45the browser's identity differs between the main thread and a Web Worker, a spoofed navigator that missed the worker context
engine_ua_mismatch40the real JS engine (V8 vs Gecko/JSC, from the Error-stack format) contradicts the browser the User-Agent claims, spoofed UA / emulation
os_mismatch45an 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_mismatch35the browser's timezone country ≠ the IP's country, a cloaked anti-detect profile (proxy in one country, clock set for another)
known_spoof_signature35the 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.

codedefault weightfires when
device_reuse25 / 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_velocity25 / 405+ / 10+ accounts from one IP in 10 minutes
device_velocity2520+ 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.

codedefault weightfires when
vpn_drop40the device's VPN dropped mid-session, a residential IP followed a datacenter/proxy one (real IP leaked)
ip_cloaking30the device started masking mid-session, a datacenter/proxy IP followed a residential one
device_ip_hopping25one 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.

codedefault weightfires when
disposable_email35email domain is on the disposable list (160,000 domains)
email_no_mx25email domain has no mail server
gibberish_email20mailbox name looks machine-generated
plus_addressing10email 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.

codedefault weightfires when
seen_in_fraud_networkup to 40an entity: incl. the edge-captured JA4, was flagged across the operator network
blocklisted_*100ip/email/device/user is on your blocklist: instant block
allowlisted_*0entity 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" }

fieldtypenotes
fraud_score0–100overall email risk
is_disposablebooldomain is a known disposable/temp-mail provider
mx_validbooldomain can actually receive mail (has MX)
catch_allboolmail server accepts any address
looks_gibberishboolmailbox name looks machine-generated
has_plus_tagbooluses +addressing (duplicate-account tactic)
canonicalstring|nullthe identity key: every alias of one mailbox collapses to this. Dedupe on it instead of the raw address.
is_aliasedboola re-spelling (gmail dots, googlemail.com) reaching an inbox you may already have
alias_tricksstring[]which tricks were present: plus_tag, dot_trick, domain_alias, …
is_malformedboolnot a usable address, it cannot receive mail
reject_reasonstring|nullwhy it is unusable (local_too_long, domain_invalid, …)
recent_abuseboolseen in the in-house abuse graph

POST /v1/check/ip

Body: { "ip": "3.5.140.1" }

fieldtypenotes
fraud_score0–100overall IP risk
is_datacenterboolsits in a cloud/hosting range (with datacenter_provider)
is_proxy / is_torboolknown proxy/VPN, or a Tor exit node
country / city / ispstringoffline geolocation (null if unavailable)
asnnumberautonomous-system number
recent_abuseboolseen in the in-house abuse graph / honeypot

POST /v1/check/phone

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

fieldtypenotes
fraud_score0–100overall phone risk
validboola real, dialable number
is_voipboolVOIP line, common for OTP farming
is_disposableboolknown temp-SMS / disposable number
line_typestringmobile / fixed_line / voip / …
countrystringISO 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

keydefaultkeydefault
datacenterIp45emailDisposable35
proxyVpn30emailNoMx25
deviceReuseLow15emailGibberish20
deviceReuseMed30emailPlusTag10
deviceReuseHigh45geoMismatch20
ipVelocityMed25deviceVelocity25
ipVelocityHigh40

Defaults, thresholds

keydefaultmeaning
review40score ≥ this → verdict review
block70score ≥ this → verdict block
deviceReuseMed / High4 / 10account-count boundaries for device reuse tiers
ipVelocityMed / High5 / 10accounts-per-IP boundaries (10-minute window)
deviceVelocity20events-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 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).
  • legit marks a false positive. It's a local suppression only, it stops your account acting on that entity and neverlowers 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

statusmeaningtypical cause
400invalid requestmissing event, unknown fields (strict validation), bad config keys
401unauthenticatedmissing/wrong API key, expired session, rotated key still in use
404not founddeleting a list entry or tenant that doesn't exist
409conflictregistering an email that already has an account
429rate limitedover 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.