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

ShipDo not ship
The script tag, or a React component that loads the same filenpm install @imnotahuman/react or @imnotahuman/server
POST /api/v1/tokens/verify with the token, your site key, and consume: trueA decision based only on the widget callback or a hidden form field
site_test_… on staging, with allow_test: trueallow_test on a production request
site_live_… once a registered agent can signA claim that the visitor is safe, authorised, or free of human involvement
One statement

A verified token means a recognized agent credential signed this challenge. It does not grant access. See Threat model.

Before you start

  1. Sign in with your email and password, then create a site for your domain.
  2. You get site_live_… and site_test_…. Both are public. Neither is a secret.
  3. 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 needs http://localhost:5173 (or whichever port you use) under allowed origins. A non-default HTTPS port is not covered by the domain rule.
  4. 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.

checkout.html
<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.

Checkout.tsx
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.

Production
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
  }'
200
{
  "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:

Staging only
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

verify-agent.ts
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_at

After 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

  1. On staging, use your site_test_… key. The widget shows Test mode and a Simulate agent button. That button calls POST /api/v1/challenges/:id/simulate. You do not need a private key for this step.
  2. Confirm your server accepts that token only when allow_test: true is sent, and rejects it when the field is absent.
  3. Submit the same token again. The second call returns TOKEN_ALREADY_USED if the first used consume: true.
  4. Switch the page to your site_live_… key. Remove allow_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_key to that same key.
  • The server treats every non-200, and every body where verified is not true, as a refusal.
  • Production refuses mode: "test" by not sending allow_test.
  • The granting request sends consume: true once. Do not verify the same token again on a later hop.
  • verification.action matches 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.