One endpoint. Behavioral signals. A confidence score. No puzzle in front of your users. See what it does not catch.
<script src="https://humaverify.com/huma.js"></script> const sessionData = window.Huma.debug().features; // send userId + sessionData to your own backend
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 }),
});
const result = await res.json();
if (result.human && result.confidence > 0.8) { /* allow */ }The snippet silently collects behavioral signals (mouse, keyboard, scroll). Your key never reaches the browser: it authorizes webhook management as well as verification, so anyone who reads it from your page can spend your quota and rewire where your results are sent.
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 | Every plan, Free included. AI-agent detection: { is_agent, agent_confidence (0–1), signals[] }. Parallel to the human score — present only when behavioral signals are sent. |
| 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": { // every plan, Free included
"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, WebGL or WebGPU 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 every plan, the free one included, 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. Returned on every plan.
// 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 your server's verify() call came back human, it returns you the token.
// Point the heartbeat at your own route so the key stays on your server.
Huma.startSession(userId, '', verifyResult.token, {
endpoint: '/api/huma/heartbeat', // your backend adds the Authorization header
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.
| 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. |
| 429 | MONTHLY_LIMIT_EXCEEDED | Plan quota used up for the month. The response carries the reset date. Nothing is ever billed as overage. |
| 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. |
The usehuma package is published on npm with three entries: usehuma for any browser app, usehuma/react for hooks and components, and usehuma/next for verifying on the server. It collects behavioral signals and calls the same endpoint documented above.
npm install usehuma
// Browser: collect signals, then verify
import { init, verify } from "usehuma";
init(); // call once, early — signals need time to accumulate
const result = await verify({ apiKey: "huma_live_...", userId: "user_123" });
if (result.human) { /* ... */ }
// React: gate content behind verification
import { HumaGate } from "usehuma/react";
<HumaGate apiKey="huma_live_..." userId={userId} fallback={<Spinner />}>
<SensitiveContent />
</HumaGate>
// Next.js: verify on the server, key stays in HUMA_API_KEY
import { verifyHumaSession } from "usehuma/next";
const result = await verifyHumaSession({ userId, sessionData });One thing to know. The browser entries send your API key from the page, which is fine while testing; in production keep the key on your server, either with usehuma/next or by pointing endpoint at your own proxy route. Since 1.2.2 the SDK collects the same signal families as huma.js, so responses include the agent verdict on plans that carry it; on 1.2.1 and earlier they do not.
No package is required, though. The whole surface in any language is a script tag and one POST:
// 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 separate server-only package, @usehuma/node, exists in the repository but is not on npm; usehuma/next covers the same ground. Missing something? Say so at team@humaverify.com and it moves up.
Get your API key in seconds. Free plan: 1,000 verifications a month, no card, no expiry.
GET YOUR API KEY →