Introduction
Ship this week
Put widget.js on a page, check the token with POST /api/v1/tokens/verify, and go live with a test key and a live key. No npm install.
This is the website path that works with the service as it is today. You embed widget.js, your server calls POST /api/v1/tokens/verify, and your application decides what that credential may do. @imnotahuman/react and @imnotahuman/server are not on npm.
What you can ship
| Ship | Do not ship |
|---|---|
| The script tag, or a React component that loads the same file | npm install @imnotahuman/react or @imnotahuman/server |
| POST /api/v1/tokens/verify with the token, your site key, and consume: true | A decision based only on the widget callback or a hidden form field |
| site_test_… on staging, with allow_test: true | allow_test on a production request |
| site_live_… once a registered agent can sign | A claim that the visitor is safe, authorised, or free of human involvement |
A verified token means a recognized agent credential signed this challenge. It does not grant access. See Threat model.
Before you start
- Sign in with your email and password, then create a site for your domain.
- You get
site_live_…andsite_test_…. Both are public. Neither is a secret. - Production
https://on that domain and its subdomains is allowed with no extra row. Add every other origin exactly, including scheme and port. Local work needshttp://localhost:5173(or whichever port you use) under allowed origins. A non-default HTTPS port is not covered by the domain rule. - Your backend must be able to POST to imnotahuman.ai. No API secret is required to verify a token.
Example ids in this guide, such as site_live_exampleweb2ab3cd4ef5gh6j, are placeholders.
HTML
Omit async on the widget script if the next script calls imnotahuman. The host is inserted immediately after the script tag, so keep that tag inside the form if you want the token submitted with it.
<form method="post" action="/checkout">
<!-- widget.js inserts its host, and a hidden imnotahuman-token input, after this tag. -->
<script src="https://imnotahuman.ai/widget.js"
data-site-key="site_live_exampleweb2ab3cd4ef5gh6j"
data-api="https://imnotahuman.ai"
data-action="checkout"></script>
<button type="submit">Continue</button>
</form>
<script>
imnotahuman.onVerified(function (result) {
// UI signal only. Forward result.token; decide on the server.
// result.verified === true
// result.type === "agent"
// result.agent_id, result.agent_name, result.mode, result.challenge_id
// result.verification_method, result.token, result.issued_at, result.expires_at
});
</script>The callback shape is the widget's VerifiedResult, not the JSON from /tokens/verify. There is no result.agent object in the browser callback: the id is result.agent_id. Attributes, the queue snippet, and CSP are on JavaScript.
React
Save the component on the React page as NotHuman.tsx. It loads https://imnotahuman.ai/widget.js and calls render() with api set to https://imnotahuman.ai, the same origin as data-api on the script tag above.
import { useState } from "react";
import { NotHuman } from "./NotHuman";
export function Checkout() {
const [token, setToken] = useState<string | null>(null);
return (
<form action="/api/checkout" method="post">
<NotHuman
siteKey="site_live_exampleweb2ab3cd4ef5gh6j"
action="checkout"
onVerified={(result) => setToken(result.token)}
/>
<button type="submit" disabled={token === null}>Continue</button>
</form>
);
}Put NotHuman inside the <form>. The hidden input is created on the widget host, which lives inside the component. Disable submit until result.token is a string: a verified result can still carry token: null if the token was already consumed or expired when the widget polled.
Verify with curl
Run this on the server that receives the form or the token your page forwarded. consume is not the default. If you omit it, the token stays reusable until it expires (10 minutes) or a later call consumes it.
curl -sS -X POST https://imnotahuman.ai/api/v1/tokens/verify \
-H 'content-type: application/json' \
-H 'accept: application/json' \
-d '{
"token": "<imnotahuman-token>",
"site_key": "site_live_exampleweb2ab3cd4ef5gh6j",
"consume": true
}'{
"verified": true,
"status": "VERIFIED",
"subject_type": "agent",
"mode": "live",
"site_key": "site_live_exampleweb2ab3cd4ef5gh6j",
"agent": { "id": "agent_nova_8f2a", "name": "Nova" },
"verification": {
"method": "ed25519_challenge",
"verified_at": "2026-09-20T12:00:07.000Z",
"challenge_id": "ch_mfr4q2xk7tbn3ycz5w6ah2ep4d",
"origin": "https://example.com",
"action": "checkout",
"intent": "purchase"
},
"consumed": true,
"expires_at": "2026-09-20T12:10:07.000Z"
}Any other status is not verified. The body is { "error": { "code", "message" } }. Staging, with a test key and the simulate button:
curl -sS -X POST https://imnotahuman.ai/api/v1/tokens/verify \
-H 'content-type: application/json' \
-H 'accept: application/json' \
-d '{
"token": "<imnotahuman-token>",
"site_key": "site_test_exampleweb2ab3cd4ef5gh6j",
"consume": true,
"allow_test": true
}'Drop allow_test before production. A test token then fails with TOKEN_TEST_MODE.
The same call from your backend
const response = await fetch("https://imnotahuman.ai/api/v1/tokens/verify", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
token, // hidden input "imnotahuman-token" or result.token
site_key: siteKey, // site_live_… in production
consume: true, // omit allow_test in production
}),
});
const body = await response.json();
if (!response.ok || body.verified !== true) {
// body.error.code — TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_SITE_MISMATCH,
// TOKEN_ALREADY_USED, TOKEN_TEST_MODE, …
throw new Error(body.error?.code ?? "NOT_VERIFIED");
}
// body.status === "VERIFIED"
// body.mode === "live" | "test"
// body.agent.id, body.agent.name
// body.verification.method, .action, .intent, .origin, .challenge_id
// body.consumed === true
// body.expires_atAfter a 200, check the fields you actually depend on: mode is "live", verification.action matches this route, and verification.origin is the page you issued the challenge on. Which agent ids you accept is your allowlist, not ours. Details and offline JWKS verification are on Server verification.
Staging, then live
- On staging, use your
site_test_…key. The widget shows Test mode and a Simulate agent button. That button callsPOST /api/v1/challenges/:id/simulate. You do not need a private key for this step. - Confirm your server accepts that token only when
allow_test: trueis sent, and rejects it when the field is absent. - Submit the same token again. The second call returns
TOKEN_ALREADY_USEDif the first usedconsume: true. - Switch the page to your
site_live_…key. Removeallow_test. The simulate button disappears. The widget stays PENDING until a registered agent signs the challenge. Register one with Register an agent if you need to complete a live challenge yourself.
Go-live checklist
- Production origin is allowed. Localhost origins are not required in production.
- The page uses the live site key. The server compares
site_keyto that same key. - The server treats every non-200, and every body where
verifiedis nottrue, as a refusal. - Production refuses
mode: "test"by not sendingallow_test. - The granting request sends
consume: trueonce. Do not verify the same token again on a later hop. verification.actionmatches the operation. A token for login does not authorise checkout.- You do not log the token. If you need an audit row, store the agent id and challenge id.
- Copy on your site does not say the visitor was proved safe or human. The widget's own verified line names the agent.
When it fails
Origin errors, a widget that stays pending, test tokens, and replay are on Troubleshooting. Error codes and HTTP statuses are on Errors.