One endpoint. Behavioral signals. A confidence score. No puzzle in front of your users. See what it does not catch.
The snippet silently collects behavioral signals (mouse, keyboard, scroll) and sends them with each verify call for a richer score.
All requests must include your API key in the Authorization header as a Bearer token.
Get your key from your dashboard →
/api/v1/verifyVerify a userAnalyzes a user's behavioral signals and returns a human confidence score. Call at critical moments — signups, logins, form submissions, high-value actions.
| Parameter | Type | Required | Description |
|---|---|---|---|
| userId | string | Yes | Your internal user identifier. Stored with the verification record so it appears in your dashboard and so repeat activity can be scored. Send a pseudonymous id, never an email or a name. |
| sessionData | object | In practice | The behavioral signals collected by huma.js in the browser. This is the entire input: without it there is nothing to score and the call cannot tell you anything about the visitor. Optional in the signature only so a call can still be made when the browser sent nothing. Field reference below. |
| Field | Type | Description |
|---|---|---|
| human | boolean | True when confidence is at or above your account's threshold, returned below. |
| confidence | number | Score from 0.0 to 1.0. Higher = more human-like behavior. |
| threshold | number | The cutoff this verdict was compared against. Returned on every call so you never have to guess which number decided it. |
| token | string | Unique verification token. Store for audit purposes. |
| pii_stored | boolean | Always false. useHUMA never stores personal information. |
| agent | object | Pro & Enterprise. AI-agent detection: { is_agent, agent_confidence (0–1), signals[] }. Parallel to the human score — present only when behavioral signals are sent. Omitted on lower plans. |
| warnings | string[] | Present only when something is wrong with your payload, such as a field name we do not recognise. Read it in development. A visitor scored from a field we never read is not a bot, it is a wiring mistake, and this is where you find out. |
curl -X POST https://humaverify.com/api/v1/verify \
-H "Authorization: Bearer huma_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_abc123",
"sessionData": { /* the object from Huma.debug().features */ }
}'{
"human": true,
"confidence": 0.92,
"threshold": 0.5,
"token": "h_verified_Kd3mP8luYMC2Mi1x",
"pii_stored": false,
"agent": { // Pro & Enterprise
"is_agent": false,
"agent_confidence": 0,
"signals": []
}
}These are the fields sessionData is read from. You do not build this object yourself: huma.js produces it in the browser and you forward it untouched. The list is here because you should be able to see exactly what leaves your users' browsers, and because an integration that rebuilds the object by hand needs to know the names.
<script src="https://humaverify.com/huma.js"></script> <script> // Send this to your own backend, unchanged const sessionData = window.Huma.debug().features; </script>
| Field | Type | What it is |
|---|---|---|
| time_on_page_ms | number | Milliseconds since the collector started. |
| mouse_sample_count | number | Pointer positions sampled. |
| mouse_speed_cv | number | Variation in pointer speed. |
| mouse_direction_changes | number | Times the pointer changed direction. |
| key_count | number | Intervals between keystrokes, not keystrokes. Four characters gives three. |
| key_interval_cv | number | Variation in typing rhythm. |
| scroll_count | number | Scroll events. |
| scroll_interval_cv | number | Variation in scroll timing. |
| click_count | number | Clicks. |
| click_interval_cv | number | Variation in click timing. |
| tab_switches | number | Times the page lost or regained focus. |
| tap_count | number | Touch taps. Mobile. |
| touch_move_count | number | Touch drags. Mobile. |
| agent | object | Automation tells. Forward this nested object untouched. An integration that keeps only the numbers drops it, and automation detection silently stops working. It is the single most common way to break this. |
The collector sends a few additional touch measurements alongside these. They are accepted and ignored, so forwarding the whole object is always safe. Send a field name we do not recognise and the response carries a warnings array saying so, rather than quietly scoring the visitor as though the data were never there.
No cookies, no local storage, no canvas or WebGL fingerprint, no user-agent profiling, and nothing you typed. Pointer coordinates and keystroke timings are reduced to the summary numbers above inside the browser itself, and the raw traces are discarded there. You can confirm all of it in devtools, which is the point of publishing the list.
On Pro & Enterprise, every verification returns an agent block alongside the human score. It flags AI agents driving a real browser — Playwright, Puppeteer, Selenium, and extension / computer-use agents — even when the session looks human. It only appears when behavioral signals are sent.
One response, a three-way decision: check human first, then agent.is_agent. The action is always yours.
const { human, confidence, agent } = await res.json();
if (!human) {
// Not human — block or challenge (bot / automated abuse)
return reject();
}
if (agent?.is_agent) {
// A real browser driven by an AI agent (Pro & Enterprise).
// Your call: allow the automation you want, or block / step-up the rest.
return challenge();
}
// Trusted human — let them through
return allow();Detection is high-confidence, not absolute — a perfectly throttled agent that mimics human input is an arms race. Use agent_confidence to tune how aggressively you act.
/api/v1/sessionHeartbeat — continuous monitoringMonitor an active user session for bot behavior changes and account takeovers. Call every 30 seconds after a successful verify(). Returns a live score and flags anomalies when behavior drops significantly from the user's verified baseline.
| Parameter | Type | Required | Description |
|---|---|---|---|
| userId | string | Yes | Same userId used in verify(). |
| sessionToken | string | Yes | The token returned by verify() — used to fetch the behavioral baseline. |
| sessionData | object | No | Behavioral signals from the current window (same shape as verify sessionData). |
| Field | Type | Description |
|---|---|---|
| score | number 0–100 | Live session score for this window. |
| confidence | number 0–1 | Confidence as a decimal. |
| baseline | number 0–100 | Score from the original verify — used as comparison reference. |
| delta | number | Score drop from baseline. Negative = degraded behavior. |
| anomaly | boolean | True when this window departs far enough from the session baseline, or is low enough on its own, to be worth acting on. |
| action | string | allow | flag | block. Branch on this rather than on score: a window where the visitor simply read without touching anything carries no information, and thresholding it yourself would treat a reader as a bot. |
| reason | string | ok | quiet_window | behaviour_changed | no_human_signals | automation. Enough to branch on and to log. |
// After a successful verify():
const result = await Huma.verify('user_123', 'huma_live_...');
if (result.human) {
Huma.startSession('user_123', 'huma_live_...', result.token, {
intervalMs: 30000, // every 30 seconds
onAnomaly: function(r) {
if (r.action === 'block') {
window.location.href = '/logout'; // account takeover detected
}
}
});
}// Call your own backend, which holds the key and forwards to /api/v1/session.
useEffect(() => {
const id = setInterval(async () => {
const r = await fetch("/api/huma/heartbeat", {
method: "POST",
body: JSON.stringify({
userId,
sessionToken: verifyResult.token,
sessionData: window.Huma.debug().features,
}),
}).then((r) => r.json());
if (r.action === "block") router.push("/logout");
}, 30000);
return () => clearInterval(id);
}, [userId]);Receive real-time HTTP POST notifications when a bot is detected or a human is verified. Signatures use HMAC-SHA256 — the same pattern as Stripe. Max 5 webhooks per API key.
/api/v1/webhooksList your webhooks/api/v1/webhooksRegister a webhook| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | Your HTTPS endpoint. Must start with https:// |
| events | string[] | No | Events to receive: bot.detected, human.verified. Defaults to both. |
⚑ The secret is shown only once. Save it immediately.
{
"id": "wh_abc123...",
"secret": "huma_whsec_abc123...", // shown once
"url": "https://yourdomain.com/webhooks/huma",
"events": ["bot.detected", "human.verified"],
"active": true
}Each webhook includes a Huma-Signature header. Verify it to confirm the request came from useHUMA.
Huma-Signature: t=1716840000,v1=abc123def456...
import crypto from 'crypto';
function verifyWebhook(rawBody, signature, secret) {
const [tPart, vPart] = signature.split(',');
const timestamp = tPart.replace('t=', '');
const received = vPart.replace('v1=', '');
const payload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received)
);
}{
"id": "evt_9Kd3mP8luYMC",
"event": "bot.detected",
"created_at": "2026-08-06T21:00:00.000Z",
"data": {
"user_id": "user_123",
"human": false,
"confidence": 0.18,
"score": 18,
"threshold": 0.5,
"reason": "no_human_signals",
"token": "h_verified_Kd3mP8luYMC2Mi1x"
}
}Everything except id, event and created_at is nested under data. Read it from there: a handler written against a flat body sees undefined for every field, and if (!body.human) is then true on a human.verified event too.
When confidence is borderline (30–70%), present a visual challenge instead of hard-blocking. If passed, the user receives a verification token equivalent to a standard verify. Max 3 attempts per challenge. Challenges expire in 5 minutes.
/api/v1/challenge/createGenerate a challenge| Parameter | Type | Required | Description |
|---|---|---|---|
| userId | string | Yes | Your internal user identifier. |
{
"challenge_id": "ch_abc123...",
"type": "click_targets", // or "pattern"
"payload": {
"instruction": "Click the 🎯",
"targets": [
{ "id": "t0", "x": 20, "y": 25, "label": "🌿" },
{ "id": "t1", "x": 65, "y": 25, "label": "🎯" },
...
]
},
"expires_in_seconds": 300
}/api/v1/challenge/verifySubmit the user's answer| Parameter | Type | Required | Description |
|---|---|---|---|
| challenge_id | string | Yes | ID from challenge/create. |
| answer | string | Yes | For click_targets: target id (e.g. "t1"). For pattern: dot sequence (e.g. "1-5-9"). |
{ "passed": true, "token": "h_challenge_abc..." }{ "passed": false, "message": "Incorrect answer", "attempts_remaining": 2 }// After verify() returns borderline confidence.
// Render the challenge yourself: there is no component to import yet.
const ch = await fetch('/api/v1/challenge/create', {
method: 'POST',
headers: { Authorization: 'Bearer huma_live_...' },
body: JSON.stringify({ userId }),
}).then(r => r.json());
// ch = { challenge_id, type, payload }
// Show it, collect the answer, then verify it:
const res = await fetch('/api/v1/challenge/verify', {
method: 'POST',
headers: { Authorization: 'Bearer huma_live_...' },
body: JSON.stringify({ challenge_id: ch.challenge_id, answer }),
}).then(r => r.json());
if (res.passed) continueLogin(res.token);
else router.push('/blocked');| Status | Code | Meaning |
|---|---|---|
| 200 | — | Verification successful. |
| 401 | MISSING_AUTH | No Authorization header provided. |
| 401 | INVALID_API_KEY_FORMAT | Key doesn't match format (huma_live_...). |
| 401 | INVALID_API_KEY | Key not found in database. Check your dashboard. |
| 403 | EMAIL_NOT_VERIFIED | Your key is valid, but your email isn't confirmed yet. Click the link in your signup email, or resend it from your dashboard. |
| 400 | — | Missing or invalid userId in request body. |
| 402 | TRIAL_EXPIRED | Free trial has expired. Upgrade at humaverify.com/#pricing. |
| 429 | MONTHLY_LIMIT_EXCEEDED | Monthly plan limit reached. Upgrade your plan. |
| 429 | BURST_LIMIT_EXCEEDED | Too many requests per minute. Check Retry-After header. |
| 429 | USER_FLOOD_DETECTED | Same userId verified too many times per minute (max 10/min). |
| 503 | — | Service temporarily unavailable. Retry with exponential backoff. |
This section used to document an npm install usehuma package with React hooks and Next.js helpers. None of it existed. Six copy-paste examples on this page imported things that were never written, which is worse than having no SDK at all, so they are gone until the package they describe is real.
You do not need one. The integration is a script tag and one POST, and that is the whole surface in any language:
// Browser: collect
<script src="https://humaverify.com/huma.js"></script>
const sessionData = window.Huma.debug().features;
// Server: score, and let people through if we are unreachable
let result = { human: true, confidence: 0 };
try {
const res = await fetch("https://humaverify.com/api/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, sessionData }),
signal: AbortSignal.timeout(5000),
});
if (res.ok) result = await res.json();
} catch {
// Our outage must not become your outage. See /limits.
}
if (!result.human) { /* challenge, flag, or block — your call */ }A Node package lives in the repository as @usehuma/node, and it will be documented here when it is published and not before. If you want it sooner, say so at team@humaverify.com and it moves up.
Get your API key in seconds. 14-day free trial. No credit card required.
GET YOUR API KEY →