Installation
Load the embed script and mark a container. Everything else is automatic.
<script src="https://privcaptcha.com/1/api.js" async defer></script>
<div class="privcaptcha" data-sitekey="pk_your_sitekey"></div>
The script is cached for 10 minutes, so a widget fix reaches your visitors the same day without you redeploying anything.
Container attributes
| Attribute | Required | Meaning |
|---|---|---|
class="privcaptcha" |
yes, for automatic rendering | marks the container the loader scans for |
data-sitekey |
yes | your public sitekey |
data-callback |
no | name of a global function called with the token on success |
data-expired-callback |
no | name of a global function called when the token lapses |
data-error-callback |
no | name of a global function called when a challenge or solve request fails |
data-theme |
no | light (default) or dark, the same values as hCaptcha and reCAPTCHA |
data-required |
no | make the widget a required form field - the browser blocks submit until it is solved |
data-required-message |
no | the message shown in that browser bubble (default: "Please confirm that you are human.") |
<div class="privcaptcha"
data-sitekey="pk_your_sitekey"
data-callback="onCaptchaSolved"></div>
<script>
function onCaptchaSolved(token) {
document.querySelector('#submit').disabled = false;
}
</script>
The response field
On success the token is written into a hidden input named privcaptcha-response inside the
container. A normal form submit carries it; you do not need any JavaScript.
Read it server-side as you would any form field, then
verify it. If you submit with fetch() instead, read the token with
privcaptcha.getResponse().
Making it required
Add data-required and the widget behaves like any other required field: the browser refuses
to submit the form and shows its native validation bubble at the widget until the user has
solved it.
<div class="privcaptcha"
data-sitekey="pk_your_sitekey"
data-required
data-required-message="Please confirm that you are human."></div>
The token field then renders as a visually hidden text input instead of a type="hidden" one,
because hidden inputs are excluded from HTML5 constraint validation. Nothing else changes: the
field keeps the same name and the same value.
This is a client-side convenience only - the browser can be bypassed. You must still verify the token server-side on every submit.
It only applies to forms the browser itself validates. A form with novalidate, or one
submitted from JavaScript without calling form.reportValidity() first, is not affected.
Explicit rendering
Skip the privcaptcha class and render when you are ready — useful for dynamically inserted
forms, modals or single-page apps.
<div id="captcha-slot"></div>
<script>
var id = privcaptcha.render('captcha-slot', {
sitekey: 'pk_your_sitekey',
callback: function (token) { console.log(token); },
'expired-callback': function () { console.log('token expired'); },
'error-callback': function (err) { console.error(err); },
});
</script>
render() accepts an element or an element id and returns a widget id you can pass to the
other JavaScript API methods. It returns null if no sitekey
was given.
Layout
The badge is a 302×76 iframe. The puzzle opens in a centred overlay above the page
(z-index: 2147483647), dismissible with Escape or a click outside. Nothing is injected
into your page's styles, and the iframes carry a frame-ancestors policy scoped to your
site's own domains, so no one else can frame your
widget.
The iframe endpoints
The loader builds these itself; you never link to them directly, but they show up in a CSP report or a network tab, so they are listed here.
| Path | What it is |
|---|---|
/widget/badge |
the 302×76 checkbox iframe |
/widget/challenge |
the puzzle iframe inside the overlay |
Both take the sitekey and the widget theme as query parameters and answer with a
Content-Security-Policy: frame-ancestors header scoped to that site's domains, so only
your own pages can frame them. A site with an empty domain list gets *, so a first
integration works before the list is configured; an unknown sitekey gets 'none'. Both are
noindex.
Lifetime
A token is valid for 120 seconds server-side. The widget clears it after 110 seconds, resets
itself and fires expired-callback, so a slowly-filled form never submits a dead token.