hCaptcha siteverify: verifying tokens server-side.
Passing the hCaptcha widget produces a token, not a verdict. Your server gets the
verdict by POSTing that token to https://api.hcaptcha.com/siteverify
with your secret key. This page covers the full server side: the request
parameters, the response fields, working code in curl, Node, Python, and Go,
and the mistakes that quietly break real integrations.
The verification flow
When a visitor passes the widget, hCaptcha writes a token into a hidden
h-captcha-response field inside your form. Submitting the form sends
that token to your server along with the other fields:
POST /signup HTTP/1.1
Host: your-site.example
Content-Type: application/x-www-form-urlencoded
email=user%40example.com&h-captcha-response=P1_eyJ0eXAi...UV8w
That token proves nothing by itself. The browser controls everything in that
request, so a bot can put any string it likes in h-captcha-response.
The check that matters happens next: your server POSTs the token to
siteverify together with your secret key, a value only
your backend holds, and hCaptcha answers with a JSON verdict. Three steps total:
- The form posts
h-captcha-responseto your endpoint. - Your server POSTs
secret+responsetohttps://api.hcaptcha.com/siteverify. - You act on the JSON verdict: proceed when
successistrueand thehostnamematches, reject otherwise.
Request parameters
siteverify takes a POST with a
form-encoded body (application/x-www-form-urlencoded),
not JSON. Two parameters are required and two are optional:
| Parameter | Required | What it does |
|---|---|---|
secret | Yes | Your account secret key. Server-side only; never ship it to the browser. |
response | Yes | The token from the form’s h-captcha-response field. |
remoteip | No (recommended) | The end user’s IP. Improves accuracy and feeds risk scoring on Enterprise. |
sitekey | No | The sitekey you expect the token to belong to. Rejects tokens minted against a different key. |
The optional two earn their keep. remoteip gives hCaptcha more signal
and feeds risk scores on Enterprise plans. sitekey closes a subtle
hole: without it, a token solved against any sitekey under your account
verifies fine, so passing the key you expect pins the token to the right widget.
The response
A passing token comes back like this:
{
"success": true,
"challenge_ts": "2026-06-12T09:41:27Z",
"hostname": "your-site.example"
} A rejected one carries machine-readable reasons in error-codes:
{
"success": false,
"error-codes": ["expired-input-response"]
} | Field | Type | Meaning |
|---|---|---|
success | boolean | Whether the token is valid and meets your security criteria. |
challenge_ts | string | ISO timestamp of when the challenge was passed. |
hostname | string | The hostname of the site where the challenge was solved. Check it. |
error-codes | array (optional) | Machine-readable reasons when success is false. |
score / score_reason | float / array | Enterprise only: a malicious-activity score and the reasoning behind it. |
Treat success as necessary but not sufficient. A token solved on
someone else's page using your public sitekey still verifies, so compare
hostname against your own domain before accepting the request. The
deprecated credit field still appears in some responses; ignore it.
curl
The quickest sanity check. The body is plain form fields; -d already
sends them form-encoded, so you do not set a Content-Type header:
curl https://api.hcaptcha.com/siteverify \
-d "secret=$HCAPTCHA_SECRET" \
-d "response=$TOKEN" \
-d "remoteip=203.0.113.7" \
-d "sitekey=f5ab1c2d-7e8f-4a9b-b1c2-d3e4f5a6b7c8" Node
With built-in fetch, URLSearchParams handles the
form encoding. The example wires it into an Express handler and checks both
success and hostname:
// Node 18+: built-in fetch, no dependency needed.
async function verifyHcaptcha(token, remoteip) {
const res = await fetch("https://api.hcaptcha.com/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: process.env.HCAPTCHA_SECRET,
response: token,
remoteip,
sitekey: "f5ab1c2d-7e8f-4a9b-b1c2-d3e4f5a6b7c8",
}),
});
const data = await res.json();
if (!data.success) {
throw new Error("hCaptcha failed: " + (data["error-codes"] || []).join(", "));
}
if (data.hostname !== "your-site.example") {
throw new Error("hCaptcha hostname mismatch: " + data.hostname);
}
return data;
}
// Express: the widget posts the token as h-captcha-response.
app.post("/signup", async (req, res) => {
await verifyHcaptcha(req.body["h-captcha-response"], req.ip);
// token is valid and single-use; safe to create the account
}); Python
requests form-encodes a data= dict automatically, which is
exactly what siteverify wants:
import os
import requests
def verify_hcaptcha(token: str, remoteip: str | None = None) -> dict:
r = requests.post(
"https://api.hcaptcha.com/siteverify",
data={
"secret": os.environ["HCAPTCHA_SECRET"],
"response": token,
"remoteip": remoteip,
"sitekey": "f5ab1c2d-7e8f-4a9b-b1c2-d3e4f5a6b7c8",
},
timeout=10,
)
data = r.json()
if not data["success"]:
raise ValueError(f"hCaptcha failed: {data.get('error-codes')}")
if data["hostname"] != "your-site.example":
raise ValueError(f"hCaptcha hostname mismatch: {data['hostname']}")
return data
# Flask: the widget posts the token as h-captcha-response.
verify_hcaptcha(request.form["h-captcha-response"], request.remote_addr) Go
http.PostForm sets the right content type for you. Map
error-codes with a struct tag, since the hyphen rules out a direct
field name:
type Siteverify struct {
Success bool `json:"success"`
ChallengeTS string `json:"challenge_ts"`
Hostname string `json:"hostname"`
ErrorCodes []string `json:"error-codes"`
}
func verifyHcaptcha(token, remoteip string) (*Siteverify, error) {
resp, err := http.PostForm("https://api.hcaptcha.com/siteverify", url.Values{
"secret": {os.Getenv("HCAPTCHA_SECRET")},
"response": {token},
"remoteip": {remoteip},
"sitekey": {"f5ab1c2d-7e8f-4a9b-b1c2-d3e4f5a6b7c8"},
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out Siteverify
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if !out.Success {
return nil, fmt.Errorf("hcaptcha failed: %s", strings.Join(out.ErrorCodes, ", "))
}
if out.Hostname != "your-site.example" {
return nil, fmt.Errorf("hcaptcha hostname mismatch: %s", out.Hostname)
}
return &out, nil
} Error codes
When success is false, the error-codes array
says why. The ones you will actually hit:
missing-input-response/missing-input-secret: a required field never reached the endpoint, usually a JSON-instead-of-form-encoding bug.invalid-input-response: the token is malformed or fake.expired-input-response: the token outlived its ~120-second window.already-seen-response: the token was verified once before; tokens are single-use.sitekey-secret-mismatch: thesitekeyyou passed is not registered to that secret.not-using-dummy-passcode: you used a test sitekey with a real secret (or the reverse).
The full list, including the client-side widget errors, is in hCaptcha error codes.
Mistakes that break real integrations
These four account for most "hCaptcha works in testing but fails in production" reports:
- Verifying client-side only. Checking that the widget callback
fired, or that
h-captcha-responseis non-empty, verifies nothing. Every check the browser runs, an attacker controls. If your backend never callssiteverify, the captcha is decoration. - Re-verifying a token. Tokens are single-use. A retry loop, a
double-submitted form, or a middleware that verifies before the handler verifies
again all produce
already-seen-responseon the second call, which reads like a user error but is your own code consuming the token twice. - Ignoring the ~120-second expiry. A token minted when the page loaded is stale by the time a slow user submits, and a token parked in a queue for batch processing is dead on arrival. Verify at submit time, synchronously.
- Checking
successbut nothostname. Your sitekey is public, so anyone can render your widget on their own page and harvest passing tokens. Those tokens returnsuccess: true. Thehostnamefield is what tells you the solve happened on your site.
Why siteverify is the bar solver tokens are measured against
This endpoint is also why captcha-solving shortcuts fail. A token that is scraped, replayed, expired, or minted against the wrong sitekey gets rejected right here, regardless of how convincing it looked in the form. That is the standard NoneCap holds its output to: the API returns real
P1_tokens that pass siteverify, including on invisible and enterprise (rqdata) sitekeys. If you run a site, none of this changes your integration: the checks on this page are the same ones every token has to clear.
If you got here from the other direction, debugging why a token you submitted was rejected, start with how hCaptcha tokens work and the error code reference; between single-use and the 120-second window, those two pages explain nearly every rejection.
Last updated June 2026.
Frequently asked
Do I have to verify hCaptcha tokens server-side?
api.hcaptcha.com/siteverify, because that call carries your secret key, which the client never sees. A form handler that skips the siteverify call has no captcha protection at all.Can I verify the same token twice?
success: false with already-seen-response (older integrations may know this as invalid-or-already-seen-response). If your handler retries on error, make sure it does not re-verify. See how hCaptcha tokens work.Does siteverify accept a JSON body?
application/x-www-form-urlencoded). Sending JSON typically gets you success: false with missing-input-response or bad-request, because the parser never finds your fields. This is one of the most common first-integration bugs.Is the secret key the same as the sitekey?
invalid-input-secret, and if you verify with a secret from a different key pair you get sitekey-secret-mismatch.How long is a token valid before siteverify rejects it?
expired-input-response even though the token looks intact. Verify the token promptly after the form arrives rather than queueing it for later processing.