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:

What arrives at your server
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:

  1. The form posts h-captcha-response to your endpoint.
  2. Your server POSTs secret + response to https://api.hcaptcha.com/siteverify.
  3. You act on the JSON verdict: proceed when success is true and the hostname matches, 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:

siteverify request parameters
ParameterRequiredWhat it does
secretYesYour account secret key. Server-side only; never ship it to the browser.
responseYesThe token from the form’s h-captcha-response field.
remoteipNo (recommended)The end user’s IP. Improves accuracy and feeds risk scoring on Enterprise.
sitekeyNoThe 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:

siteverify · success
{
  "success":      true,
  "challenge_ts": "2026-06-12T09:41:27Z",
  "hostname":     "your-site.example"
}

A rejected one carries machine-readable reasons in error-codes:

siteverify · failure
{
  "success":     false,
  "error-codes": ["expired-input-response"]
}
siteverify response fields
FieldTypeMeaning
successbooleanWhether the token is valid and meets your security criteria.
challenge_tsstringISO timestamp of when the challenge was passed.
hostnamestringThe hostname of the site where the challenge was solved. Check it.
error-codesarray (optional)Machine-readable reasons when success is false.
score / score_reasonfloat / arrayEnterprise 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:

Verify a token
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 · fetch
// 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:

Python · requests
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:

Go · net/http
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: the sitekey you 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-response is non-empty, verifies nothing. Every check the browser runs, an attacker controls. If your backend never calls siteverify, 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-response on 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 success but not hostname. Your sitekey is public, so anyone can render your widget on their own page and harvest passing tokens. Those tokens return success: true. The hostname field 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?
Yes. The widget is front-end UI; anything it puts in the page can be faked by whoever controls the browser. The only trustworthy signal is the verdict your server gets from 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?
No. Tokens are single-use: the first siteverify call consumes the token, and any later call for the same token returns 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?
No. The endpoint expects a POST with a form-encoded 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?
No. The sitekey is the public UUID baked into the widget; the secret key is its private counterpart, issued in the hCaptcha dashboard and used only in the siteverify call. If you mix them up you get 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?
About 120 seconds from issuance by default. After that, siteverify returns expired-input-response even though the token looks intact. Verify the token promptly after the form arrives rather than queueing it for later processing.

Start solving hCaptcha in minutes.

100 free credits on signup. Pay per solve, credits never expire, failed solves auto-refunded.