# Integration Guide This guide shows the full flow for adding bollwark to a real application: 1. Run the CAPTCHA service. 2. Register your application as a site. 3. Embed the browser widget in your form. 4. Forward the opaque widget token from your backend. 5. Verify the token server-to-server before accepting the form. ## 1. Run the Service For local development: ```bash export ADMIN_TOKEN=$(openssl rand -hex 32) export SITE_DB_PATH=tmp/sites.db cargo run ``` The service listens on `http://localhost:3000` by default. `ADMIN_TOKEN` protects site provisioning and admin APIs. `SITE_DB_PATH` persists registered sites so restarts do not invalidate your `secret_key`. For production, also set a stable bind address and run behind TLS: ```bash LISTEN_ADDR=127.0.0.1:3000 ADMIN_TOKEN= SITE_DB_PATH=/var/lib/bollwark/sites.db ``` ## 2. Register a Site Call `POST /v1/sites` once for each application or environment. ```bash curl -s -X POST http://localhost:3000/v1/sites \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"my-production-app"}' | jq ``` Response: ```json { "site_key": "00000000-0000-0000-0000-000000000000", "secret_key": "hex-encoded-secret", "allowed_origins": [], "policy": {} } ``` Use the keys this way: | Key | Where it goes | |---|---| | `site_key` | Public frontend HTML, inside the widget | | `secret_key` | Private backend config only | Never expose `secret_key` to the browser. ### Optional: restrict browser origins Pass an `allowed_origins` array to limit which origins can request puzzles for this `site_key`: ```bash curl -s -X POST http://localhost:3000/v1/sites \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"my-app","allowed_origins":["https://app.example.com"]}' | jq ``` Each entry is a full web origin — `http(s)://host[:port]`, no path or trailing slash (up to 32 entries; values are lowercased and validated, a bad entry returns `400`). When the list is non-empty, `GET /v1/puzzle` returns `403` to a browser whose `Origin` header isn't on the list. Requests with **no** `Origin` header (same-origin embeds, server-to-server calls) always pass. This is **tenant hygiene, not bot defense**: it stops a third party from embedding your public `site_key` on their own page and burning your quota or polluting your stats. It is *not* a security control — a non-browser client can forge the `Origin` header, so the real trust boundary stays the `secret_key` at `/v1/verify`. Change the list later without rotating the secret via `PUT /v1/admin/sites/{site_key}/origins` with the same `allowed_origins` body. ### Optional: tune the thresholds for this site By default every site is scored with the server's env-var thresholds. Pass a `policy` to override any of them for this `site_key` alone — useful when one instance protects forms with different risk profiles (a contact form and a login endpoint want very different bands): ```bash curl -s -X POST http://localhost:3000/v1/sites \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name":"login", "policy":{"tier_hard_pow_min":25,"tier_block_min":60,"verify_block_min":30} }' | jq ``` Omitted fields inherit the server value (and keep tracking it if the operator changes it later), so a policy only ever states what differs. Change it later with `PUT /v1/admin/sites/{site_key}/policy` — the body replaces the stored policy wholesale, so `{}` clears every override. See **Per-site policy** in `CONFIGURATION.md` for the full field list and the validation rules. ### Optional: start in monitor mode If you're putting this in front of an existing form and want to see what it would do before it does it, register the site with `"mode": "monitor"`. Every verdict is scored and logged as usual, but no visitor is ever refused — a Block-tier request gets a max-difficulty puzzle instead of a `429`, and a verify-time block returns `success: true`. ```bash -d '{"name":"contact-form","policy":{"mode":"monitor"}}' ``` Watch `GET /v1/admin/stats` (`outcomes.verify_monitored`) for a week, then `PUT .../policy` with `{"mode":"enforce"}`. The scoring is identical in both modes, so nothing about the numbers changes when you flip it. ## 3. Embed the Widget Add the widget container and one script tag to the form you want to protect. ```html
``` > Examples here use `https://api.bollwark.eu`, the hosted endpoint. Self-hosting? Substitute your own host throughout — nothing below is specific to ours. `/v1/widget.js` is the entry point, and the only URL you should ever hardcode. It loads the stylesheet, the PoW worker and the Argon2 bundle itself, from a content-hashed directory pinned to that exact widget build — so a deploy can never leave you with a new widget talking to a worker your browser cached last month. The entry point is cached for 5 minutes; everything it pulls is cached for a year and never mutates. Because the script is served from `https://api.bollwark.eu`, the widget uses that origin for `GET /v1/puzzle` and its assets automatically. Nothing else to configure. If you bundle or proxy the script from your own app origin instead, set `data-server-url` explicitly: ```html
```
Older embeds using /static/captcha-widget.js The previous embed — a `` for `/static/captcha-widget.css` plus a ` ``` The `bollwark:puzzle` event fires in default mode too if you want to drive your own UI alongside the widget — it bubbles, so a listener on a parent element works. ### Theme The widget's appearance follows `data-theme`: - **`data-theme="auto"`** (used when the attribute is omitted): follows the visitor's OS via `prefers-color-scheme` — light on a light OS, dark on a dark one. - **`data-theme="light"`** / **`data-theme="dark"`**: force a fixed palette. Use this to match a host whose theme is fixed regardless of the OS (e.g. an always-dark dashboard). ```html
``` ### Accessibility The widget is operable without a mouse. The checkbox is reachable by Tab and activated with Space or Enter, carries `role="checkbox"` with an `aria-checked` value that tracks verification, and shows a focus outline. State changes ("Solving challenge…", "Verified", "Verification failed") and the block/failover messages are announced through live regions rather than being visual-only, and the widget's container is tagged with the resolved `lang` so translated text is pronounced correctly. Nothing is required of you to get this — but if you restyle the widget, keep a visible `:focus` indicator on `.rc-captcha-checkbox`. `e2e/tests/a11y.spec.ts` covers the whole keyboard path, including a full form submission with no mouse events at all. > 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 was written to catch. Anyone navigating by keyboard produces an interaction per keystroke, Tab and scroll, so they score zero on it. ### Language The widget ships translated. Bundled locales: **en, de, fr, es, it, nl**. English is the fallback, applied per string — a locale that is missing one entry falls back for that entry alone. The locale is picked from the first source that names a bundled language, most explicit first: 1. **`data-lang`** on the widget container — an explicit override, and the only way to pin a language different from the page's. 2. **``** — the page already declares what language it's in. 3. **`navigator.language`** — the visitor's browser preference, for pages that declare no language. `` deliberately outranks the browser: a widget sitting inside a German form should read German even for a visitor whose browser is set to English. Regional tags resolve to their base language (`de-AT` → `de`), and an unrecognised tag falls through to the next source rather than straight to English. Most integrations need nothing — a page with `` gets a German widget automatically. Override only when the widget's language should differ from the document's: ```html
``` The widget also sets `lang` on its own container so screen readers pronounce the translated text correctly. **Adding a language:** the strings live in one `TRANSLATIONS` table at the top of `static/captcha-widget.js` — eleven entries, no build step. `de` is maintained by the project; `fr`/`es`/`it`/`nl` follow the conventional phrasing other CAPTCHA widgets established but have not had a native review, so corrections are welcome. The `data-debug` panel is intentionally English — it's a developer tool. The widget writes a hidden form field named `captcha-token` whose value is a single **opaque token** (a hex string). It already carries everything `/v1/verify` needs — the challenge id, the PoW nonce, the honeypot, and behavioural telemetry. Your backend treats it as a black box: read the field and forward it verbatim. There is nothing to parse. ``` a3f1c0... # opaque; do not parse or depend on its contents ``` > Dwell time is **not** carried in the token. The server derives it from the challenge's issuance timestamp, so a bot can't claim a longer time-on-page than actually elapsed. ### Single-page apps: when nothing posts a form The flow above assumes a real form submission. If your form is a React/Vue/Svelte/Dioxus component that calls an API instead, the widget still works and still writes `captcha-token` into the enclosing `
` — nothing posts it, so you read the value out and send it yourself. Use `Bollwark.token()`. One `await`, and it handles the parts that are easy to get wrong: ```js const result = await Bollwark.token(formEl); // or the container element if (!result.ok) return showError(result.reason); await api.signup({ email, captchaToken: result.token }); ``` It never rejects — every expected outcome is a value, so a submit handler reads as a branch rather than a try/catch, and the failure cases stay distinguishable in a way a bare token-or-null cannot be: | `reason` | What happened | Reasonable response | |---|---|---| | `needs-interaction` | An escalated tier rendered a checkbox the visitor hasn't clicked. Returns immediately rather than holding a disabled button against a click that may never come. | "Please complete the verification." | | `blocked` | Refused at puzzle time (HTTP 429). In invisible mode nothing was rendered, so this is the only way you learn of it. | Show a failure state, or route to your own moderation path. | | `unreachable` | The script never loaded, the container never mounted, or the puzzle fetch failed. | Usually an ad/script blocker. Say so — it reads as an infra problem, not user error. | | `no-form` | Solved, but the container has no `` ancestor, so there was nowhere to write the token. | An integration bug; see point 2 below. | | `timeout` | Mounted and solving, but not finished within `timeoutMs` (default 15s). | Ask them to try again. | On success you get `{ ok: true, token, tier, failover }`. Pass `{ timeoutMs }` to override the deadline. Two things still to get right, which no helper can do for you: 1. **Keep the `
` inside a `` element.** The widget walks up to the nearest form to inject its hidden field; with no form ancestor there is nowhere to write the token (that's `reason: "no-form"`). The form never has to be *submitted* — it just has to exist. 2. **Call `Bollwark.token()` at submit time**, not at mount. It refreshes the token before returning so the behavioural counters reflect the visitor's actual interaction rather than the moment the worker happened to finish.
Doing it by hand `Bollwark.token()` is a convenience, not a requirement. The manual equivalent is to call `window.Bollwark.scan()` after your component mounts (auto-init runs once at `DOMContentLoaded`, so a form in a modal or a late route was never on the page then; `scan()` tags what it mounts, so re-running is a no-op rather than a second widget), then read `document.querySelector('input[name="captcha-token"]').value` at submit time. What you take on by doing that: the mount race in both directions, the asynchronous solve in invisible mode — where a fast submit beats the token and an empty field is indistinguishable from a failure — and telling `blocked` apart from `needs-interaction` apart from a script that never loaded, via the `bollwark:puzzle` event.
Your backend then calls `/v1/verify` with that string exactly as below — the token is opaque either way, and nothing about the server side changes. > **Cross-origin**: the widget fetches its puzzle from wherever the script came from, and that endpoint answers every origin with wildcard CORS, so a different origin to your app (the usual case) needs no server setting. If you registered the site with `allowed_origins`, your app's origin must be on that list or `GET /v1/puzzle` answers `403`. ## 4. Verify on Your Backend Your form handler should reject the submission if: - `captcha-token` is missing or empty. - `/v1/verify` does not return HTTP 200. - `/v1/verify` returns `{ "success": false }`. Forward the token verbatim: ```bash curl -s -X POST https://api.bollwark.eu/v1/verify \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "token": "" }' ``` > **Server-to-server callers** that build the request without the widget can send the explicit fields instead of `token`: `challenge_id` plus `nonce`, with optional `honeypot` and `behavior`. Forward the widget's `behavior` blob verbatim or omit it — never synthesise a constant one: identical activity-claiming blobs repeating on a site are scored as scripted traffic (see *Verify-time signals* in `CONFIGURATION.md`), and an omitted blob is neutral by default. Forward the visitor's IP too, if you can: ```bash -d '{ "token": "", "remote_ip": "" }' ``` `remote_ip` is optional. When present, the service compares it with the address the puzzle was issued to (IPv6 at /64); a mismatch is the shape of a **token farm** — puzzles solved on one machine, tokens handed to a fleet — and lands the submission in `risk: "elevated"`, never a refusal on its own. Pass the address you would rate-limit on: behind a proxy or CDN that is the resolved client IP, not the proxy's, or every submission reads as elevated. Only the top-level field counts; a `remote_ip` inside the opaque `token` is ignored, since the token is written by the visitor's browser. The `bollwark-verify` crate exposes this as `Client::verify_from(token, ip)`. Successful response: ```json { "success": true, "failover": false, "risk": "low", "challenge_id": "…" } ``` Failed response: ```json { "success": false, "failover": false, "risk": "high", "challenge_id": "…", "signals": ["honeypot"] } ``` Challenges are single-use. A second submit with the same `challenge_id` will fail. ### The `risk` field `risk` is the verify-time verdict as a band — `low`, `elevated` or `high` — so you can step up on a submission the service accepted but did not like. `success` stays the enforcement decision; `risk` is the reason behind it: | `risk` | `success` | Meaning | Suggested handling | |---|---|---|---| | `low` | `true` | Solved, nothing suspicious at submit time | Accept | | `elevated` | `true` | Solved, but verify-time signals landed in the shadow band (`VERIFY_SHADOW_MIN`) | Accept, and step up where the action warrants it: email confirmation, hold for review, tighter downstream limits | | `high` | `false` | Solved, but refused on risk (`VERIFY_BLOCK_MIN`) | Reject or queue for manual review | | `high` | `true` | Would have been refused, but the site is in monitor mode | Log it — this is what the site will reject once you flip to `enforce` | `risk` is absent when there was no risk verdict to report: an invalid proof of work (`success: false`, no band). Servers from before the field existed omit it; treat a missing band as `low`. The `bollwark-verify` crate exposes it as `Verdict::Passed { risk, .. }`. ### The `signals` field `signals` names the verify-time checks that contributed to `risk`: any of `honeypot`, `time_on_page`, `behavior` and `remote_ip`, in that order. The names are a stable contract — they are the same names the operator dashboard and decision log use — but the weights behind them are not, so no per-signal score is exposed. The field is omitted when nothing fired, which is every clean submission, and on the failover path and an invalid proof of work, where no verify-time scoring is reported. Treat a missing field as an empty list. #### Enriching Stripe Radar If the form leads to a payment, forward the verdict as PaymentIntent `metadata` so Radar rules can match on it, e.g. `Review if ::bollwark_risk:: = 'elevated'` or `Block if ::bollwark_signals:: INCLUDES 'honeypot'`: ```js await stripe.paymentIntents.create({ amount, currency: "eur", metadata: { bollwark_risk: verdict.risk ?? "low", bollwark_signals: (verdict.signals ?? []).join(","), }, }); ``` ### The `failover` field `failover: true` means `success` was granted **without a solved puzzle**, because the captcha service was attestably unreachable when the visitor loaded your form. It's off unless the operator enabled client failover (see `CONFIGURATION.md`). If you don't care, keep reading `success` alone — you get availability during outages by default. If you do, this is the hook for accept-but-flag: ```js if (!result.success) return res.status(400).send("CAPTCHA failed"); if (result.failover) { // Verified only in the weak sense: the service was down, so no proof of // work was possible. The honeypot and behavioural signals were still // checked. Reasonable responses: accept but queue for review, tighten a // downstream rate limit, or require email confirmation before acting. logger.warn({ userId }, "captcha failover — accepted without proof of work"); } ``` ## 4b. When the captcha service is down Two failure modes the `failover` field does **not** cover, because in both of them there's no verify response to read. **The widget script never loads.** A TLS, DNS, or CDN failure on `/v1/widget.js` means no widget code runs at all — so it can't fall back on your behalf. Only your page can detect this: ```html ``` **Your backend can't reach `/v1/verify`.** A connection error or timeout is not a failed verification — it's an unknown. Decide deliberately, and don't let it read as `success: false` by accident: ```js let result; try { result = await verifyCaptcha(token); } catch (err) { // Fail open or closed — a real choice, not a default. Fail closed is safer // for account creation or payments; fail open is usually right for a // contact form, where blocking every visitor is the worse outcome. logger.error({ err }, "captcha verify unreachable"); if (FAIL_CLOSED) return res.status(503).send("Try again shortly"); result = { success: true, failover: true }; } ``` The widget also emits `bollwark:puzzle` with `detail.failover === true` when it enters failover mode, and `detail.recovered === true` if the service comes back and it upgrades to a real solved token in place. ## 4c. Tell the service what it got wrong The verify-time score is only as good as the ground truth behind it, and the service never sees what happens to a submission after it answers. When you know — a `risk: "low"` submission that turned out to be spam, a `risk: "elevated"` one that was a real customer — say so: ```bash curl -s -X POST https://api.bollwark.eu/v1/feedback \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "challenge_id": "", "verdict": "spam" }' ``` `verdict` is `spam` or `legit`; `204` on success. `/v1/verify` returns the `challenge_id` next to `success`, so keep it alongside the submission if you may want to label it later (the `bollwark-verify` crate carries it on `Verdict::Passed` and `Verdict::Blocked`). Only your own site's challenges can be labelled — anyone else's is a `404` — and only the id and the verdict are stored, never the submission. The label lands on an anonymised training record that carries no IP and no user-agent, and it still counts if it arrives days later. ## 5. Backend Example: Express ```js app.post("/signup", express.urlencoded({ extended: false }), async (req, res) => { const token = req.body["captcha-token"]; if (!token) { return res.status(400).send("CAPTCHA failed"); } const verifyResp = await fetch("https://api.bollwark.eu/v1/verify", { method: "POST", headers: { "Authorization": `Bearer ${process.env.BOLLWARK_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ token }), }); if (!verifyResp.ok) { return res.status(400).send("CAPTCHA failed"); } const result = await verifyResp.json(); if (!result.success) { return res.status(400).send("CAPTCHA failed"); } // CAPTCHA passed. Continue with your real signup logic. res.send("ok"); }); ``` ## 6. Backend Example: Rust Use [`bollwark-verify`](crates/bollwark-verify) rather than the hand-rolled version below: ```toml bollwark-verify = { version = "0.1", features = ["axum"] } ``` ```rust use bollwark_verify::{Client, Error, Verdict}; let client = Client::new("https://api.bollwark.eu", std::env::var("BOLLWARK_SECRET_KEY")?); match client.verify(token).await { Ok(Verdict::Passed { failover }) => accept(failover), // Recoverable — the visitor left the tab open, or double-submitted. // Ask for a resubmit rather than reporting a failure. Ok(Verdict::Expired | Verdict::Replayed) => ask_to_resubmit(), Ok(Verdict::Blocked) => refuse(), // Not a failed check: the service never got to decide. Fail closed for // signup or payments, open for a contact form — but decide. Err(Error::Unreachable(e)) => on_outage(e), Err(e) => return Err(e.into()), } ``` With the `axum` feature, `Captcha` does this as an extractor, so the handler only runs on a pass.
Without the crate ```rust use serde::Deserialize; #[derive(Deserialize)] struct VerifyResponse { success: bool, } /// `token` is the opaque value of the hidden `captcha-token` field, forwarded /// verbatim — no parsing. async fn verify_captcha(token: &str) -> anyhow::Result { if token.is_empty() { return Ok(false); } let client = reqwest::Client::new(); let resp = client .post("https://api.bollwark.eu/v1/verify") .bearer_auth(std::env::var("BOLLWARK_SECRET_KEY")?) .json(&serde_json::json!({ "token": token })) .send() .await?; if !resp.status().is_success() { return Ok(false); } let result: VerifyResponse = resp.json().await?; Ok(result.success) } ``` Note what this collapses, and decide whether you mean it: an expired challenge, a replayed one and a risk block all become `false`, and so does an outage — every visitor silently rejected by a fail-closed policy nobody chose. See §9 for the status codes.
## 7. Cross-Origin Setup None needed on the server. `GET /v1/puzzle` and the widget assets answer every origin with `Access-Control-Allow-Origin: *`, so an app at `https://app.example.com` embedding a widget served from `https://api.bollwark.eu` works as-is. To restrict which origins may use your site key, register the site with `allowed_origins` (or set it later with `PUT /v1/admin/sites/{site_key}/origins`). Origins outside the list get a `403` the widget can read and explain. The retired `CORS_ALLOWED_ORIGINS` env is ignored and logs a warning at boot. The service is cookie-free, so cross-origin embeds need no `SameSite` or credentials handling. ## 8. Production Checklist Before using this in a real application: - Set `ADMIN_TOKEN` to a long random value. - Set `SITE_DB_PATH` so registered sites survive restarts. - Store `secret_key` only in backend secrets/config. - Put the service behind HTTPS. - Register each site with `allowed_origins` so only your app's origin can spend its quota. - Decide whether to enable IP reputation (`IP_REPUTATION_FILE`) and TLS fingerprinting (`TLS_FINGERPRINT_HEADER` + `TRUSTED_PROXIES`). Both self-gate on their own config and stay off until set; enabling either gives stronger scoring but adds a fingerprinting signal, so update your DPIA accordingly. The service is otherwise cookie-free and runs every signal under legitimate interest with data minimization. - Configure `TRUSTED_PROXIES` if you rely on `X-Forwarded-For` or TLS fingerprint headers. - Keep `/v1/verify`, `/v1/sites`, and `/v1/admin/*` server-to-server only. - Monitor `puzzle_decision` and `verify_decision` logs before tightening thresholds. Optional dashboard: ```bash ADMIN_DB_PATH=/var/lib/bollwark/decisions.db ADMIN_TOKEN= ``` Then open: ```text https://api.bollwark.eu/static/admin.html ``` ## 9. Error Handling Common responses: | Case | Response | What to do | |---|---|---| | Missing or bad `site_key` | `400` from `/v1/puzzle` | Check frontend config | | Origin not on the site's allowlist | `403` from `/v1/puzzle` | Add the embedding origin via `allowed_origins`, or leave the allowlist empty to allow any origin. The widget shows the visitor "Verification isn't set up for this site" and logs the refused origin to the browser console. | | High-risk puzzle request (`block` tier) | `429` from `/v1/puzzle` | Show a retry/error state or fall back to your own moderation path. | | Missing verify auth | `401` from `/v1/verify` | Check backend `secret_key` | | Challenge expired | `410` from `/v1/verify` | Ask user to retry the form | | Replayed challenge | `404` from `/v1/verify` (single-use: the challenge is removed on the first successful verify) | Ask user to retry; do not accept the form | | Valid PoW but blocked risk score | `{ "success": false, "risk": "high" }` | Reject or queue for manual review | | Valid PoW, accepted with reservations | `{ "success": true, "risk": "elevated" }` | Accept; step up (confirmation, review) where the action warrants it | ## 10. Local Test Harness Run the service, then open: ```text http://localhost:3000/static/testsite.html ``` With `ADMIN_TOKEN` set, the test page prompts for it and registers a test site. In debug builds, you can skip the prompt for local/e2e testing: ```bash DEV_DISABLE_ADMIN_AUTH=1 SITE_DB_PATH=tmp/sites.db cargo run ``` Do not use `DEV_DISABLE_ADMIN_AUTH=1` in production. Release builds ignore it.