Backend: POST the same JSON body to …/humanproof/verify. The response includes status, suggestedAction, reasonCode, and accept (boolean — true only when the submission should be treated as verified). Call from your server only—never send secretKey in the browser.
Some forms require a hostname field on verify (it must match boundHost from the client payload—the host used when the challenge was issued). If requireVerifyHostname is enabled, omitting hostname returns reasonCode HOSTNAME_REQUIRED. 127.0.0.1 and localhost are different; allow both in hostnames or align your site URL and data-challenge-hostname with the verify body.
Script and API URLs on this page follow your configuration (production defaults to api.agenthouse.org; set humanproof.publicSdkBase in secrets.json to point at a dev API).
When the SDK must fetch a challenge and solve proof-of-work on submit, it dispatches the CustomEvent humanproof.securingForm on window (detail includes form, siteKey, and formId) so your app can show progress. When securing completes and the signed proof is attached—immediately before the native submit—it dispatches agenthouse_humanproof_verified_submit on the form (bubbles; detail includes form, siteKey, formId, and proof metadata). That event means client-side securing succeeded only; your backend must still call verify. On failure it dispatches humanproof-error on the form.
window.addEventListener('humanproof.securingForm', function (e) {
/* e.detail.form, siteKey, formId */
});
form.addEventListener('agenthouse_humanproof_verified_submit', function (e) {
/* e.detail.form, siteKey, formId, proof.challengeId, proof.proof, … */
});
By default the SDK locks the form (input, select, textarea, button — anything not already disabled) while it fetches the challenge and solves proof-of-work. The form gets data-humanproof-securing=1 and aria-busy=true for styling and accessibility, and is unlocked just before submission (or immediately on error). Turn it off with data-lock-while-securing=false on the script, or per-form via data-humanproof-lock-while-securing=false.
You cannot solve proof-of-work before the server issues a challenge. Optional: set data-prefetch-challenge-delay-ms on the script (milliseconds after load) to fetch a challenge and solve in the background; submit then reuses the proof while it is still valid. Use data-prefetch-expiry-margin-ms to require that much remaining TTL before reuse (clock skew). Per-form overrides: data-humanproof-prefetch-challenge-delay-ms and data-humanproof-prefetch-expiry-margin-ms on the form.
HTML
HTML multi-form
Node.js
PHP
Python
<form id="contact-form" data-humanproof="YOUR_FORM_ID">
<label>Email <input type="email" name="email" required></label>
<button type="submit">Send</button>
</form>
<script
src="https://api.agenthouse.org/humanproof/humanproof.js"
data-site-key="YOUR_PUBLIC_SITE_KEY"
data-powered-by="true"
data-endpoint="https://api.agenthouse.org/humanproof"
data-prefetch-challenge-delay-ms="2500"
data-prefetch-expiry-margin-ms="15000"
></script>
<form id="a" data-humanproof="form-a">...</form>
<form id="b" data-humanproof="form-b">...</form>
<script
src="https://api.agenthouse.org/humanproof/humanproof.js"
data-site-key="YOUR_PUBLIC_SITE_KEY"
data-powered-by="true"
data-endpoint="https://api.agenthouse.org/humanproof"
data-prefetch-challenge-delay-ms="2500"
data-prefetch-expiry-margin-ms="15000"
></script>
const verifyUrl = 'https://api.agenthouse.org/humanproof/verify';
const secretKey = ''; // set from your secrets store before use
const payload = JSON.parse(formFields.humanproof);
const body = { secretKey, ...payload };
if (typeof body.hostname !== 'string' || !body.hostname.trim()) {
if (typeof body.boundHost === 'string' && body.boundHost.trim()) {
body.hostname = body.boundHost.trim();
}
}
const result = await (await fetch(verifyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})).json();
if (!result.accept) return; // do not process as a verified submit
<?php
$url = 'https://api.agenthouse.org/humanproof/verify';
$secret = ''; // set from your server secret store before use
$payload = json_decode($_POST['humanproof'], true);
$body = array_merge(['secretKey' => $secret], $payload);
if (empty($body['hostname']) && !empty($body['boundHost'])) {
$body['hostname'] = $body['boundHost'];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
]);
$out = curl_exec($ch);
$result = json_decode($out, true);
// if (empty($result['accept'])) { return; }
?>
import json
import urllib.request
VERIFY_URL = "https://api.agenthouse.org/humanproof/verify"
def verify_humanproof(secret_key: str, payload: dict) -> dict:
body = {"secretKey": secret_key, **payload}
if not (isinstance(body.get("hostname"), str) and body["hostname"].strip()):
bh = body.get("boundHost")
if isinstance(bh, str) and bh.strip():
body["hostname"] = bh.strip()
req = urllib.request.Request(
VERIFY_URL,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
r = json.load(resp)
# if not r.get("accept"): return
return r