Server API
Authentication
Every call on this page runs on your server. The base address is https://api.. Send your secret key in the X-API-Key header; an Authorization: Bearer header with the same key also works.
- Secret key
- Starts with
sk_ - Where it worksEvery call under /api on this page. Server only.
- IP data key
- Starts with
ck_ - Where it worksThe IP data API only (network lookups below). Refused on /api, so it can never open anything else.
Sessions
/api/ collector/ sessionSecret keyMints a short-lived session token for one page, bound to that page's origin. The body takes origin (required), ttlSeconds (30 to 600, default 120) and maxUses (1 to 20, default 3). The quick start covers the response and each error.
curl -X POST https://api.lrdefender.lightningresearch.ai/api/collector/session \ -H "X-API-Key: $LR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"origin": "https://app.example.com"}'Verdicts
/api/ device-id/ {requestId}Secret keyReturns the verdict for one identification, by the request ID the browser sent you. Results are kept for 24 hours; an unknown or expired ID returns 404 with the status not_found.
curl https://api.lrdefender.lightningresearch.ai/api/device-id/$REQUEST_ID \ -H "X-API-Key: $LR_SECRET_KEY"| Name | Type | Description |
|---|---|---|
requestId | string | The identification this verdict belongs to. |
status | string | success, or challenge_required when the bot checks asked for a challenge and the visit did not pass it. The browser SDK runs the challenge itself; a visit that passed reads success. Treat challenge_required like the challenge action and ask for another step in your own flow. |
deviceId | string | The device ID, scoped to your account. |
risk. | string | low, medium, high or critical. |
risk. | string | allow, monitor, challenge or block: the action from our checks. Neither the risk score block nor your rules change it. |
risk. | string[] | Stable keys for why. |
smartSignals. | boolean | Whether the bot checks flagged this visit. |
smartSignals. | boolean | Whether the request came from an AI agent, found the way verificationMethod names. |
smartSignals. | string | null | How the agent was found. cryptographic: the request carried a Web Bot Auth signature (HTTP Message Signatures, RFC 9421) that verified against the key directory the agent publishes; we load OpenAI's, for its ChatGPT agent, and Google-Agent's. web_bot_auth_failed: the request carried a signature that did not verify, so the agent is not trusted, and signatureError says why, such as invalid_signature, or unknown_keyid for a key no directory we load holds. ip_range: the user agent names an agent that publishes its addresses, and the request came from one of them. declaration, header_pattern and behavioral prove nothing about who sent the request: a header declared an agent, the user agent names one or the headers point to one, or the request and its interaction looked like an agent's. null when no agent was found. |
smartSignals. | boolean | true only when verificationMethod is cryptographic or ip_range. |
smartSignals. | string | null | The agent's key, such as openai-gpt or google-agent, and unknown_agent when only the request's behavior gave it away. null when no agent was found. |
ipBlocklist. | boolean | Whether the address is a published Tor exit. |
smartSignals. | boolean | true when a proxy list names the address, when the timing check names a relay, or when the browser's own WebRTC address belongs to another network operator than the connection. Of proxyDetection, the browser's answer carries only this field; the others below come only to your server. false does not prove a direct connection. |
smartSignals. | boolean | null | Whether a proxy list names the address. null when the address lookup did not answer. |
smartSignals. | boolean | null | The timing check. Our edge times its own TCP round trip to whatever holds the connection, then times echoes the browser answers. A proxy ends the TCP connection, so the echoes travel an extra leg to the browser and back, which the browser can make longer but not shorter. true when that leg is clearly longer than the connection allows. false when the check judged and named none, which does not rule out a relay that adds less than about 25 ms, or a VPN that tunnels packets without ending the connection. null when it did not judge: a mobile carrier line, a privacy relay such as iCloud Private Relay or Cloudflare WARP, a CDN or anycast network, geostationary satellite internet, an address lookup that did not answer, or no reading (the WebSocket blocked, an older copy of the SDK, an identification sent from your server). null never means a direct connection. While the risk engine runs, a named relay also counts in the proxy estimate as a fact, and risk_signals lists fake_ip. |
smartSignals. | boolean | Present, and true, only when the browser's own WebRTC address belongs to another network operator than the connection. See vpnDetection.vpnMethods.webrtc_leak below. |
smartSignals. | number | null | The extra leg the timing check measured, in whole milliseconds. null when it did not judge. |
smartSignals. | string | null | high when a proxy list names the address, medium when only the timing check or WebRTC named a proxy, low when nothing did. null when the address lookup did not answer. |
smartSignals. | boolean | true when the browser's own WebRTC address belongs to another network operator than the connection: the visit came through a proxy or a VPN, and that address is the line it started from. WebRTC runs over UDP, which a proxy set in the browser does not carry. A privacy relay such as iCloud Private Relay, or another address of the same operator, is not a leak. The browser sends this address, so a browser that keeps WebRTC off shows nothing, and no leak does not mean no proxy. |
smartSignals. | string | null | With a WebRTC leak, the country of the line the visit started from, and realLocation.evidenceClass then reads webrtc. Without one, it comes from the browser's keyboard layout and default voice when both name the same country (evidenceClass 2). realLocation.countryMismatch is true when it differs from the address's country. null when nothing established a true country. |
ruleAction | string | Present when one of your matched rules has a block, challenge or flag action: the strictest of them (block, then challenge, then flag). Act on the stricter of this and risk.recommendation.action. On POST /api/device-id a matching block rule answers 403 with status blocked instead of a verdict. |
ruleResults | object[] | Present when any of your rules matched: the rules that matched, each with its ruleId, ruleName and action, and notTested when it counted a pattern condition it could not test as matched. |
requestIdstring- The identification this verdict belongs to.
statusstring- success, or challenge_required when the bot checks asked for a challenge and the visit did not pass it. The browser SDK runs the challenge itself; a visit that passed reads success. Treat challenge_required like the challenge action and ask for another step in your own flow.
deviceIdstring- The device ID, scoped to your account.
risk.stringlevel - low, medium, high or critical.
risk.stringrecommendation. action - allow, monitor, challenge or block: the action from our checks. Neither the risk score block nor your rules change it.
risk.string[]recommendation. reasonCodes - Stable keys for why.
smartSignals.booleanbotDetection. isBot - Whether the bot checks flagged this visit.
smartSignals.booleanaiAgentDetection. detected - Whether the request came from an AI agent, found the way verificationMethod names.
smartSignals.string | nullaiAgentDetection. verificationMethod - How the agent was found. cryptographic: the request carried a Web Bot Auth signature (HTTP Message Signatures, RFC 9421) that verified against the key directory the agent publishes; we load OpenAI's, for its ChatGPT agent, and Google-Agent's. web_bot_auth_failed: the request carried a signature that did not verify, so the agent is not trusted, and signatureError says why, such as invalid_signature, or unknown_keyid for a key no directory we load holds. ip_range: the user agent names an agent that publishes its addresses, and the request came from one of them. declaration, header_pattern and behavioral prove nothing about who sent the request: a header declared an agent, the user agent names one or the headers point to one, or the request and its interaction looked like an agent's. null when no agent was found.
smartSignals.booleanaiAgentDetection. verified - true only when verificationMethod is cryptographic or ip_range.
smartSignals.string | nullaiAgentDetection. agent - The agent's key, such as openai-gpt or google-agent, and unknown_agent when only the request's behavior gave it away. null when no agent was found.
ipBlocklist.booleantor_node - Whether the address is a published Tor exit.
smartSignals.booleanproxyDetection. isProxy - true when a proxy list names the address, when the timing check names a relay, or when the browser's own WebRTC address belongs to another network operator than the connection. Of proxyDetection, the browser's answer carries only this field; the others below come only to your server. false does not prove a direct connection.
smartSignals.boolean | nullproxyDetection. proxyMethods. ip_list - Whether a proxy list names the address. null when the address lookup did not answer.
smartSignals.boolean | nullproxyDetection. proxyMethods. edge_relay - The timing check. Our edge times its own TCP round trip to whatever holds the connection, then times echoes the browser answers. A proxy ends the TCP connection, so the echoes travel an extra leg to the browser and back, which the browser can make longer but not shorter. true when that leg is clearly longer than the connection allows. false when the check judged and named none, which does not rule out a relay that adds less than about 25 ms, or a VPN that tunnels packets without ending the connection. null when it did not judge: a mobile carrier line, a privacy relay such as iCloud Private Relay or Cloudflare WARP, a CDN or anycast network, geostationary satellite internet, an address lookup that did not answer, or no reading (the WebSocket blocked, an older copy of the SDK, an identification sent from your server). null never means a direct connection. While the risk engine runs, a named relay also counts in the proxy estimate as a fact, and risk_signals lists fake_ip.
smartSignals.booleanproxyDetection. proxyMethods. webrtc_leak - Present, and true, only when the browser's own WebRTC address belongs to another network operator than the connection. See vpnDetection.vpnMethods.webrtc_leak below.
smartSignals.number | nullproxyDetection. relayLegMs - The extra leg the timing check measured, in whole milliseconds. null when it did not judge.
smartSignals.string | nullproxyDetection. proxyConfidence - high when a proxy list names the address, medium when only the timing check or WebRTC named a proxy, low when nothing did. null when the address lookup did not answer.
smartSignals.booleanvpnDetection. vpnMethods. webrtc_leak - true when the browser's own WebRTC address belongs to another network operator than the connection: the visit came through a proxy or a VPN, and that address is the line it started from. WebRTC runs over UDP, which a proxy set in the browser does not carry. A privacy relay such as iCloud Private Relay, or another address of the same operator, is not a leak. The browser sends this address, so a browser that keeps WebRTC off shows nothing, and no leak does not mean no proxy.
smartSignals.string | nullrealLocation. trueCountryCode - With a WebRTC leak, the country of the line the visit started from, and realLocation.evidenceClass then reads webrtc. Without one, it comes from the browser's keyboard layout and default voice when both name the same country (evidenceClass 2). realLocation.countryMismatch is true when it differs from the address's country. null when nothing established a true country.
ruleActionstring- Present when one of your matched rules has a block, challenge or flag action: the strictest of them (block, then challenge, then flag). Act on the stricter of this and risk.recommendation.action. On POST /api/device-id a matching block rule answers 403 with status blocked instead of a verdict.
ruleResultsobject[]- Present when any of your rules matched: the rules that matched, each with its ruleId, ruleName and action, and notTested when it counted a pattern condition it could not test as matched.
Risk scores
While the risk engine or account scoring runs, every verdict carries session_id and contract_version; if we switch both off, a verdict carries neither. With the risk engine off, no verdict carries the risk score block. While the risk engine is enforced for your workspace, every verdict also carries the risk score block, on version 1 and 2 of the response contract alike: one estimate from 0 to 1 for each risk dimension, a tier for the session, and the risk signals that fired. While the engine runs in shadow, only version 2 verdicts carry the block, so set Response contract version to 2 under Detection settings in the dashboard to keep the block in your verdicts if the engine returns to shadow. That page says which applies to your workspace now. The block is on the verdict you fetch here and on the answer of POST /api/. The browser's answer carries it only when Send scores to the browser is also on; keep that off unless your page needs the scores, because the browser is where an attacker reads the answer.
{ "session_id": "be259ccdffa6601ad1c8c7a6", "contract_version": 2, "scores": { "bot": 0.999, "device": 0.16, "proxy": 0.03, "vpn": 0.05, "datacenter": 0.02, "tor": 0.002, "tcpip_is_proxy": null, "location_spoofing": 0.03, "impossible_travel": null, "fake_ip": null, "network_risk": 0.03 }, "score_status": { "bot": "evidence", "device": "evidence", "proxy": "clean", "vpn": "clean", "datacenter": "clean", "tor": "clean", "tcpip_is_proxy": "not_measured", "location_spoofing": "clean", "impossible_travel": "not_measured", "fake_ip": "not_measured", "network_risk": "clean" }, "subscores": { "pointer": null, "keyboard": null, "clipboard": 0.02 }, "subscore_status": { "pointer": "not_measured", "keyboard": "not_measured", "clipboard": "clean" }, "risk_probability": 0.94, "risk_tier": "fake", "hard_tell": true, "tier_thresholds": { "suspicious_at": 0.5, "fake_at": 0.75, "source": "default" }, "flag_cutoff": 0.5, "derived_flags": { "is_bot": true, "is_device_risk": false, "is_proxy": false, "is_vpn": false, "is_datacenter": false, "is_tor": false, "is_tcpip_proxy": null, "is_location_spoofing": false, "is_impossible_travel": null, "is_fake_ip": null, "is_network_risk": false }, "risk_signals": ["automation_framework_detected", "gpu_anomaly", "headless_browser", "known_bot_user_agent"], "model": { "schema": "lr-risk-wire/1", "calibrated": false }}| Name | Type | Description |
|---|---|---|
risk_tier | string | real, suspicious, fake or unscored. Real below tier_thresholds.suspicious_at, suspicious from it up to and including fake_at, fake above it. A session where hard_tell is true is never real. unscored when risk_probability is null. |
risk_probability | number | The model's overall risk estimate for the session, from 0 to 1, combining the dimensions in scores. null when nothing could be scored. When the analysis did not finish in time, only the evidence that had arrived is scored, so the estimate is a lower bound of the session's risk, and score_status marks what had not arrived as not_measured. That bound is given, with its tier, when it already reads suspicious or fake or hard_tell is true; otherwise risk_probability is null and the tier is unscored. |
hard_tell | boolean | true when a signal fired that the engine treats as conclusive on its own, such as navigator.webdriver set to true or a headless browser's own user agent; false when none did. null when nothing could be scored, or when the analysis did not finish in time and none had fired among the evidence that had arrived. |
scores | object | One estimate from 0 to 1 for each of bot, device, proxy, vpn, datacenter, tor, tcpip_is_proxy, location_spoofing, impossible_travel, fake_ip and network_risk. network_risk combines the network dimensions. null when there is no usable measurement (score_status says why); it is never filled in with 0. |
score_status | object | For each dimension: evidence (a signal fired), clean (measured, nothing found), not_measured (no data, and the score is null) or invalid (the value received was not a probability, and the score is null). |
subscores | object | The same kind of estimate for the visitor's behaviour on the page: pointer, keyboard and clipboard, null when there is no usable measurement. |
subscore_status | object | The status of each subscore, with the same values as score_status. |
derived_flags | object | One flag per dimension, such as is_bot and is_vpn: true when its score is at or above flag_cutoff, false below it, null when its score is null. |
flag_cutoff | number | The score at or above which a dimension's flag is raised. 0.5 unless you set Risk flag cutoff. |
tier_thresholds | object | suspicious_at and fake_at, with source: default (0.5 and 0.75) or tenant when you set Tier thresholds. A pair that is not usable is not applied: the defaults are, and override_rejected says why. |
risk_signals | string[] | Stable keys for what fired, such as automation_framework_detected or headless_browser. The dashboard explains each one. |
model | object | Which model produced the scores. model.calibrated is false until the scores are calibrated against labelled outcomes. |
risk_tierstring- real, suspicious, fake or unscored. Real below tier_thresholds.suspicious_at, suspicious from it up to and including fake_at, fake above it. A session where hard_tell is true is never real. unscored when risk_probability is null.
risk_probabilitynumber- The model's overall risk estimate for the session, from 0 to 1, combining the dimensions in scores. null when nothing could be scored. When the analysis did not finish in time, only the evidence that had arrived is scored, so the estimate is a lower bound of the session's risk, and score_status marks what had not arrived as not_measured. That bound is given, with its tier, when it already reads suspicious or fake or hard_tell is true; otherwise risk_probability is null and the tier is unscored.
hard_tellboolean- true when a signal fired that the engine treats as conclusive on its own, such as navigator.webdriver set to true or a headless browser's own user agent; false when none did. null when nothing could be scored, or when the analysis did not finish in time and none had fired among the evidence that had arrived.
scoresobject- One estimate from 0 to 1 for each of bot, device, proxy, vpn, datacenter, tor, tcpip_is_proxy, location_spoofing, impossible_travel, fake_ip and network_risk. network_risk combines the network dimensions. null when there is no usable measurement (score_status says why); it is never filled in with 0.
score_statusobject- For each dimension: evidence (a signal fired), clean (measured, nothing found), not_measured (no data, and the score is null) or invalid (the value received was not a probability, and the score is null).
subscoresobject- The same kind of estimate for the visitor's behaviour on the page: pointer, keyboard and clipboard, null when there is no usable measurement.
subscore_statusobject- The status of each subscore, with the same values as score_status.
derived_flagsobject- One flag per dimension, such as is_bot and is_vpn: true when its score is at or above flag_cutoff, false below it, null when its score is null.
flag_cutoffnumber- The score at or above which a dimension's flag is raised. 0.5 unless you set Risk flag cutoff.
tier_thresholdsobject- suspicious_at and fake_at, with source: default (0.5 and 0.75) or tenant when you set Tier thresholds. A pair that is not usable is not applied: the defaults are, and override_rejected says why.
risk_signalsstring[]- Stable keys for what fired, such as automation_framework_detected or headless_browser. The dashboard explains each one.
modelobject- Which model produced the scores. model.calibrated is false until the scores are calibrated against labelled outcomes.
Accounts at sign-up
/api/v2/ sessions/ {sessionId}/ authenticateSecret keyWhen your server creates an account or signs one in, it names the account for the session the browser just identified. The answer is the account's decision, computed at that moment with this session included, and the other accounts it is linked to, so you can act at sign-up rather than on a later read. Take sessionId from the session_id field of the verdict you fetched for the request ID.
curl -X POST https://api.lrdefender.lightningresearch.ai/api/v2/sessions/$SESSION_ID/authenticate \ -H "X-API-Key: $LR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"account": {"id": "user_8812", "email": "jane@example.com", "expected_countries": ["DE"]}}'| Name | Type | Required or optional | Description |
|---|---|---|---|
account.id | string | Required | Your own ID for the account, 1 to 256 characters. |
account. | string | Optional | The account's email address. |
account. | string[] | Optional | ISO 3166 country codes where you expect this account to be used. |
account. | string | Optional | The account's username, at most 256 characters. |
account. | string | Optional | A phone number in international form, starting with +. |
account. | string | Optional | At most 256 characters. |
account. | string | Optional | At most 256 characters. |
account. | string | Optional | Your own ID for the signed-in session, such as your session cookie's ID, at most 512 characters. Used when takeover signals are switched on: the same ID later sent from another device is then a takeover risk. Otherwise listed in ignored. |
account. | string | Optional | Your own ID for the account that referred this one, 1 to 256 characters. Used when identity matching is switched on; otherwise listed in ignored. |
account.idstringRequired- Your own ID for the account, 1 to 256 characters.
account.stringOptionalemail - The account's email address.
account.string[]Optionalexpected_countries - ISO 3166 country codes where you expect this account to be used.
account.stringOptionalusername - The account's username, at most 256 characters.
account.stringOptionalphone - A phone number in international form, starting with +.
account.stringOptionalfirst_name - At most 256 characters.
account.stringOptionallast_name - At most 256 characters.
account.stringOptionalsession_handle - Your own ID for the signed-in session, such as your session cookie's ID, at most 512 characters. Used when takeover signals are switched on: the same ID later sent from another device is then a takeover risk. Otherwise listed in ignored.
account.stringOptionalreferrer_id - Your own ID for the account that referred this one, 1 to 256 characters. Used when identity matching is switched on; otherwise listed in ignored.
We never store these values as you send them. The account ID and the identity fields become keyed hashes, scoped to your account, and the email address is reduced to its domain's type. A field we do not use is listed in ignored.
{ "session_id": "28a7ef783ba48f49b9c27172", "account_key": "a1ea86348f43a7207dc045a2ac6a79ba57ea99e133e1a56b8d308f500cf3a6d3", "status": "rekeyed", "accepted": ["email", "expected_countries"], "ignored": [], "account": { "decision": { "tier": "real", "source": "model", "list": null }, "account_risk_score": 0.08, "multi_accounting_probability": 0, "accounts_linked": 0, "risk_signals": [], "email": { "domain_type": "other", "num_accounts_from_domain": 1 }, "num_sessions": 1 }, "account_status": null, "linked_accounts": []}| Name | Type | Description |
|---|---|---|
status | string | rekeyed the first time, already when the same account was named before. |
account. | string | real, suspicious, fake or unscored. |
account. | number | 0 to 1. How strongly this account is linked to other accounts. |
account. | string[] | Stable keys for why, for example multi_accounting_linked. |
linked_accounts | object[] | The other accounts it is linked to, each with how, for example device or network. |
account_status | string | Null when account is present. When account scoring is not on for your account, account is null and this says why. |
statusstring- rekeyed the first time, already when the same account was named before.
account.stringdecision.tier - real, suspicious, fake or unscored.
account.numbermulti_accounting_probability - 0 to 1. How strongly this account is linked to other accounts.
account.string[]risk_signals - Stable keys for why, for example multi_accounting_linked.
linked_accountsobject[]- The other accounts it is linked to, each with how, for example device or network.
account_statusstring- Null when account is present. When account scoring is not on for your account, account is null and this says why.
A session can belong to one account: naming another account for it answers 409 with session_already_authenticated. An unknown session answers 404 with session_not_found. A secret key sent from a browser is refused with 403 and secret_key_required.
Failed sign-ins
/api/v2/ sessions/ {sessionId}/ login-failedSecret keyWhen a sign-in on a session fails, send {"account": {"id": "user_8812"}} with the account that was tried. We count, for the last hour and day, how many accounts that device failed on and how many devices failed on that account. One device failing on many accounts is how credential stuffing looks, and a later successful sign-in from that device carries it as a takeover risk. The answer is {"session_id": ..., with the counts added once takeover signals are switched on.
Reading accounts and sessions
The sign-up answer is the account at that moment. Later, your server can read the account again, add what it learns about it, and read any session. Like the sign-up call, these calls take your secret key, sent from your server: a missing or unknown key, or a publishable key, answers 401, and a secret key sent from a browser answers 403 with secret_key_required.
An account
/api/v2/ accounts/ {accountKey}Secret keyReturns the account as it stands now. Take accountKey from the account_key field of the sign-up answer and keep it with your own user record: your account ID is kept only as a keyed hash, so the key is how you find the account again. The account is rescored in the background after each new session or attribute change, and a daily pass refreshes accounts with sessions in the last 90 days, so a read can be newer than the sign-up answer. aggregate_as_of says when it was scored.
curl https://api.lrdefender.lightningresearch.ai/api/v2/accounts/$ACCOUNT_KEY \ -H "X-API-Key: $LR_SECRET_KEY"{ "range": null, "tier_thresholds": { "suspicious_at": 0.5, "fake_at": 0.75, "source": "default" }, "flag_cutoff": 0.5, "calibration": { "model_version": "0" }, "empty_reason": null, "backfill_possible": false, "attributed_since": null, "account": { "account_key": "7027e61881042c9e8df094c83dee53e79952b062fe6d736c648d7bea71a6ff6c", "key_kind": "client", "key_basis": "linked_id", "tier": "fake", "decision": { "tier": "fake", "source": "model", "list": null }, "account_risk_score": 0.95, "general_risk_probability": 0.16, "multi_accounting_probability": 0.125, "scores": { "bot": 0.999, "device": 0.16, "proxy": 0.03, "vpn": 0.05, "datacenter": 0.02, "tor": 0.002, "tcpip_is_proxy": null, "location_spoofing": 0.03, "impossible_travel": null, "fake_ip": null, "network_risk": 0.03 }, "derived_flags": { "is_bot": true, "is_device_risk": false, "is_proxy": false, "is_vpn": false, "is_datacenter": false, "is_tor": false, "is_tcpip_proxy": null, "is_location_spoofing": false, "is_impossible_travel": null, "is_fake_ip": null, "is_network_risk": false, "is_multi_accounting": false }, "hard_tell_seen": true, "accounts_linked": 1, "cluster": null, "num_sessions": 1, "num_countries": 1, "countries": ["FR"], "expected_countries": ["AT", "DE"], "unique_device_count_7": 1, "unique_network_count_7": 1, "first_seen": "2026-10-06T06:32:25.150Z", "last_seen": "2026-10-06T06:32:25.150Z", "email": { "risk_score": null, "domain_type": "other", "num_accounts_from_domain": 1, "calibration_status": "uncalibrated" }, "trust_signals": [], "risk_signals": [ "automation_framework_detected", "gpu_anomaly", "headless_browser", "known_bot_user_agent", "pointer_anomaly", "multi_accounting_linked", "country_outside_expected" ], "lists": [], "rules": null, "tier_thresholds": { "suspicious_at": 0.5, "fake_at": 0.75, "source": "default" }, "flag_cutoff": 0.5, "calibration": { "model_version": "lr-reference-fit/1" }, "aggregate_as_of": "2026-10-06T06:34:06.248Z" }}| Name | Type | Description |
|---|---|---|
account_key | string | 64 hexadecimal characters: a keyed hash of your account ID, or of the device for a device account (see key_kind). |
key_kind | string | client when your server named the account, device when the account stands for a device no account was named for. |
key_basis | string | What the key was made from: linked_id, tag_user_id or metadata_user_id for a client account, device_stable_hash for a device account. |
tier | string | The model's tier: real, suspicious, fake or unscored, read from account_risk_score and tier_thresholds by the same rule as a session's risk_tier. An account where hard_tell_seen is true is never real. unscored when account_risk_score is null. |
decision | object | The tier to act on, with its source: model, allow_list or block_list. A block list makes the account fake and an allow list makes it real, a block list winning over an allow list; a review list changes nothing. list names the deciding list, null when the source is model. |
account_risk_score | number | The account's overall risk estimate, from 0 to 1, combining general_risk_probability, the bot score and multi_accounting_probability, then lowered by trust_signals. null when nothing about the account could be scored. |
general_risk_probability | number | The risk from the account's device, network and travel scores, from 0 to 1. |
multi_accounting_probability | number | 0 to 1. How strongly this account is linked to other accounts through the devices and networks they share. null when its links could not be read. |
scores | object | The account's score in each dimension a session is scored in: the strongest evidence among its sessions in the last 90 days, older sessions counting for less. null when no session measured the dimension. |
derived_flags | object | One flag per dimension, as for a session, plus is_multi_accounting: true when the score is at or above flag_cutoff, false below it, null when the score is null. |
hard_tell_seen | boolean | true once any of the account's sessions carried a hard tell. It stays true, and the account then never reads real. |
accounts_linked | number | How many linked accounts multi_accounting_probability counted. null when its links could not be read. |
cluster | object | {id, size}: the group of client accounts joined to this one by shared devices, recomputed daily. null when there is none. |
num_sessions | number | Its sessions in the last 90 days. |
num_countries | number | How many countries are in countries. |
countries | string[] | The countries of its sessions in the last 90 days, by address and by the device's own evidence (ISO 3166 alpha-2 codes). |
expected_countries | string[] | The countries you said the account is used in, at sign-up or later. |
unique_device_count_7 | number | How many devices its sessions used in the last 7 days. null when one of those sessions could not be tied to a device. |
unique_network_count_7 | number | How many networks its sessions used in the last 7 days. null when one of those sessions could not be tied to a network. |
first_seen | string | When its first session happened. |
last_seen | string | When its latest session happened. |
email | object | Once you sent an email address: domain_type (free, disposable or other), num_accounts_from_domain (your accounts on the same domain) and risk_score, which stays null because no email model is calibrated, as calibration_status says. null before any email was sent. |
trust_signals | string[] | History in the account's favour, such as long_lived_account or consistent_country. Together they lower account_risk_score by at most 0.25, and not at all once hard_tell_seen is true. |
risk_signals | string[] | Stable keys for what fired on the account's sessions in the last 30 days, then the account's own, such as multi_accounting_linked or country_outside_expected. |
lists | object[] | The account lists it is on, each {list_id, name, action} with action allow, block or review. null when list membership could not be read. |
rules | string[] | Always null: rule matches are not recorded on the account. Your rules can read the account on each identification (the account your server named on a server call, the device's own account on a browser identification); the rules that matched are in that identification's ruleResults. |
tier_thresholds | object | As in the risk score block. |
flag_cutoff | number | As in the risk score block. |
calibration | object | status and model_version of the model that scored the account. status is calibrated only once the scores are calibrated against labelled outcomes, and one of two other values until then. |
aggregate_as_of | string | When the account was last scored. |
account_keystring- 64 hexadecimal characters: a keyed hash of your account ID, or of the device for a device account (see key_kind).
key_kindstring- client when your server named the account, device when the account stands for a device no account was named for.
key_basisstring- What the key was made from: linked_id, tag_user_id or metadata_user_id for a client account, device_stable_hash for a device account.
tierstring- The model's tier: real, suspicious, fake or unscored, read from account_risk_score and tier_thresholds by the same rule as a session's risk_tier. An account where hard_tell_seen is true is never real. unscored when account_risk_score is null.
decisionobject- The tier to act on, with its source: model, allow_list or block_list. A block list makes the account fake and an allow list makes it real, a block list winning over an allow list; a review list changes nothing. list names the deciding list, null when the source is model.
account_risk_scorenumber- The account's overall risk estimate, from 0 to 1, combining general_risk_probability, the bot score and multi_accounting_probability, then lowered by trust_signals. null when nothing about the account could be scored.
general_risk_probabilitynumber- The risk from the account's device, network and travel scores, from 0 to 1.
multi_accounting_probabilitynumber- 0 to 1. How strongly this account is linked to other accounts through the devices and networks they share. null when its links could not be read.
scoresobject- The account's score in each dimension a session is scored in: the strongest evidence among its sessions in the last 90 days, older sessions counting for less. null when no session measured the dimension.
derived_flagsobject- One flag per dimension, as for a session, plus is_multi_accounting: true when the score is at or above flag_cutoff, false below it, null when the score is null.
hard_tell_seenboolean- true once any of the account's sessions carried a hard tell. It stays true, and the account then never reads real.
accounts_linkednumber- How many linked accounts multi_accounting_probability counted. null when its links could not be read.
clusterobject- {id, size}: the group of client accounts joined to this one by shared devices, recomputed daily. null when there is none.
num_sessionsnumber- Its sessions in the last 90 days.
num_countriesnumber- How many countries are in countries.
countriesstring[]- The countries of its sessions in the last 90 days, by address and by the device's own evidence (ISO 3166 alpha-2 codes).
expected_countriesstring[]- The countries you said the account is used in, at sign-up or later.
unique_device_count_7number- How many devices its sessions used in the last 7 days. null when one of those sessions could not be tied to a device.
unique_network_count_7number- How many networks its sessions used in the last 7 days. null when one of those sessions could not be tied to a network.
first_seenstring- When its first session happened.
last_seenstring- When its latest session happened.
emailobject- Once you sent an email address: domain_type (free, disposable or other), num_accounts_from_domain (your accounts on the same domain) and risk_score, which stays null because no email model is calibrated, as calibration_status says. null before any email was sent.
trust_signalsstring[]- History in the account's favour, such as long_lived_account or consistent_country. Together they lower account_risk_score by at most 0.25, and not at all once hard_tell_seen is true.
risk_signalsstring[]- Stable keys for what fired on the account's sessions in the last 30 days, then the account's own, such as multi_accounting_linked or country_outside_expected.
listsobject[]- The account lists it is on, each {list_id, name, action} with action allow, block or review. null when list membership could not be read.
rulesstring[]- Always null: rule matches are not recorded on the account. Your rules can read the account on each identification (the account your server named on a server call, the device's own account on a browser identification); the rules that matched are in that identification's ruleResults.
tier_thresholdsobject- As in the risk score block.
flag_cutoffnumber- As in the risk score block.
calibrationobject- status and model_version of the model that scored the account. status is calibrated only once the scores are calibrated against labelled outcomes, and one of two other values until then.
aggregate_as_ofstring- When the account was last scored.
The account also carries a second copy of account_risk_score under an older name, which the dashboard still reads; read account_risk_score. Beside account, every account and session read carries tier_thresholds and flag_cutoff, calibration for the model in force now, and empty_reason, null when the answer has its account or session. range and attributed_since are null and backfill_possible is false on these reads; they are there for the dashboard's lists.
A key that is not 64 lowercase hexadecimal characters answers 400, and a key the API does not hold answers 404 with not_found. When account scoring is not on for your account, the answer is 200 with account null and empty_reason saying why: account_layer_off, or account_layer_shadow while accounts are recorded but not yet served. history_table_unavailable there means the account tables are not set up yet.
What you learn later
/api/v2/ accounts/ {accountKey}/ attributesSecret keyAdds what your server learns about an account after sign-up. The body takes email and expected_countries, at most 50 ISO 3166 alpha-2 codes; username, phone, first_name and last_name are taken too when identity matching is switched on. The answer is 202 with account_key and accepted, the fields that were taken, and the account is rescored in the background. An identity field that could not be kept is named in ignored instead of accepted; ignored is left out when everything was kept. As at sign-up, nothing is stored as you send it: the identity fields become keyed hashes, scoped to your account, and the email address is reduced to its domain's type.
curl -X POST https://api.lrdefender.lightningresearch.ai/api/v2/accounts/$ACCOUNT_KEY/attributes \ -H "X-API-Key: $LR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"email": "jane@example.com", "expected_countries": ["DE", "AT"]}'A body that gives none of the fields taken, or an email address or country list that is not valid, answers 400, with field saying which. A key the API does not hold answers 404 with not_found, after a short wait for a sign-up that is still being written. While account scoring is off, the call answers 409 with account_layer_off and stores nothing.
A session
/api/v2/ sessions/ {sessionId}Secret keyReturns one session as it was scored: its network context, its scores and the evidence behind them. Take sessionId from the session_id field of a verdict. Sessions are kept for 90 days; after that, or for an ID your account does not hold, the answer is 404 with not_found. A session is stored just after its verdict is sent, so a read made at that same moment can answer 404 too; read it again a few seconds later. An ID that is not 24 lowercase hexadecimal characters answers 400, and while account scoring is not on, the answer is 200 with session null and empty_reason saying why, as for an account.
The score fields scores, score_status, subscores, subscore_status, risk_probability, risk_tier, hard_tell, derived_flags, risk_signals, tier_thresholds and flag_cutoff are those of the risk score block, described under Risk scores. The table gives the rest.
curl https://api.lrdefender.lightningresearch.ai/api/v2/sessions/$SESSION_ID \ -H "X-API-Key: $LR_SECRET_KEY"{ "range": null, "tier_thresholds": { "suspicious_at": 0.5, "fake_at": 0.75, "source": "default" }, "flag_cutoff": 0.5, "calibration": { "model_version": "0" }, "empty_reason": null, "backfill_possible": false, "attributed_since": null, "session": { "session_id": "8eea1b3caecfeda7679c0d95", "created_at": "2026-10-06T06:32:25.150Z", "account_key": "7027e61881042c9e8df094c83dee53e79952b062fe6d736c648d7bea71a6ff6c", "key_kind": "client", "device_id": "d62b29a5dbfddacc256ac9d1", "ip": "203.0.113.0/24", "network_key": "5f3a9c1e7b2d4f80", "country_code": "FR", "true_country_code": null, "city": null, "region": null, "connection_type": "isp", "asn": { "number": 64500, "org": "Example ISP" }, "browser": "Headless Chrome", "os": "Linux", "match_type": "alias", "confidence": null, "scores": { "bot": 0.999, "device": 0.16, "proxy": 0.03, "vpn": 0.05, "datacenter": 0.02, "tor": 0.002, "tcpip_is_proxy": null, "location_spoofing": 0.03, "impossible_travel": null, "fake_ip": null, "network_risk": 0.03 }, "score_status": { "bot": "evidence", "device": "evidence", "proxy": "clean", "vpn": "clean", "datacenter": "clean", "tor": "clean", "tcpip_is_proxy": "not_measured", "location_spoofing": "clean", "impossible_travel": "not_measured", "fake_ip": "not_measured", "network_risk": "clean" }, "subscores": { "pointer": null, "keyboard": null, "clipboard": 0.02 }, "subscore_status": { "pointer": "not_measured", "keyboard": "not_measured", "clipboard": "clean" }, "risk_probability": 0.94, "risk_tier": "fake", "hard_tell": true, "derived_flags": { "is_bot": true, "is_device_risk": false, "is_proxy": false, "is_vpn": false, "is_datacenter": false, "is_tor": false, "is_tcpip_proxy": null, "is_location_spoofing": false, "is_impossible_travel": null, "is_fake_ip": null, "is_network_risk": false }, "risk_signals": ["automation_framework_detected", "gpu_anomaly", "headless_browser", "known_bot_user_agent", "pointer_anomaly"], "rule_hits": null, "list_hits": [], "tier_thresholds": { "suspicious_at": 0.5, "fake_at": 0.75, "source": "default" }, "flag_cutoff": 0.5, "model": { "version": "lr-reference-fit/1" }, "contributions": [ { "basis": "log_odds", "dimension": "bot", "signal": "bot.known_bot_ua", "family": "ua_automation_token", "source": "botDefenseService", "grade": "fact", "hard": true, "flag": "known_bot_user_agent", "lr": 4851, "log_odds": 8.49, "share": 0.33, "marginal": 0, "shadowed_by": null }, { "basis": "log_odds", "dimension": "bot", "signal": "bot.ua_headless", "family": "ua_automation_token", "source": "botDefenseService", "grade": "fact", "hard": true, "flag": "headless_browser", "lr": 4851, "log_odds": 0, "share": 0, "marginal": 0, "shadowed_by": "bot.known_bot_ua" } ] }}| Name | Type | Description |
|---|---|---|
session_id | string | The session's ID, as in the verdict. |
created_at | string | When the identification happened. |
account_key | string | The account the session belongs to. |
key_kind | string | client or device, as for the account. |
device_id | string | The device ID, as in the verdict. |
ip | string | The network the visitor came from: the /24 of an IPv4 address or the /48 of an IPv6 address. The full address is not kept. |
network_key | string | A keyed hash of the full address, scoped to your account: two sessions with the same network_key came from one address. |
country_code | string | The country of the address. |
true_country_code | string | The country the device's own evidence points to, when that evidence is strong enough; null otherwise. |
city | string | The city of the address, when known. |
region | string | The region of the address, when known. |
connection_type | string | isp, mobile, hosting, business, education, government, cdn or unknown. |
asn | object | The address's network, {number, org}. |
browser | string | The browser, as the session reported it. |
os | string | The operating system, as the session reported it. |
match_type | string | How the device was recognised, for example new for a device seen for the first time. |
confidence | number | How sure the device match is, from 0 to 1; null when not reported. |
rule_hits | string[] | Not recorded on sessions yet, so always null, whether or not your rules ran. The rules that matched an identification are in its ruleResults: in the answer to POST /api/device-id, and from GET /api/device-id/{requestId}. |
list_hits | object[] | Your lists that matched when the session was scored, each {list_id, name, action}; [] when checked and none matched, null when not checked. |
model | object | The model that scored the session: version and calibration_status. |
contributions | object[] | The evidence behind the scores, as computed when the session was scored: one entry per signal that fired, with its dimension, signal, flag, grade (fact, inference or model), lr (its likelihood ratio), log_odds (what it added to its dimension, 0 when shadowed_by names a stronger signal of the same family), share (its part of the dimension's total) and marginal (how much the dimension would drop without it). |
session_idstring- The session's ID, as in the verdict.
created_atstring- When the identification happened.
account_keystring- The account the session belongs to.
key_kindstring- client or device, as for the account.
device_idstring- The device ID, as in the verdict.
ipstring- The network the visitor came from: the /24 of an IPv4 address or the /48 of an IPv6 address. The full address is not kept.
network_keystring- A keyed hash of the full address, scoped to your account: two sessions with the same network_key came from one address.
country_codestring- The country of the address.
true_country_codestring- The country the device's own evidence points to, when that evidence is strong enough; null otherwise.
citystring- The city of the address, when known.
regionstring- The region of the address, when known.
connection_typestring- isp, mobile, hosting, business, education, government, cdn or unknown.
asnobject- The address's network, {number, org}.
browserstring- The browser, as the session reported it.
osstring- The operating system, as the session reported it.
match_typestring- How the device was recognised, for example new for a device seen for the first time.
confidencenumber- How sure the device match is, from 0 to 1; null when not reported.
rule_hitsstring[]- Not recorded on sessions yet, so always null, whether or not your rules ran. The rules that matched an identification are in its ruleResults: in the answer to POST /api/device-id, and from GET /api/device-id/{requestId}.
list_hitsobject[]- Your lists that matched when the session was scored, each {list_id, name, action}; [] when checked and none matched, null when not checked.
modelobject- The model that scored the session: version and calibration_status.
contributionsobject[]- The evidence behind the scores, as computed when the session was scored: one entry per signal that fired, with its dimension, signal, flag, grade (fact, inference or model), lr (its likelihood ratio), log_odds (what it added to its dimension, 0 when shadowed_by names a stronger signal of the same family), share (its part of the dimension's total) and marginal (how much the dimension would drop without it).
Identifying from your server
In the usual flow the browser sends its identification to us directly, and we see the visitor's own address. If your server forwards identifications to POST /api/ itself, we see your server's address instead. Send the visitor's address in the body as end_user_ip, an IPv4 or IPv6 address.
// Only when YOUR server sends an identification to POST /api/device-id itself.await fetch("https://api.lrdefender.lightningresearch.ai/api/device-id", { method: "POST", headers: { "X-API-Key": process.env.LR_SECRET_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ ...payloadFromTheBrowser, end_user_ip: visitorAddress, // the address your server saw the visitor connect from }),});Without end_user_ip, the network checks for that call run on your server's address, so the network fields in the answer (smartSignals., smartSignals. and ipBlocklist) describe your server, not your visitor. The stored session records its network checks as not measured, and it links to no other account by network: your server's address is never treated as your visitors' address. We read the field only from a call made with your secret key from a server. A call relayed from a browser cannot set it.
Device lookups
/api/ device-recall/ {deviceId}Secret keyLooks up a device your account has seen, by its 24-character device ID. It returns status (ok), deviceId, confidence, matchType, firstSeen and lastSeen, or 404 with the status not_found for a device your account has not seen.
Network lookups
Network lookups use the IP data API and its own key class (ck_). You can send addresses in a request body, singly or in batches, so they never appear in a URL:
/geo/ batchIP data keycurl -X POST https://api.lrdefender.lightningresearch.ai/geo/batch \ -H "X-API-Key: $LR_IP_DATA_KEY" \ -H "Content-Type: application/json" \ -d '{"ips": ["203.0.113.7", "198.51.100.23"]}'| Name | Type | Required or optional | Description |
|---|---|---|---|
ips | string[] | Required | The addresses to look up, at most 500 in one call. |
ipsstring[]Required- The addresses to look up, at most 500 in one call.
Each result in a batch carries a risk object with the verdicts for the address (is_vpn, is_proxy, is_datacenter and is_tor) and a decision with the recommended action. A batch answers within a time budget. When complete is false, remaining_count says how many addresses have no result yet; send those again.
For one address in a URL, three response shapes are available, and only one carries verdicts by default:
/geo/full/{ip}is the complete answer. Itsproxyobject carries the verdicts:is_vpn,is_public_proxy,is_data_centerandis_tor./geo/compact/{ip}(the ip-api.com field names) and/geo/standard/{ip}(the ipwho.is shape) keep that provider's fields, and none of them is a verdict. To get one, listlr_riskin thefieldsparameter beside the fields you read: the answer gains anlr_riskobject with the same verdicts as a batch result'srisk.
curl https://api.lrdefender.lightningresearch.ai/geo/full/203.0.113.7 \ -H "X-API-Key: $LR_IP_DATA_KEY"Each address you look up counts as one request toward your plan's daily limit (see Errors and limits). The IP data API page covers switching from another provider.
Errors and limits
- 400
- The request is invalid, for example a session without an origin.
- 401
- The key is missing or invalid, or it is the wrong class for this call.
- 403
- The call is not allowed: an origin your account does not allow, an inactive license, or a secret key sent from a browser. On POST /api/device-id, also when one of your rules blocked the identification (status blocked; ruleResults lists the rules).
- 404
- The request ID, session or device is unknown to your account, or the result has expired.
- 409
- The session already belongs to another account, or account scoring is not on for your account.
- 429
- Too many requests in a short time, or your plan's daily limit is reached (code
plan_limit_exceeded). A daily limit resets at midnight UTC. - 5xx
- A failure on our side. Retry with a delay, and check the status page.
Error bodies are JSON with an error message and, for most errors, a machine-readable code. Live health is on the status page.
Daily limits
Each plan also has daily limits on API requests and on new devices, counted per UTC day. Each identification counts as one request, and each address looked up on the IP data API as one. Over a limit, every call to the API for your account is refused with HTTP 429 until midnight UTC: new sessions, verdict fetches, IP data lookups and your dashboard. To upgrade while your account is over a limit, email sales@lightningresearch.ai.
API requests a day by plan: Free trial: 3,500 a day, Starter: 1,700 a day, Pro: 3,500 a day, Enterprise: by contract. New devices a day by plan: Free trial: 3,000 a day, Starter: 500 a day, Pro: 3,000 a day, Enterprise: by contract. Over a limit, calls under /api answer 429 with the code plan_limit_exceeded, so your server can tell a daily limit apart from a short burst. Plans and prices are on the pricing page.