A GeeTest v3 solver in Python takes four HTTP calls, and only the middle two talk to the solver. You get a fresh gt and challenge from the site's register endpoint. You send them to a solving API. You poll until it returns challenge, validate and seccode. Then you POST those three back to the site in the same session that fetched the challenge.
When the solve works but the site still says no, the bug is almost always in the first or last call. Below are all four with runnable code, plus a check to run first: v3 and v4 are not interchangeable.
The setup
You're automating a lookup form you're allowed to automate, and a GeeTest v3 slider sits in front of the submit button. The goal is plain requests, no browser, ending in an accepted form POST.
First, confirm it's GeeTest v3 and not v4
Most GeeTest confusion online comes from mixing guides for the two versions. They take different parameters and hand the site different values. Open DevTools, reload, and compare:
| Signal | GeeTest v3 | GeeTest v4 |
|---|---|---|
| Page init | initGeetest({gt, challenge, ...}) |
initGeetest4({captchaId: ...}) |
| What identifies it |
gt (32 hex chars) plus a one-time challenge
|
captcha_id only |
| Network |
api.geetest.com or api-na.geetest.com (gettype.php, get.php, ajax.php) |
gcaptcha4.geetest.com, static.geetest.com/v4/gt4.js
|
| What the site receives |
geetest_challenge, geetest_validate, geetest_seccode
|
lot_number, captcha_output, pass_token, gen_time
|
If you'd rather script it, this loads the page once and classifies it by network traffic (needs pip install playwright and playwright install chromium):
import re
from playwright.sync_api import sync_playwright
def geetest_version(url: str) -> str:
seen = []
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("request", lambda req: seen.append(req.url) if "geetest.com" in req.url else None)
page.goto(url, wait_until="networkidle")
browser.close()
if any("gcaptcha4.geetest.com" in u or "/v4/" in u for u in seen):
return "GeeTest v4 (captcha_id): this guide does not apply"
if any(re.search(r"/(gettype|get|ajax)\.php", u) for u in seen):
return "GeeTest v3 (gt + challenge)"
return "no GeeTest traffic yet: it may only load when you click submit"
Everything below is v3 only. It's the same habit I recommend for reCAPTCHA: identify the variant before writing solver code, since a request for the wrong variant fails in confusing ways.
Step 1: find the register endpoint
On v3 the challenge is not in the HTML. The site's backend mints it: it calls GeeTest's register.php, signs the result with its private key, and hands the page JSON shaped like this:
{"success": 1, "gt": "f1ab2cd4e5f6a7b8c9d0e1f2a3b4c5d6", "challenge": "6f3b2c...", "new_captcha": true}
To find it, filter DevTools' Network tab by Fetch/XHR, reload, and look for a JSON response containing "challenge". It's usually named /register, /geetest/register or /captcha/init, often with a ?t=<timestamp> cache-buster. GeeTest's own demo uses /pc-geetest/register?t=.... Also check whether the page's initGeetest(...) call sets api_server (e.g. api-na.geetest.com); if so, pass it to the solver.
Two rules decide whether the final POST is accepted:
- Fresh. Per the API guide linked at the end, a challenge expires after about 10 minutes and becomes invalid once the widget has loaded it. Fetch it right before you solve, and don't let a browser render the widget with it.
- Same session. GeeTest's official v3 server SDK demo stores the register status in the server-side session and reads it back when validating. Register with one cookie jar and submit with another, and a site built like that has no record of your challenge.
A GeeTest v3 solver in Python, end to end
Prerequisites: Python 3.9+, pip install requests, your API key, the page and register URLs, and the form's POST URL (visible in DevTools after one manual solve). The request parameters follow the documented in.php/res.php flow (method=geetest, gt, challenge, pageurl, plus optional api_server and userAgent).
import json
import time
import requests
API_KEY = "YOUR_API_KEY"
API = "https://ocr.captchaai.com"
PAGE_URL = "https://example.com/lookup" # page that shows the slider
REGISTER_URL = "https://example.com/geetest/register" # found in Step 1
SUBMIT_URL = "https://example.com/lookup" # where the form POSTs
API_SERVER = None # e.g. "api-na.geetest.com" if initGeetest() sets api_server
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36"
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}
class SolveError(Exception):
pass
def fresh_pair(s: requests.Session) -> dict:
r = s.get(REGISTER_URL, params={"t": int(time.time() * 1000)}, timeout=30)
r.raise_for_status()
data = r.json()
if not data.get("success"):
raise SolveError("register returned success=0 (fallback mode), not a normal challenge")
return data
def solve(gt: str, challenge: str) -> dict:
params = {"key": API_KEY, "method": "geetest", "gt": gt, "challenge": challenge,
"pageurl": PAGE_URL, "userAgent": UA, "json": 1}
if API_SERVER:
params["api_server"] = API_SERVER
sub = requests.get(f"{API}/in.php", params=params, timeout=30).json()
if sub.get("status") != 1:
raise SolveError(sub.get("request"))
task_id = sub["request"]
print(f"task {task_id} submitted, polling...")
time.sleep(15) # the docs suggest 15-20 s before the first poll
deadline = time.time() + 120
while time.time() < deadline:
try:
res = requests.get(f"{API}/res.php", timeout=30, params={
"key": API_KEY, "action": "get", "id": task_id, "json": 1}).json()
except requests.RequestException:
time.sleep(10)
continue
if "seccode" in res: # solved: {"challenge", "validate", "seccode"}
return res
if res.get("status") == 1: # same three values wrapped in "request"
sol = res["request"]
return json.loads(sol) if isinstance(sol, str) else sol
code = res.get("request")
if code in TRANSIENT:
time.sleep(10)
elif code == "CAPCHA_NOT_READY":
time.sleep(5)
else:
raise SolveError(code)
raise SolveError("timed out after 120 s")
def main() -> None:
s = requests.Session()
s.headers["User-Agent"] = UA
s.get(PAGE_URL, timeout=30) # pick up the cookies a normal visit sets
for attempt in (1, 2):
pair = fresh_pair(s) # fresh challenge, same session
print(f"register: gt={pair['gt'][:8]}... challenge={pair['challenge'][:8]}...")
try:
sol = solve(pair["gt"], pair["challenge"])
except SolveError as e:
if str(e) == "ERROR_CAPTCHA_UNSOLVABLE" and attempt == 1:
continue # never resubmit an old challenge
raise
print(f"solved: seccode={sol['seccode'][:12]}...")
form = {"q": "example"} # the form's own fields
form.update({
"geetest_challenge": sol["challenge"], # the RETURNED challenge
"geetest_validate": sol["validate"],
"geetest_seccode": sol["seccode"],
})
r = s.post(SUBMIT_URL, data=form, timeout=30)
print(f"POST {SUBMIT_URL} -> {r.status_code}")
return
if __name__ == "__main__":
main()
Expected output:
register: gt=f1ab2cd4... challenge=6f3b2c1e...
task 73919012345 submitted, polling...
solved: seccode=2a9c04b7e1f3...
POST https://example.com/lookup -> 200
Two details in that code matter more than they look:
-
Use the
challengethe solver returns, not the one you sent. It can come back changed, and the site checks the three values as a set. -
The API returns short keys; the site expects the
geetest_prefix, and some sites rename fields in their own JS. Solve once by hand with DevTools open and copy the exact names.
The same solve step in Node.js
Node 18+ has fetch built in. Keep the register and submit calls on one cookie-keeping client, for the same session reason.
const API = "https://ocr.captchaai.com";
const KEY = "YOUR_API_KEY";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function solveGeetestV3({ gt, challenge, pageurl, userAgent }) {
const q = new URLSearchParams({ key: KEY, method: "geetest", gt, challenge, pageurl, json: "1" });
if (userAgent) q.set("userAgent", userAgent);
const sub = await fetch(`${API}/in.php?${q}`).then((r) => r.json());
if (sub.status !== 1) throw new Error(`submit: ${sub.request}`);
await sleep(15000);
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
const res = await fetch(`${API}/res.php?` + new URLSearchParams({
key: KEY, action: "get", id: sub.request, json: "1",
})).then((r) => r.json());
if (res.seccode) return res; // solved: { challenge, validate, seccode }
if (res.status === 1) {
return typeof res.request === "string" ? JSON.parse(res.request) : res.request;
}
if (res.request !== "CAPCHA_NOT_READY") throw new Error(`poll: ${res.request}`);
await sleep(5000);
}
throw new Error("timed out after 120 s");
}
Why a correct seccode still gets rejected
Sites behind GeeTest v3 rarely say why they rejected you. Check these in order:
| Symptom | Likely cause | Fix |
|---|---|---|
| Rejected every time | Register and submit used different sessions | One requests.Session for both |
| Rejected after a slow run | Challenge expired or the widget already loaded it | Register right before solving; never render the widget for it |
| Rejected despite a fresh pair | Original challenge sent instead of the returned one |
Send sol["challenge"]
|
| Field missing (400/422) | Wrong names or a JS rename | Copy names from a manual solve |
success: 0 from register |
The site fell back to offline mode | Log it; retry register later |
ERROR_CAPTCHA_UNSOLVABLE |
The solve failed for this challenge | New register call, then resubmit |
The first two rows are the same kind of failure as a Turnstile token that works in the browser but 403s from requests. In both cases the solved values are fine; the context around them changed between issue and use.
FAQ
Does this work for GeeTest v4? No. v4 is a different flow (captcha_id in, four different values out), and none of the code above applies.
Do I need a browser? Not for the solve. If the register endpoint needs a token only the page's JS can produce, call it through Playwright's page.request, which shares the page's cookies, and pass the pair to the same solve().
I already have 2Captcha-style GeeTest v3 code. What changes? The in.php/res.php shape is the same, so it's mostly the base URL. I covered that swap in migrating solver code off 2Captcha.
I'm Bassem, and I run CaptchaAI, the API behind ocr.captchaai.com in the code above. It solves GeeTest v3 and not v4, and I'd rather say so here than have you find out in testing. If you want to run this script against your own target, claim a free thread: 1 thread for 30 days, no card. The GeeTest v3 guide has the full parameter and error list.
Top comments (0)