Provisioning CAPTCHA widgets per site from your own admin API

Create a widget, get a sitekey and an embed snippet back, and keep the record id. Why one widget per site beats one shared across tenants.

If you host forms on behalf of customers, each of their domains needs its own CAPTCHA configuration — and doing that through a vendor dashboard doesn’t scale past a handful. Infrai’s POST /v1/captcha/widget/create takes a name, the domains it’s valid for and a widget_mode, and returns a sitekey, an embed_snippet and the widget_record_id you’ll verify against.

One call per customer site, from your own provisioning code.

Create a widget

curl -sS -X POST "https://api.infrai.cc/v1/captcha/widget/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "northwind-portal",
    "domains": ["portal.northwind.example"],
    "widget_mode": "managed"
  }'
{
  "ok": true,
  "data": {
    "widget_record_id": "cwg_2fVc8nRqLmT4xBzY",
    "name": "northwind-portal",
    "vendor": "hcaptcha",
    "status": "active",
    "sitekey": "10000000-ffff-ffff-ffff-000000000001",
    "widget_id": "wid_9wQ1zV6pLkS3dHyB",
    "domains": ["portal.northwind.example"],
    "embed_url": "https://js.hcaptcha.com/1/api.js",
    "embed_snippet": "<div class=\"h-captcha\" data-sitekey=\"10000000-ffff-ffff-ffff-000000000001\"></div>",
    "created_at": "2026-09-21T03:41:00Z"
  }
}

Two identifiers, two audiences, and mixing them up is the most common integration bug here. The sitekey is public and belongs in the page. The widget_record_id is what your backend passes to POST /v1/captcha/verify — it’s the handle for the configuration, not a secret in the cryptographic sense, but it has no business in your HTML.

embed_snippet is the markup, ready to render. Using it rather than assembling your own means a vendor-side change in the embed shape doesn’t require a code change on your side.

Three widget modes

widget_mode accepts managed, non-interactive and invisible, and the choice is a friction decision rather than a security one.

managed shows a checkbox the user interacts with — most visible, most obviously “there is a CAPTCHA here”. non-interactive renders a badge but doesn’t ask the user to do anything unless the signal is poor. invisible shows nothing at all and relies entirely on the score.

ModeUser seesUse for
manageda checkboxsignup, password reset, anything an attacker targets directly
non-interactivea badgelogins, comment forms
invisiblenothinghigh-volume, low-value actions where friction costs conversions

The honest trade: invisible means every decision rests on score, so your verify handler needs a considered score_threshold and a plan for what happens to a borderline human. managed is blunter and more reliable.

Provision as part of domain onboarding

A widget is per-domain, so the natural place to create it is the same flow that verified the domain:

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def provision_widget(tenant_slug: str, hostname: str, mode: str = "managed") -> dict:
    """Create the widget for a tenant's hostname. Name it after the tenant so the
    listing is legible six months from now — the name is the only field you
    control and the only one an audit can read."""
    resp = SESSION.post(
        f"{API}/v1/captcha/widget/create",
        json={"name": f"{tenant_slug}-portal", "domains": [hostname], "widget_mode": mode},
        timeout=30,
    )
    body = resp.json()
    if not body.get("ok"):
        raise RuntimeError(body["error"]["code"])
    data = body["data"]
    # Store the record id server-side; ship only the sitekey to the browser.
    return {
        "widget_record_id": data["widget_record_id"],   # backend, for verify
        "sitekey": data["sitekey"],                     # page, public
        "embed_snippet": data["embed_snippet"],
        "status": data.get("status"),
        "domains": data.get("domains"),
    }


def widget(record_id: str) -> dict:
    resp = SESSION.get(f"{API}/v1/captcha/widget/get/{record_id}", timeout=20)
    resp.raise_for_status()
    return resp.json()["data"]


def inventory() -> list[dict]:
    out, cursor = [], None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        resp = SESSION.get(f"{API}/v1/captcha/widget/list", params=params, timeout=25)
        resp.raise_for_status()
        page = resp.json()["data"]
        out += page.get("items", [])
        cursor = page.get("next_cursor")
        if not cursor:
            return out


if __name__ == "__main__":
    print(provision_widget("northwind", "portal.northwind.example"))

Read the inventory back

curl -sS "https://api.infrai.cc/v1/captcha/widget/list?limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": { "items": [], "total_count": 0, "next_cursor": null }
}

total_count and a cursor, so the listing pages. An empty list on an account that should have widgets is the first thing to check when verification starts failing for a tenant — a widget that was never created can’t verify anything.

GET /v1/captcha/widget/get/{widget_record_id} returns the same record for one widget, including its status and the domains it’s valid for. That domains list is what makes expected_hostname on the verify call meaningful, so it’s worth asserting in a test that they match what you think they are.

Why one widget per site

A single shared widget across every customer domain seems simpler and costs you the hostname check: if the widget is valid for fifty domains, a token solved on any of them verifies against any other. Per-site widgets keep that boundary, and they also mean revoking or reconfiguring one customer’s protection doesn’t touch anyone else’s.

The cost is more records to manage, which is exactly what an API is for.

Limitations

Widget creation reports hcaptcha as its ready vendor today — GET /v1/discovery/captcha.widget.create is the live answer — so provisioning is single-vendor even though verification accepts more than one. There’s also no update or delete in this surface: you can create, get and list, so changing a widget’s domains means creating a new one and repointing your config, and retired widgets accumulate in the listing.

Going direct to Turnstile or hCaptcha gets you their dashboards, analytics on challenge outcomes, and per-widget threshold tuning — genuinely more control, and the better fit if CAPTCHA configuration is something your team wants to iterate on weekly rather than provision once.

What’s already on the key is the flow around it. The domain the widget protects was verified with POST /v1/dns/domain/verify, the signup behind the form runs on POST /v1/auth/email/send_code, and a verification anomaly lands in POST /v1/errors/capture — one credential, one invoice, one GET /v1/account/usage. Widget management reports billing_class: free in discovery; verification is the billed call (verified 2026-09-21), and those rates drift downward as vendor contracts improve.

References

Browse more captcha developer guides