NULLR API Documentation
NULLR is a three-layer bot and threat detection service. This document covers the REST API used by the detection snippet and by server-side integrations.
https://api.nullr.ioHow it works
The detection snippet is loaded asynchronously on your page. It collects a JS fingerprint and sends it to /v1/detect. The API runs all three detection layers server-side and returns a verdict. Approved visitors receive a session token stored in sessionStorage — subsequent page views use /v1/validate for instant approval without re-running full detection.
Three detection layers
- Layer 0 — Server-side instant (23 signals): UA parsing, bot keyword matching (590+ patterns), header analysis, AI crawler detection (16 companies), challenge mode. Runs in <1ms with zero external calls.
- Layer 1 — IP intelligence (22 signals): Cross-references 41 CIDR threat feeds (AWS, GCP, Azure, Cloudflare, Tor, Spamhaus) and live lookups from ipinfo.io, ip-api.com, and AbuseIPDB. Self-learning reputation DB caches results.
- Layer 2 — JS fingerprint (43 signals): Browser consistency, headless detection, VNC/RDP, VM environments, AI agent behavioral analysis, canvas/WebGL fingerprinting. Snippet is obfuscated per-request.
Authentication
Pass your API key in the X-Nullr-Key header on every request. API keys are created in the user panel and scoped to a single domain.
X-Nullr-Key: nlr_live_your_key_here
The API validates the Origin and Referer headers to enforce domain binding. Requests from a domain that doesn't match the key's registered domain are rejected with 403.
Key format
API keys use the format nlr_live_ followed by 32 alphanumeric characters. Test keys use nlr_test_ and work in development mode only.
Installation
Add the detection snippet to every page you want protected. The snippet URL is unique per key and the JS payload is obfuscated with randomised variable names per request — never cache it.
HTML (any static site)
Paste before </head>:
<script>
(function(){
var s = document.createElement('script');
s.src = 'https://api.nullr.io/v1/snippet?k=nlr_live_YOUR_KEY';
s.async = true;
document.head.appendChild(s);
})();
</script>
WordPress
Add to your theme's functions.php or use a code snippets plugin:
add_action('wp_head', function() {
echo '<script>(function(){var s=document.createElement("script");'
. 's.src="https://api.nullr.io/v1/snippet?k=nlr_live_YOUR_KEY";'
. 's.async=true;document.head.appendChild(s)})();</script>';
});
React / Next.js
Add to your root layout (_app.js, layout.tsx, or a top-level component):
import { useEffect } from 'react';
export default function RootLayout({ children }) {
useEffect(() => {
const s = document.createElement('script');
s.src = 'https://api.nullr.io/v1/snippet?k=nlr_live_YOUR_KEY';
s.async = true;
document.head.appendChild(s);
return () => { s.remove(); };
}, []);
return <div>{children}</div>;
}
Next.js (Script component)
Using the built-in Next.js Script component:
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<head>
<Script
src="https://api.nullr.io/v1/snippet?k=nlr_live_YOUR_KEY"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
);
}
Shopify
Edit theme.liquid (Online Store → Themes → Edit code) and paste before </head>:
<script>
(function(){
var s = document.createElement('script');
s.src = 'https://api.nullr.io/v1/snippet?k=nlr_live_YOUR_KEY';
s.async = true;
document.head.appendChild(s);
})();
</script>
Server-side validation (optional)
For extra security, validate the session token server-side on sensitive routes. The snippet stores a token in a cookie named _nlr_t_KEYID. Send it to /v1/validate to confirm.
POST /v1/detect
Primary detection endpoint. Called by the snippet with the collected JS fingerprint. Returns a verdict and — if approved — a session token and visitor intelligence object.
Request body
| Field | Type | Description |
|---|---|---|
| fpoptional | object | JS fingerprint payload collected by the snippet. If omitted, only L0 and L1 run. |
| fp.stable_hash | string | Deterministic hash of stable fingerprint components. |
| fp.webdriver | boolean | navigator.webdriver value. |
| fp.automation_objects | string[] | Automation window properties found. |
| fp.is_headless | boolean | Headless Chrome signals detected. |
| fp.canvas_noise_detected | boolean | Canvas renders differ between calls. |
| fp.screen_width / screen_height | number | Physical screen dimensions. |
| fp.cores / fp.memory | number | hardwareConcurrency / deviceMemory. |
| powoptional | object | PoW solution. Required when DDoS level is elevated or under_attack. |
| pow.nonce | string | Nonce that satisfies the PoW challenge. |
| pow.prefix | string | The prefix provided by the server. |
Response — allow
{
"verdict": "allow",
"token": "abc123...",
"visitor": {
"country_code": "DE",
"city": "Berlin",
"is_vpn": false,
"browser": "Chrome",
...
}
}
Response — challenge
{
"verdict": "challenge",
"challenge": {
"prefix": "nlr_1700000000_abc",
"difficulty": 18,
"expires": 1700000060000
}
}
Response — block
{
"verdict": "block",
"response": "404",
"is_ai_crawler": false
}
POST /v1/validate
Fast token validation for subsequent page views. If the token is valid, no detection runs — response is instant.
Request body
{ "token": "abc123..." }
Response
{ "valid": true } // or { "valid": false } — re-run /detect
GET /v1/snippet
Returns the obfuscated detection snippet as application/javascript. Variable names and string encoding change per request. Do not cache.
Query parameters
| Param | Type | Description |
|---|---|---|
| krequired | string | Your API key (nlr_live_xxx or nlr_test_xxx). |
Visitor object
Returned on every approved visitor. Fields marked † are server-side only (available in webhooks but not in window.NullrVisitor).
| Field | Type | Description |
|---|---|---|
| country_code | string | ISO 3166-1 alpha-2 (e.g. "DE") |
| country | string | Country name |
| country_flag | string | Flag emoji |
| city | string | City name |
| region | string | Region/state |
| timezone | string | IANA timezone (e.g. "Europe/Berlin") |
| currency_code | string | ISO 4217 currency code |
| currency_symbol | string | Currency symbol (e.g. "€") |
| calling_code | string | Phone calling code (e.g. "+49") |
| is_eu | boolean | EU member state |
| is_vpn | boolean | VPN detected |
| is_proxy | boolean | Proxy detected |
| is_tor | boolean | Tor exit node |
| is_mobile | boolean | Mobile device |
| device | string | "desktop" | "mobile" | "tablet" |
| os / os_version | string | Operating system and version |
| browser / browser_version | string | Browser name and version |
| language | string | Browser language (e.g. "fr") |
| screen_width / screen_height | number | Screen dimensions (px) |
| cores | number | CPU core count |
| memory | number | Device memory (GB) |
| connection_type | string | "wifi" | "cellular" | "ethernet" |
| touch_points | number | Touch points (0 = desktop) |
| stable_hash | string | Stable fingerprint hash |
| ip † | string | Client IP address |
| isp † | string | Internet service provider |
| org † | string | Organization (ASN owner) |
| asn † | string | Autonomous system number |
| abuse_score † | number | AbuseIPDB confidence (0–100) |
Use cases
- Geo-personalisation: localise prices by
currency_code, redirect bycountry_code - Analytics enrichment: send
device,os,country_codeto GA4 or Mixpanel - Content gating: show/hide content based on
is_vpnoris_eu - Fraud detection: flag
is_proxyoris_toron checkout pages
Signal keys
NULLR uses 88 detection signals across 3 layers. Each signal has a key, default score (0–1000), and default action. Scores accumulate — when the total exceeds the block threshold (default 500), the visitor is blocked.
| Signal key | Score | Action | User label |
|---|---|---|---|
| Layer 0 — Server-side Instant (23 signals) | |||
| user_blacklist_ip | 1000 | instant_block | IP blacklisted |
| user_blacklist_fingerprint | 1000 | instant_block | Device blacklisted |
| ua_empty | 900 | instant_block | No browser identity |
| ua_bot_keyword | 900 | instant_block | Known bot pattern |
| bad_http_method | 800 | instant_block | Suspicious request method |
| challenge_failed | 800 | instant_block | Security check failed |
| suspicious_path | 800 | instant_block | Suspicious URL path |
| ua_too_short | 700 | instant_block | Incomplete browser identity |
| missing_headers | 700 | instant_block | Missing browser headers |
| org_keyword_blocked | 600 | score_only | Organization blocked |
| challenge_missing | 600 | score_only | Security check not completed |
| ie_user_agent | 500 | instant_block | Outdated browser (IE) |
| rate_limit_signal | 500 | score_only | Too many requests |
| sec_ch_ua_mismatch | 450 | score_only | Browser version mismatch |
| browser_no_html_accept | 400 | instant_block | Non-browser request |
| accept_wildcard_only | 400 | score_only | Generic request headers |
| sec_fetch_missing | 300 | score_only | Missing security headers |
| old_browser_version | 300 | score_only | Outdated browser version |
| round_chrome_version | 300 | score_only | Suspicious browser version |
| connection_close_header | 200 | score_only | Automated connection pattern |
| incognito | 200 | score_only | Private browsing |
| ai_crawler | 0 | instant_block | AI crawler |
| user_whitelist_ip | 0 | bypass | IP whitelisted |
| Layer 1 — IP Intelligence (22 signals) | |||
| ip_abuse_critical | 900 | instant_block | Known malicious IP |
| country_blocked | 900 | instant_block | Country blocked |
| continent_blocked | 900 | instant_block | Region blocked |
| ip_tor | 800 | instant_block | Tor network |
| ip_cidr_match | 800 | instant_block | Blocklisted IP range |
| ip_known_bot | 800 | instant_block | Known bot IP |
| impossible_travel | 800 | score_only | Impossible location change |
| ip_abuse_high | 700 | instant_block | Reported IP |
| os_blocked | 700 | instant_block | OS blocked |
| browser_blocked | 700 | instant_block | Browser blocked |
| device_blocked | 700 | instant_block | Device type blocked |
| isp_blocked | 700 | instant_block | ISP blocked |
| org_blocked | 700 | instant_block | Organization blocked |
| ip_intelligence_fail | 600 | score_only | IP lookup unavailable |
| internal_reputation | 600 | score_only | Poor reputation history |
| hostname_blocked | 600 | score_only | Hostname blocked |
| ip_proxy | 500 | instant_block | Proxy connection |
| country_switching | 500 | score_only | Multiple countries detected |
| language_blocked | 500 | score_only | Language blocked |
| ip_hosting | 400 | score_only | Datacenter IP |
| ip_rotation | 400 | score_only | IP address rotation |
| ip_vpn | 300 | score_only | VPN connection |
| Layer 2 — JS Fingerprint (43 signals) | |||
| hacking_tool | 900 | instant_block | Analysis tool detected |
| headless_chrome | 900 | instant_block | Automated browser |
| rdp_remote_tool | 900 | instant_block | Remote desktop tool |
| automation_object | 850 | instant_block | Automation framework |
| emulator_detected | 800 | instant_block | Device emulator |
| vnc_detected | 750 | instant_block | Remote viewer detected |
| vm_environment | 700 | instant_block | Virtual machine |
| rdp_detected | 700 | score_only | Remote desktop session |
| mobile_no_sensors | 700 | instant_block | Fake mobile device |
| behavioral_bot | 700 | instant_block | No human interaction |
| ai_agent_behavior | 600 | score_only | AI agent pattern |
| canvas_noise | 600 | instant_block | Fingerprint manipulation |
| css_dpr_mismatch | 600 | instant_block | Display spoofing |
| ua_mismatch | 600 | score_only | Browser identity mismatch |
| webgl_gpu_spoof | 500 | instant_block | GPU spoofing |
| ua_data_mismatch | 500 | instant_block | Browser data inconsistency |
| sandbox_timing | 500 | score_only | Analysis sandbox |
| webgl_params_mismatch | 500 | score_only | GPU parameter mismatch |
| battery_stub | 500 | score_only | Virtual battery |
| screen_too_large | 500 | score_only | Impossible screen size |
| rdp_cleartype | 450 | score_only | Remote rendering |
| plugin_count_zero | 400 | score_only | No browser plugins |
| devtools_open | 400 | score_only | Developer tools open |
| devtools_proxy_console | 400 | score_only | Developer tools (advanced) |
| webgl_anomaly | 400 | score_only | Virtual graphics |
| paint_timing_anomaly | 400 | instant_block | Invisible rendering |
| dpr_mismatch | 400 | score_only | Display ratio mismatch |
| memory_mismatch | 400 | score_only | Memory inconsistency |
| cores_mismatch | 400 | score_only | CPU inconsistency |
| error_stack_mismatch | 400 | score_only | Engine inconsistency |
| rdp_mouse_lag | 400 | score_only | Remote input latency |
| cpu_speed_mismatch | 350 | score_only | CPU speed inconsistency |
| performance_resolution | 350 | score_only | Timer inconsistency |
| webgpu_missing | 350 | score_only | Missing modern API |
| no_audio_desktop | 350 | score_only | No audio hardware |
| mouse_velocity_uniform | 300 | score_only | Robotic mouse movement |
| audio_fingerprint_fail | 300 | score_only | Audio anomaly |
| canvas_fail | 300 | score_only | Rendering failure |
| font_anomaly | 300 | score_only | Missing expected fonts |
| intl_mismatch | 300 | score_only | Internationalization mismatch |
| keyboard_api_mismatch | 300 | score_only | Keyboard API mismatch |
| storage_quota_mismatch | 300 | score_only | Storage inconsistency |
| webrtc_leak | 200 | score_only | Network leak detected |
Block responses
Configured per API key. Blocked visitors never know they failed — the detection runs silently and the response is applied by the snippet.
| Value | Behaviour |
|---|---|
| 404 | Replace page with a 404 not found shell |
| 403 | Replace page with a 403 forbidden shell |
| 503 | Replace page with a 503 unavailable shell |
| redirect | Redirect to URL set in key settings |
| empty | Silent 200 — blank document |
| custom | Custom HTML from key settings (Business+) |
| ai_empty | AI crawlers only — HTTP 200 with empty body |
Webhooks
Configure a webhook URL per API key. NULLR sends a signed POST on allow, block, or threshold_exceeded events.
Signature verification
// X-Nullr-Sig: t=TIMESTAMP,v1=HMAC_HEX
const crypto = require('crypto');
function verifySignature(rawBody, sigHeader, secret) {
const [tPart, vPart] = sigHeader.split(',');
const t = tPart.split('=')[1];
const v = vPart.split('=')[1];
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`).digest('hex');
return expected === v;
}
Webhook payload
{
"event": "block",
"score": 880,
"signals": { "ip_tor": { "score": 800, "triggered": true } },
"visitor": { "country_code": "NL", "is_tor": true, ... },
"key_id": 42,
"domain": "mysite.com",
"timestamp": 1700000000000
}
Retry policy
Non-2xx responses trigger up to 3 retries with exponential backoff (5s, 30s, 120s). After 3 failures the webhook is marked failing and an email alert is sent.
Domain verification
Each API key is bound to one domain. Verify ownership with either method:
Method A — DNS TXT record
Host: _nullr-verify.yourdomain.com
Type: TXT
Value: YOUR_VERIFICATION_TOKEN
Method B — HTML meta tag
<meta name="nullr-verify" content="YOUR_VERIFICATION_TOKEN"/>
NULLR polls every 2 minutes. Once confirmed, the key activates automatically.
JavaScript events
The snippet dispatches events on window:
nullr:allow
Fires when the visitor passes all layers. window.NullrVisitor is populated.
window.addEventListener('nullr:allow', function(e) {
if (window.NullrVisitor.country_code === 'DE') {
showGermanContent();
}
});
nullr:challenge
Fires when PoW challenge starts. The spinner is shown automatically.
window.addEventListener('nullr:challenge', function(e) {
console.log('Visitor challenged — PoW in progress');
});
nullr:block
Fires when a visitor is blocked. Block response (404, redirect, etc.) is applied automatically.
window.addEventListener('nullr:block', function(e) {
analytics.track('Visitor Blocked');
});
JavaScript example
// Load the detection snippet dynamically
(function() {
var script = document.createElement('script');
script.src = 'https://api.nullr.io/v1/snippet?k=nlr_live_YOUR_KEY';
script.async = true;
document.head.appendChild(script);
})();
// React to the approval event
window.addEventListener('nullr:allow', function() {
var v = window.NullrVisitor || {};
// Personalise by country
if (v.country_code === 'US') showUsPricing();
if (v.currency_code === 'EUR') showEuroPricing();
// Track in analytics
analytics.track('Visitor Approved', {
country: v.country_code,
device: v.device,
vpn: v.is_vpn,
});
});
PHP example
<?php
function nullrValidate(string $token, string $apiKey): bool {
$ch = curl_init('https://api.nullr.io/v1/validate');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['token' => $token]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Nullr-Key: ' . $apiKey,
],
CURLOPT_TIMEOUT => 3,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) return true; // Fail open
$data = json_decode($body, true);
return $data['valid'] ?? false;
}
$token = $_COOKIE['_nlr_t_42'] ?? '';
if (!nullrValidate($token, 'nlr_live_YOUR_KEY')) {
http_response_code(404);
exit;
}
Python example
import requests
def nullr_validate(token: str, api_key: str) -> bool:
try:
r = requests.post(
'https://api.nullr.io/v1/validate',
json={'token': token},
headers={'X-Nullr-Key': api_key},
timeout=3,
)
return r.json().get('valid', False)
except Exception:
return True # Fail open
# Django middleware
class NullrMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
token = request.COOKIES.get('_nlr_t_42', '')
if not nullr_validate(token, 'nlr_live_YOUR_KEY'):
from django.http import Http404
raise Http404
return self.get_response(request)
cURL example
# Run detection
curl -X POST https://api.nullr.io/v1/detect \
-H 'Content-Type: application/json' \
-H 'X-Nullr-Key: nlr_live_YOUR_KEY' \
-d '{"fp": {"webdriver": false, "stable_hash": "abc123"}}'
# Validate a token
curl -X POST https://api.nullr.io/v1/validate \
-H 'Content-Type: application/json' \
-H 'X-Nullr-Key: nlr_live_YOUR_KEY' \
-d '{"token": "SESSION_TOKEN_HERE"}'
Ruby example
require 'net/http'
require 'json'
require 'uri'
def nullr_validate(token, api_key)
uri = URI('https://api.nullr.io/v1/validate')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 3
req = Net::HTTP::Post.new(uri.path)
req['Content-Type'] = 'application/json'
req['X-Nullr-Key'] = api_key
req.body = { token: token }.to_json
res = http.request(req)
JSON.parse(res.body)['valid']
rescue StandardError
true # Fail open
end
# Rails before_action
before_action :check_nullr
def check_nullr
token = cookies[:_nlr_t_42].to_s
unless nullr_validate(token, 'nlr_live_YOUR_KEY')
render plain: '', status: :not_found
end
end