One container element and one script tag. The widget finds the nearest
<form> ancestor and writes a hidden captcha-token field into it.
<div data-sitekey="YOUR_SITE_KEY"></div>
<script src="https://api.bollwark.eu/v1/widget.js"></script>Because the script is served from api.bollwark.eu, the widget fetches its
puzzle from that same origin automatically. If you proxy or bundle the script
from your own origin, name the service explicitly:
<div
data-sitekey="YOUR_SITE_KEY"
data-server-url="https://captcha.example.com"
></div>Modes
data-mode selects how much chrome renders.
default(the value when the attribute is omitted) — the checkbox row and brand footer always render, and every visitor sees the same "I'm not a robot" → spinner → "Verified" sequence. Low-risk visitors get the spinner without clicking; escalated tiers wait for a click.invisible— nothing renders for a low-risk visitor; the proof of work runs in the background and the token is injected on submit. If the service escalates the tier, the visible UI appears on demand.
In invisible mode a blocked visitor sees nothing at all, because there is
nothing to render. Listen for the bollwark:puzzle event to surface your own
failure state:
document.getElementById("captcha").addEventListener("bollwark:puzzle", (e) => {
// e.detail = { ok, tier, difficulty?, error? }
if (!e.detail.ok) {
showSignupBlockedMessage(e.detail.tier);
}
});The event bubbles, and it fires in default mode too.
Theme and language
data-theme takes auto (the default — follows prefers-color-scheme),
light or dark. Force a value when the host page's theme is fixed
regardless of the OS.
The widget ships translated into en, de, fr, es, it and nl, and picks a
locale from the first source that names a bundled language: data-lang on the
container, then <html lang>, then navigator.language. A page with
<html lang="de"> gets a German widget with no configuration.
Accessibility
The checkbox is reachable by <kbd>Tab</kbd> and activated with <kbd>Space</kbd>
or <kbd>Enter</kbd>, carries role="checkbox" with a tracking aria-checked,
and announces its state changes through live regions rather than visually only.
Keyboard and screen-reader use carries no scoring penalty. The behavioural signal's "no pointer movement" penalty only applies to submissions that also show at most one interaction — the isolated synthetic click it exists to catch. Anyone navigating by keyboard produces an interaction per keystroke.
If you restyle the widget, keep a visible :focus indicator on
.rc-captcha-checkbox.
Single-page apps
When your form calls an API instead of posting, the token is still written into
the enclosing <form> — nothing submits it, so read it yourself at submit time:
const result = await Bollwark.token(formEl);
if (!result.ok) return showError(result.reason);
await api.signup({ email, captchaToken: result.token });Bollwark.token() never rejects. Every outcome is a value, so the failures
stay distinguishable:
reason |
What happened |
|---|---|
needs-interaction |
An escalated tier rendered a checkbox nobody has clicked yet. |
blocked |
Refused at puzzle time. In invisible mode this is the only way you learn of it. |
unreachable |
Script never loaded or the puzzle fetch failed — usually a blocker extension. |
no-form |
Solved, but the container has no <form> ancestor to write into. |
timeout |
Still solving after timeoutMs (default 15s). |
Two things no helper can do for you:
- Keep the container inside a
<form>. It never has to be submitted — it just has to exist, or there is nowhere to write the token. - Call
Bollwark.token()at submit time, not at mount, so the behavioural counters reflect the visitor rather than the moment the worker finished.