Website integration
React
Copy a NotHuman component that loads widget.js and calls render(). @imnotahuman/react is not published to npm.
Component
Save this as NotHuman.tsx. It loads https://imnotahuman.ai/widget.js once and calls window.imnotahuman.render() with api set to https://imnotahuman.ai, the same origin as data-api on the script tag. There is no verified prop. The callback runs only after the server reports VERIFIED.
import { useEffect, useRef } from "react";
export type AgentProof = {
verified: true;
type: "agent";
agent_id: string;
agent_name: string;
verification_method: string | null;
mode: "live" | "test";
challenge_id: string;
token: string | null;
issued_at: string | null;
expires_at: string | null;
};
export type AgentChange = {
status: string;
challenge: unknown;
result: AgentProof | null;
error: string | null;
};
type WidgetApi = {
render(
container: HTMLElement,
options: {
siteKey: string;
api?: string;
theme?: "auto" | "light" | "dark";
action?: string;
intent?: string;
ttl?: number;
},
): {
onVerified(cb: (result: AgentProof) => void): () => void;
onChange(cb: (event: AgentChange) => void): () => void;
destroy?: () => void;
};
};
declare global {
interface Window {
imnotahuman?: WidgetApi;
}
}
let loader: Promise<WidgetApi> | null = null;
function loadWidget(): Promise<WidgetApi> {
if (typeof window === "undefined") return new Promise(() => undefined);
if (window.imnotahuman?.render) return Promise.resolve(window.imnotahuman);
if (!loader) {
loader = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://imnotahuman.ai/widget.js";
script.async = true;
script.onload = () =>
window.imnotahuman?.render
? resolve(window.imnotahuman)
: reject(new Error("imnotahuman widget failed to initialise"));
script.onerror = () => reject(new Error("imnotahuman widget failed to load"));
document.head.appendChild(script);
});
}
return loader;
}
export function NotHuman({
siteKey,
onVerified,
onChange,
theme,
action,
intent,
}: {
siteKey: string;
onVerified?: (result: AgentProof) => void;
onChange?: (event: AgentChange) => void;
theme?: "auto" | "light" | "dark";
action?: string;
intent?: string;
}) {
const container = useRef<HTMLDivElement>(null);
const onVerifiedRef = useRef(onVerified);
const onChangeRef = useRef(onChange);
onVerifiedRef.current = onVerified;
onChangeRef.current = onChange;
useEffect(() => {
const parent = container.current;
if (!parent) return undefined;
const host = document.createElement("div");
parent.replaceChildren(host);
let disposed = false;
const offs: Array<() => void> = [];
let mounted: { destroy?: () => void } | null = null;
loadWidget()
.then((api) => {
if (disposed) return;
const widget = api.render(host, {
siteKey,
api: "https://imnotahuman.ai",
...(theme ? { theme } : {}),
...(action ? { action } : {}),
...(intent ? { intent } : {}),
});
mounted = widget;
offs.push(widget.onVerified((result) => onVerifiedRef.current?.(result)));
offs.push(widget.onChange((event) => onChangeRef.current?.(event)));
})
.catch(() =>
onChangeRef.current?.({
status: "ERROR",
challenge: null,
result: null,
error: "WIDGET_LOAD_FAILED",
}),
);
return () => {
disposed = true;
offs.forEach((off) => off());
mounted?.destroy?.();
host.remove();
};
}, [siteKey, theme, action, intent]);
return <div ref={container} />;
}WIDGET_LOAD_FAILED is set by this component when the script fails to load. It is not an API error code. A fresh child element is required because the widget attaches a closed shadow root, and an element can have only one.
Usage
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>
);
}The widget appends its hidden imnotahuman-token input inside the component's container, so the component has to sit inside the form. token stays null until the result includes one.
Props
| Prop | Type | Description |
|---|---|---|
siteKeyrequired | string | Public site key. |
onVerified | (result: AgentProof) => void | Called when the server reports VERIFIED. Same object as window.imnotahuman.onVerified. |
onChange | (event: AgentChange) => void | Called on every status change: { status, challenge, result, error }. If widget.js fails to load, called once with status "ERROR" and error "WIDGET_LOAD_FAILED". |
theme | "auto" | "light" | "dark" | Colour scheme. |
action | string | Host-declared action bound into the challenge. |
intent | string | Host-declared intent. |
Behaviour
- Changing
siteKey,theme,actionorintentremounts the widget and issues a new challenge. - Callbacks are read through refs, so passing new functions on each render does not remount.
- During server-side rendering the component outputs only its empty container; the widget mounts in
useEffect. - There is no
ttlprop; use the JavaScript API if you need a non-default challenge lifetime.
AgentProof
type AgentProof = {
verified: true;
type: "agent";
agent_id: string; // e.g. "agent_nova_8f2a"
agent_name: string;
verification_method: string | null;
mode: "live" | "test";
challenge_id: string;
token: string | null; // EdDSA JWT; verify it on your server
issued_at: string | null; // verification.verified_at from the server
expires_at: string | null; // token expiry (10 minutes)
};The service sets verification_method to ed25519_challenge, web_bot_auth, or simulated. The component types the field as string | null because that is what render() returns.
Private package
@imnotahuman/agent, @imnotahuman/react and @imnotahuman/server are private packages. They are not installed from npm. Use the script tag, the REST API, and the agent CLI at https://imnotahuman.ai/agent.mjs.
packages/react/src/index.tsx makes the same render() call, and adds an origin prop (default https://imnotahuman.ai) and a className prop. origin is passed as the api option, so challenge requests go to {origin}/api/v1 instead of the widget's built-in API base. The type there is named NotHumanResult and has the same fields as AgentProof.