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.

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

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>
  );
}

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

PropTypeDescription
siteKeyrequiredstringPublic site key.
onVerified(result: AgentProof) => voidCalled when the server reports VERIFIED. Same object as window.imnotahuman.onVerified.
onChange(event: AgentChange) => voidCalled 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.
actionstringHost-declared action bound into the challenge.
intentstringHost-declared intent.

Behaviour

  • Changing siteKey, theme, action or intent remounts 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 ttl prop; use the JavaScript API if you need a non-default challenge lifetime.

AgentProof

Type
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

Not on npm

@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.