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.

Base URL: https://api.nullr.io

How 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.

Fail-open recommended: If the NULLR API is unreachable, allow the request. Detection is the primary layer — server-side validation is a secondary check.

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.

POST https://api.nullr.io/v1/detect
Run full three-layer detection for a visitor.

Request body

FieldTypeDescription
fpoptionalobjectJS fingerprint payload collected by the snippet. If omitted, only L0 and L1 run.
fp.stable_hashstringDeterministic hash of stable fingerprint components.
fp.webdriverbooleannavigator.webdriver value.
fp.automation_objectsstring[]Automation window properties found.
fp.is_headlessbooleanHeadless Chrome signals detected.
fp.canvas_noise_detectedbooleanCanvas renders differ between calls.
fp.screen_width / screen_heightnumberPhysical screen dimensions.
fp.cores / fp.memorynumberhardwareConcurrency / deviceMemory.
powoptionalobjectPoW solution. Required when DDoS level is elevated or under_attack.
pow.noncestringNonce that satisfies the PoW challenge.
pow.prefixstringThe 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.

POST https://api.nullr.io/v1/validate

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.

GET https://api.nullr.io/v1/snippet?k=KEY

Query parameters

ParamTypeDescription
krequiredstringYour 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).

FieldTypeDescription
country_codestringISO 3166-1 alpha-2 (e.g. "DE")
countrystringCountry name
country_flagstringFlag emoji
citystringCity name
regionstringRegion/state
timezonestringIANA timezone (e.g. "Europe/Berlin")
currency_codestringISO 4217 currency code
currency_symbolstringCurrency symbol (e.g. "€")
calling_codestringPhone calling code (e.g. "+49")
is_eubooleanEU member state
is_vpnbooleanVPN detected
is_proxybooleanProxy detected
is_torbooleanTor exit node
is_mobilebooleanMobile device
devicestring"desktop" | "mobile" | "tablet"
os / os_versionstringOperating system and version
browser / browser_versionstringBrowser name and version
languagestringBrowser language (e.g. "fr")
screen_width / screen_heightnumberScreen dimensions (px)
coresnumberCPU core count
memorynumberDevice memory (GB)
connection_typestring"wifi" | "cellular" | "ethernet"
touch_pointsnumberTouch points (0 = desktop)
stable_hashstringStable fingerprint hash
ip †stringClient IP address
isp †stringInternet service provider
org †stringOrganization (ASN owner)
asn †stringAutonomous system number
abuse_score †numberAbuseIPDB confidence (0–100)

Use cases

  • Geo-personalisation: localise prices by currency_code, redirect by country_code
  • Analytics enrichment: send device, os, country_code to GA4 or Mixpanel
  • Content gating: show/hide content based on is_vpn or is_eu
  • Fraud detection: flag is_proxy or is_tor on 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.

Pro and above: Signal scores and actions can be customised per API key in the Signal Tuning page.
Signal keyScoreActionUser label
Layer 0 — Server-side Instant (23 signals)
user_blacklist_ip1000instant_blockIP blacklisted
user_blacklist_fingerprint1000instant_blockDevice blacklisted
ua_empty900instant_blockNo browser identity
ua_bot_keyword900instant_blockKnown bot pattern
bad_http_method800instant_blockSuspicious request method
challenge_failed800instant_blockSecurity check failed
suspicious_path800instant_blockSuspicious URL path
ua_too_short700instant_blockIncomplete browser identity
missing_headers700instant_blockMissing browser headers
org_keyword_blocked600score_onlyOrganization blocked
challenge_missing600score_onlySecurity check not completed
ie_user_agent500instant_blockOutdated browser (IE)
rate_limit_signal500score_onlyToo many requests
sec_ch_ua_mismatch450score_onlyBrowser version mismatch
browser_no_html_accept400instant_blockNon-browser request
accept_wildcard_only400score_onlyGeneric request headers
sec_fetch_missing300score_onlyMissing security headers
old_browser_version300score_onlyOutdated browser version
round_chrome_version300score_onlySuspicious browser version
connection_close_header200score_onlyAutomated connection pattern
incognito200score_onlyPrivate browsing
ai_crawler0instant_blockAI crawler
user_whitelist_ip0bypassIP whitelisted
Layer 1 — IP Intelligence (22 signals)
ip_abuse_critical900instant_blockKnown malicious IP
country_blocked900instant_blockCountry blocked
continent_blocked900instant_blockRegion blocked
ip_tor800instant_blockTor network
ip_cidr_match800instant_blockBlocklisted IP range
ip_known_bot800instant_blockKnown bot IP
impossible_travel800score_onlyImpossible location change
ip_abuse_high700instant_blockReported IP
os_blocked700instant_blockOS blocked
browser_blocked700instant_blockBrowser blocked
device_blocked700instant_blockDevice type blocked
isp_blocked700instant_blockISP blocked
org_blocked700instant_blockOrganization blocked
ip_intelligence_fail600score_onlyIP lookup unavailable
internal_reputation600score_onlyPoor reputation history
hostname_blocked600score_onlyHostname blocked
ip_proxy500instant_blockProxy connection
country_switching500score_onlyMultiple countries detected
language_blocked500score_onlyLanguage blocked
ip_hosting400score_onlyDatacenter IP
ip_rotation400score_onlyIP address rotation
ip_vpn300score_onlyVPN connection
Layer 2 — JS Fingerprint (43 signals)
hacking_tool900instant_blockAnalysis tool detected
headless_chrome900instant_blockAutomated browser
rdp_remote_tool900instant_blockRemote desktop tool
automation_object850instant_blockAutomation framework
emulator_detected800instant_blockDevice emulator
vnc_detected750instant_blockRemote viewer detected
vm_environment700instant_blockVirtual machine
rdp_detected700score_onlyRemote desktop session
mobile_no_sensors700instant_blockFake mobile device
behavioral_bot700instant_blockNo human interaction
ai_agent_behavior600score_onlyAI agent pattern
canvas_noise600instant_blockFingerprint manipulation
css_dpr_mismatch600instant_blockDisplay spoofing
ua_mismatch600score_onlyBrowser identity mismatch
webgl_gpu_spoof500instant_blockGPU spoofing
ua_data_mismatch500instant_blockBrowser data inconsistency
sandbox_timing500score_onlyAnalysis sandbox
webgl_params_mismatch500score_onlyGPU parameter mismatch
battery_stub500score_onlyVirtual battery
screen_too_large500score_onlyImpossible screen size
rdp_cleartype450score_onlyRemote rendering
plugin_count_zero400score_onlyNo browser plugins
devtools_open400score_onlyDeveloper tools open
devtools_proxy_console400score_onlyDeveloper tools (advanced)
webgl_anomaly400score_onlyVirtual graphics
paint_timing_anomaly400instant_blockInvisible rendering
dpr_mismatch400score_onlyDisplay ratio mismatch
memory_mismatch400score_onlyMemory inconsistency
cores_mismatch400score_onlyCPU inconsistency
error_stack_mismatch400score_onlyEngine inconsistency
rdp_mouse_lag400score_onlyRemote input latency
cpu_speed_mismatch350score_onlyCPU speed inconsistency
performance_resolution350score_onlyTimer inconsistency
webgpu_missing350score_onlyMissing modern API
no_audio_desktop350score_onlyNo audio hardware
mouse_velocity_uniform300score_onlyRobotic mouse movement
audio_fingerprint_fail300score_onlyAudio anomaly
canvas_fail300score_onlyRendering failure
font_anomaly300score_onlyMissing expected fonts
intl_mismatch300score_onlyInternationalization mismatch
keyboard_api_mismatch300score_onlyKeyboard API mismatch
storage_quota_mismatch300score_onlyStorage inconsistency
webrtc_leak200score_onlyNetwork 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.

ValueBehaviour
404Replace page with a 404 not found shell
403Replace page with a 403 forbidden shell
503Replace page with a 503 unavailable shell
redirectRedirect to URL set in key settings
emptySilent 200 — blank document
customCustom HTML from key settings (Business+)
ai_emptyAI 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.

Availability: Webhooks require Pro plan or above.

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