Query IP reputation, verify human-proof tokens, and embed the captcha widget. Everything you need to integrate Sentinel.
Authenticated API endpoints live under /api/v1 and are authenticated with an API key
sent in the X-Api-Key request header. Every request is scoped to the key's owner,
plan and quota.
Header
X-Api-Key: rk_xxxxxxxx_your_secret
/captcha/siteverify — no
X-Api-Key. The API key here is only for the IP‑reputation & AI endpoints below.
Manage your Sentinel sites (Site Key + Secret Key) in the Lab.
Returns the calling API key's identity, plan, remaining credits and quota. Useful for confirming a key works and checking limits before a batch of calls.
Example request
curl https://redeyed.com/api/v1/me \
-H "X-Api-Key: rk_xxxxxxxx_your_secret"
Example response
{
"key": {
"name": "Production",
"prefix": "xxxxxxxx",
"scopes": ["ip:read", "verify"],
"last_used_at": "2026-06-22T01:14:09Z"
},
"plan": "team",
"credit_balance": 9820,
"quota": 48000
}
Scores an IP address. Omit {ip} to score the caller's own IP. Returns a 0–100
score, a risk band, flags (proxy / VPN / Tor / datacenter / bot / abuser), geolocation and ASN.
Each lookup is recorded as a behavioral signal for the reputation engine.
| Param | Type | Description |
|---|---|---|
ip | string (optional) | IPv4 or IPv6 address. If omitted, the caller's IP is scored. Invalid input returns 422. |
Example request
curl https://redeyed.com/api/v1/ip/8.8.8.8 \
-H "X-Api-Key: rk_xxxxxxxx_your_secret"
Example response
{
"ip": "8.8.8.8",
"score": 72,
"risk": "medium",
"flags": {
"is_proxy": false,
"is_vpn": false,
"is_tor": false,
"is_datacenter": true,
"is_residential_proxy": false,
"is_bot": false,
"is_abuser": false
},
"connection_type": "hosting",
"network_kind": "hosting",
"country_code": "US",
"asn": 15169,
"asn_org": "Google LLC",
"signals": { /* score breakdown */ },
"evidence": { "datacenter": "confirmed" },
"confidence": 61,
"last_seen_at": "2026-06-22T01:10:00Z",
"usage": { /* request quota usage */ }
}
Server-to-server human verification — reCAPTCHA/Turnstile style. From your backend, post your site's
Secret Key together with the response token your form collected from the
widget. The secret alone identifies and authorizes the site, so no developer API key is
required. Returns the outcome band, score and reason.
| Param | Type | Description |
|---|---|---|
secret | string (required) | Your site's Secret Key (st_sec_…). Server-side only. |
response | string (required) | The captcha-token minted by the widget on a successful solve. (token is accepted as an alias.) |
remoteip | string (optional) | The end user's IP, if your server knows it. Improves scoring. |
telemetry | object (optional) | Coarse, non-PII behavioral telemetry to feed bot scoring. |
Example request
curl -X POST https://redeyed.com/captcha/siteverify \
-H "Content-Type: application/json" \
-d '{
"secret": "st_sec_xxxxxxxxxxxxxxxxxxxx",
"response": "captcha-token-from-widget",
"remoteip": "203.0.113.42"
}'
Example response
{
"success": true,
"outcome": "passed", // passed | challenged | blocked
"score": 18,
"reason": "Acceptable risk (score 18)",
"hostname": "yoursite.com",
"error-codes": []
}
Outcomes: passed — allow the request; challenged —
re-prompt or step up; blocked — deny (high-risk IP or detected automation).
Already using the /api/v1 key-based API? You can also verify with
POST /api/v1/verify (X-Api-Key + site_key + token).
It returns the same outcome. For a plain captcha integration, prefer /captcha/siteverify above.
The Sentinel widget is a self-contained, vanilla-JS captcha — no build step. Load the script once,
then drop a .redeyed-captcha element with your site key inside any form. The widget
auto-mounts, runs the challenge, and injects a hidden captcha-token input on success.
Embed
<!-- Load once, anywhere on the page -->
<script src="https://redeyed.com/captcha.js" async></script>
<!-- Place inside the form you want to protect -->
<form method="POST" action="/signup">
<!-- ...your fields... -->
<div class="redeyed-captcha" data-sitekey="site_xxxxxxxxxxxx" data-width="full"></div>
<button type="submit">Create account</button>
</form>
On success the widget appends <input type="hidden" name="captcha-token"> to the
enclosing form and fires a captcha:solved event. Submit the token to your backend and
confirm it with POST /captcha/siteverify (your Secret Key + the token). You can also
drive the widget programmatically with window.Captcha.render() and window.Captcha.reset().
Customize per form
The same site key works everywhere — add optional data-* attributes to each
.redeyed-captcha element to tune the widget per form. All are optional.
| Attribute | Values | Description |
|---|---|---|
data-sitekey | required | Your public Sentinel site key. |
data-widget | see challenge types | Challenge style. Defaults to adaptive, which escalates from a low-friction proof to procedural reasoning based on risk. |
data-theme | auto · light · dark | Colour theme. auto follows the visitor's prefers-color-scheme and re-renders live if they change it. |
data-scheme | see colour schemes | Colour scheme — surface & accent tinted to match your form. |
data-widget-steps | 1-7 | Paid plans. Pin how many verification steps the widget asks for, on any widget type. Like data-difficulty it is a floor: it can raise the number of steps above the adaptive baseline, never lower it, so a risky visitor is never given an easier ride than they would otherwise get. Ignored entirely on a free plan. |
data-difficulty | easy · medium · hard · max · 1-6 | Challenge strength. Higher = a harder puzzle and more of them: longer holds, tighter drag and rotation targets, busier scenes, bigger image grids, more rounds, harder proof-of-work. Only raises difficulty above the adaptive baseline — a risky visitor is always challenged hard regardless. |
data-width | e.g. 100% · 320px · full | Widget width. Use 100% or full for full-width responsive forms. |
data-badge | hide | Hides the Redeyed badge for this embed. Requires a Team or Enterprise plan — on any other plan the attribute is inert and the badge stays visible. The server decides, so it cannot be bypassed from the DOM. To hide it across every embed instead, use the account setting in Lab → Sentinel → Sites → Settings. |
Procedural visual challenges include a keyboard-accessible verification switch. Visitors who prefer reduced motion are moved automatically to the non-animated press-and-hold proof.
<div class="redeyed-captcha"
data-sitekey="site_xxxxxxxxxxxx"
data-widget="press_hold"
data-theme="dark"
data-difficulty="hard"></div>
Raise the challenge floor for every visitor to your site, immediately. Sentinel already arms this on its own when it sees a flood, but detection is reactive — it can only trip once traffic has been arriving for a counter window. Your WAF, CDN or on-call alerting usually notices first, so this endpoint lets that system raise the floor without a human opening a browser.
Authenticated with your site's Secret Key, like
/captcha/siteverify — no
X-Api-Key. The change is scoped to the site that key belongs to.
Request
| Field | Type | Description |
|---|---|---|
secret | string | Your site's Secret Key. |
state | string | elevated, under_attack, or normal to clear. |
minutes | int (optional) | How long to hold it. 1–1440, default 30. Ignored for normal. |
Example request
curl -X POST https://redeyed.com/captcha/attack-mode \
-H "Content-Type: application/json" \
-d '{"secret":"st_sec_xxxxxxxx","state":"under_attack","minutes":30}'
Example response
{
"success": true,
"state": "under_attack",
"held": true,
"expires_in": 1800,
"hostname": "example.com",
"applies": {
"risk_band": "medium",
"difficulty_floor": 5,
"rounds_floor": 3
}
}
Clearing it
curl -X POST https://redeyed.com/captcha/attack-mode \
-H "Content-Type: application/json" \
-d '{"secret":"st_sec_xxxxxxxx","state":"normal"}'
A hold expires on its own, so a forgotten one cannot punish your site indefinitely.
| Status | Meaning |
|---|---|
200 | Applied. state reflects what is now in force. |
401 invalid-input-secret | Unknown secret, or the site is inactive. |
403 sentinel-pro-required | On-demand attack mode needs Sentinel Pro. Automatic protection is unaffected. |
422 | Invalid state or minutes. |
503 hold-not-applied | The change did not take. Treat as a failure and retry — see the note below. |
503 attack-mode-disabled | Attack mode is switched off server-side. |
success.
elevated unless you are actually under attack, and
keep the hold short — you can always re-issue it.
Sentinel raises your challenge floor by itself when it detects a flood — on every plan, including free. Alerts tell you the moment it happens, so you can look at your own logs while it is going on rather than reading about it in a support ticket the next morning.
Configure a webhook URL and/or an email under Sentinel → Sites → your site. Alerts need an active Sentinel Pro add-on; the protection they report on does not.
Payload
{
"event": "attack_mode.changed",
"site": "example.com",
"from": "normal",
"to": "under_attack",
"direction": "raised",
"occurred_at": "2026-08-25T14:03:11+00:00",
"applies": {
"risk_band": "medium", "difficulty_floor": 5, "rounds_floor": 3
}
}
applies is null when direction is cleared.
Every call carries X-Sentinel-Timestamp and
X-Sentinel-Signature. The signature is an HMAC-SHA256 of
timestamp + "." + rawBody, keyed with the signing secret shown once
when you saved the webhook.
PHP
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_SENTINEL_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SENTINEL_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
// Constant-time compare, and reject anything older than five minutes so a
// captured call cannot be replayed at you later.
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(400);
exit;
}
If you run the address space we score — a VPN, a host, an ISP — you see the consequences of our verdicts without any view of the cause: one abuser on a shared exit degrades that address for every legitimate user behind it, and the first you hear of it is a support ticket saying "your service is broken".
These endpoints give you that view for your own networks, so the responsible account can be terminated and the address can recover. They require Sentinel Pro and an X-Api-Key.
Your claims and their status. verified_networks: 0 means nothing else here will return data yet.
Reputation for up to 500 addresses in one call, for operators holding thousands of exits. Billed per address resolved, the same as asking for each one individually — batching saves round trips, not money. Addresses you have not claimed are allowed here: it returns only what the per-IP endpoint already would.
curl -X POST https://redeyed.com/api/v1/network/bulk \
-H "X-Api-Key: rk_xxxxxxxx_your_secret" \
-H "Content-Type: application/json" \
-d '{"ips":["203.0.113.4","198.51.100.7"]}'
An address we have never seen comes back "known": false with a null
score, rather than being reported as clean. Absence of evidence is not evidence.
Per-ASN summary: how many addresses we know, the split by risk band, and the worst offenders first. The question you actually have when tickets start arriving.
Abuse observed on your verified networks. Takes since (ISO 8601, default 7 days) and limit (max 1000).
{
"since": "2026-08-18T00:00:00+00:00",
"count": 2,
"events": [
{ "ip": "203.0.113.4", "observed_at": "…", "category": "automation", "severity": "high" }
]
}
Returns every challenge type, colour scheme, theme and difficulty level this deployment accepts. No API key and no Site Key — the widget script already ships the same tables to the browser. Cached for 5 minutes.
Use it instead of hardcoding a list. An unrecognised data-widget is
not an error — it falls back to the site default — so a
stale hardcoded list keeps working while silently ignoring the setting. Plugin
settings screens should build their dropdown from this endpoint.
Example request
curl https://redeyed.com/captcha/capabilities
Example response
{
"widget_version": "20260824c",
"types": {
"meta": ["adaptive", "all"],
"concrete": ["behavioral", "pow", "press_hold", …, "shape_match", "count_match"],
"default": "press_hold"
},
"themes": ["light", "dark", "auto"],
"schemes": [
{ "name": "default", "premium": false },
{ "name": "midnight", "premium": true }
],
"difficulty": { "named": ["easy","medium","hard","max"], "min": 1, "max": 6, "raises_only": true }
}
"premium": true needs a paid plan. A free site that
asks for one silently renders default, so show that in your UI rather
than letting the choice appear to apply.
Set with data-widget. Two values are resolved server-side rather than
naming a concrete challenge:
| Value | Behaviour |
|---|---|
adaptive |
Recommended. Resolves from IP reputation and the form's difficulty floor — clean traffic gets a low-friction proof, elevated risk escalates to procedural reasoning. |
all | Picks any supported type at random per challenge, so different visitors meet different styles. |
Concrete types
| Value | Description |
|---|---|
behavioral |
One click. Pointer entropy, timing and focus signals decide in the background — no puzzle at all. |
pow |
Fully invisible. The browser solves a SHA-256 challenge; the hash is recomputed server-side, so the work cannot be faked. |
text_math |
A short arithmetic question. No dragging or fine motor control. |
image_puzzle |
Drag the missing piece into the gap. Drawn procedurally on canvas — no image assets to scrape or pre-solve. |
rotate_align |
Turn the shape upright. Generated on canvas from the challenge seed, so every render is unique. |
press_hold default |
Hold the button until it fills. Reads steadiness — trivial for a person, awkward for a script. Also the accessible fallback. |
image_pick |
Choose the tile matching a prompt. Emoji rendered to noisy canvases, so there is no selectable text. |
relational_scene |
Reason about distance and containment in a one-time scene instead of recognizing a reusable stock photo. |
motion_track |
Follow moving objects through time and identify which reaches the marked destination. |
light_shadow |
Infer a light source from the shadows it casts. |
shape_match |
Spot the two 3D objects that match — same shape, or same colour. The scene, palette and question are regenerated every time, so nothing repeats. |
count_match |
A key lists objects with a required quantity; select every matching object in the grid. The selected set is verified exactly, so selecting everything fails. |
press_hold. Visitors with prefers-reduced-motion are moved off
animated challenges automatically, so you never need a separate accessible embed.
Set with data-scheme. Themed schemes tint the surface and
compose with data-theme (light / dark / auto). Fixed schemes
carry their own surface and ignore the theme. Animated schemes paint a
moving backdrop and require a paid plan.
| Scheme | Kind | Description |
|---|---|---|
default
|
Themed | Brand red over our own light/dark surfaces. |
ocean
|
Themed | Blue / teal. |
forest
|
Themed | Green. |
sunset
|
Themed | Orange. |
graphite
|
Themed | Slate grey. |
royalty
|
Fixed | Gold on near-black. |
ruby
|
Fixed | Crimson on near-black. |
hacker
|
Fixed | Terminal green on black, monospace. Adds a scanning band and grid on paid plans. |
cyber
|
Fixed | Terminal cyan on black, monospace. Adds a scanning band and grid on paid plans. |
monochrome
|
Fixed | Pure greyscale, no hue. |
midnight
|
Animated | Night sky with a twinkling starfield and shooting star. |
ember
|
Animated | Brand-red galaxy: aurora wash, starfield and shooting star. |
aurora
|
Animated | Flowing aurora wash. |
data-scheme="midnight" is inert and the widget falls back to
default — it cannot be unlocked from the DOM.
<div class="redeyed-captcha"
data-sitekey="site_xxxxxxxxxxxx"
data-scheme="cyber"
data-theme="dark"></div>
Loading captcha.js defines window.Captcha. Any
.redeyed-captcha[data-sitekey] element on the page mounts automatically on
load — you only need these for widgets you add or reset dynamically.
| Member | Description |
|---|---|
Captcha.render(target?) | Mounts a widget. Pass an element or a CSS selector; omit to mount every unmounted widget on the page. |
Captcha.reset(target?) | Clears the token and issues a fresh challenge. Call this after a failed form submit so the visitor can retry. |
Captcha.badge(target?) | Re-mounts the badge on a widget. |
Captcha.theme · Captcha.widget · Captcha.scheme | The resolved defaults for the current page, useful for debugging what the server actually applied. |
window.CaptchaNoBadge | Set truthy before the script loads to request badge removal globally. Subject to the same plan gate as data-badge. |
Events
On success the widget dispatches a bubbling captcha:solved
CustomEvent whose detail.token is the single-use token, and writes
that token into a hidden captcha-token input in the enclosing form.
document.addEventListener('captcha:solved', function (e) {
// Send e.detail.token to your server and verify it there.
console.log(e.detail.token);
});
// Re-arm after a rejected submit
window.Captcha.reset('#signup .redeyed-captcha');
Migrating from reCAPTCHA, hCaptcha or Turnstile needs no markup changes. Swap the
provider's script for captcha.js and keep your existing container — Sentinel
adopts it and writes the response field your server already reads.
| Existing container | Response field written |
|---|---|
.g-recaptcha | g-recaptcha-response |
.h-captcha | h-captcha-response |
.cf-turnstile | cf-turnstile-response |
The explicit JS APIs keep working too: grecaptcha, hcaptcha and
turnstile shims are defined only when the real provider script is
absent, so a live install is never clobbered. render(),
reset(), ready() and getResponse() all behave as
expected.
Server-side, these paths accept your existing verify payload and return the same response shape, so only the hostname changes:
POST https://redeyed.com/recaptcha/api/siteverify
POST https://redeyed.com/hcaptcha/siteverify
POST https://redeyed.com/turnstile/v0/siteverify
secret — your old provider's
secret is not valid here. Everything else about the request and response is unchanged.
The widget uses two same-origin endpoints under the hood. You normally never call these directly, but they're documented for custom integrations.
Issues an adaptive proof-of-work challenge for a site key. Difficulty scales with the IP's live risk band; high-risk IPs are blocked rather than challenged.
Request & response
curl -X POST https://redeyed.com/captcha/challenge \
-H "Content-Type: application/json" \
-d '{ "site_key": "site_xxxxxxxxxxxx" }'
// 200 OK
{
"challenge_id": "aZ8...40-char-token",
"nonce": "16-char-nonce",
"difficulty": 3
}
// 403 { "error": "Access denied.", "blocked": true } for high-risk IPs
Find an integer solution so that sha256(nonce + solution) starts with
difficulty leading hex zeros, then post it back. The server recomputes the hash, checks
it's unsolved / unexpired / IP-matched, and mints a short-lived HMAC token.
Request & response
curl -X POST https://redeyed.com/captcha/solve \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "aZ8...40-char-token",
"solution": "104729",
"telemetry": { "solve_ms": 380, "pointer_entropy": 0.42 }
}'
// 200 OK
{
"token": "signed-captcha-token",
"bot_likelihood": 7
}
Submit the returned token with your form, then confirm it server-side via POST /captcha/siteverify (your Secret Key + the token).
Fields returned by GET /api/v1/ip/{ip}.
| Field | Type | Description |
|---|---|---|
ip | string | The IP address that was scored. |
score | integer | Reputation score, 0–100. Higher means riskier. |
risk | string | Risk band derived from the score: clean, low, medium or high. |
flags.is_proxy | boolean | IP is a known proxy. |
flags.is_vpn | boolean | IP belongs to a VPN provider. |
flags.is_tor | boolean | IP is a Tor exit / relay node. |
flags.is_datacenter | boolean | IP is hosting / datacenter, not residential. |
flags.is_residential_proxy | boolean | A consumer connection being used as a proxy exit node — the address is genuinely residential, but is seen across many unrelated sites, from many devices, at a rate a household does not produce. Inferred from behaviour, never from observing a proxy service, so evidence.residential_proxy is never confirmed. Treat it as a strong signal, not a fact. |
flags.is_bot | boolean | IP is associated with automated / bot traffic. |
flags.is_abuser | boolean | IP appears on abuse / reputation blocklists. |
country_code | string|null | ISO 3166-1 alpha-2 country code (self-owned geo data). |
asn | integer|null | Autonomous System Number. |
asn_org | string|null | Organization that owns the ASN. |
connection_type | string | e.g. residential, mobile, hosting, datacenter, education or unknown. Can be set by any classification we hold for the address. |
network_kind | string | What the network announcing this address is for, taken from the global routing table: residential, mobile, hosting, business, education, government or unknown. Separate from connection_type on purpose — when the two disagree, this is the one describing who the addresses belong to. |
evidence | object | How well-evidenced each raised flag is, keyed by flag — confirmed (the range is published as such), inferred (a block dense with confirmed hosts), or assumed (the operator sells VPN or hosting capacity). Read this before blocking: an assumed match describes the network, not the address, and is scored at half weight for that reason. |
confidence | integer | How much history backs the score, rising with observed hits. Unrelated to evidence above. |
last_seen_at | datetime|null | When this IP was last observed. |