How to Solve GeeTest v3 in C#

solve geetest in c# - How to Solve GeeTest v3 in C# and Post the Result Back

GeeTest breaks the mental model most CAPTCHA code is built on. There is no single token to drop into a form field. A solve returns three values that have to be posted back together, and the challenge you started from expires in about a minute. Get either part wrong and the request fails with no useful error.

Here is the whole flow in C#, including the parts that are easy to get wrong.

Two inputs, and only one of them is stable

GeeTest v3 identifies a site with gt and a single attempt with challenge. They behave completely differently.

ValueLifetimeWhere it comes from
gtStatic for the site. Safe to hard-code or cacheThe site’s GeeTest init response
challengeSingle use, expires in roughly 60 secondsThe same init response, fresh every time

That expiry is the single most common cause of GeeTest failures. If you fetch a pair, queue the job, and solve it thirty seconds later behind other work, the challenge may already be dead. Fetch it immediately before solving, never from a cache.

Setup

# Targets .NET Standard 2.0, so Framework 4.6.1+ and .NET 6-9 all work.
dotnet add package CapSkip

CapSkip runs on your own machine, so start the app first and point the client at the port from its settings:

using CapSkip;

var solver = new CapSkipClient(
    apiKey: "capskip",        // any string when key validation is off
    host: "127.0.0.1",
    port: 8080,
    recaptchaTimeout: 300);   // seconds, and this one covers GeeTest too

Note that GeeTest uses recaptchaTimeout, not defaultTimeout. The latter only applies to image CAPTCHAs.

Solving it

Three positional arguments, in this order:

var result = await solver.GeetestAsync(
    "81388ea1fc187e0c335c0a8907ff2625",   // gt, static per site
    "7cf6a8b1a2c34d5e6f7089abcdef0123",   // challenge, fetched seconds ago
    "https://example.com/login");

Console.WriteLine(result.Challenge);
Console.WriteLine(result.Validate);
Console.WriteLine(result.Seccode);

Those three properties are the answer. result.Code is also populated, but for GeeTest it holds the raw JSON string rather than a usable token, so reaching for Code out of habit is a mistake. On SolveResult, Challenge, Validate and Seccode are GeeTest-only and null for every other type.

Worth knowing: the challenge that comes back is not always the one you sent in. Use the returned value, not your input.

Posting the answer back

Submit all three exactly as the site’s own front end would. Most GeeTest v3 integrations use these field names, though a site can rename them, so check the real form before assuming:

using System.Net.Http;
using System.Collections.Generic;

using var http = new HttpClient();

var form = new FormUrlEncodedContent(new[]
{
    new KeyValuePair<string, string>("geetest_challenge", result.Challenge),
    new KeyValuePair<string, string>("geetest_validate",  result.Validate),
    new KeyValuePair<string, string>("geetest_seccode",   result.Seccode),
    new KeyValuePair<string, string>("username", "..."),
    new KeyValuePair<string, string>("password", "..."),
});

var response = await http.PostAsync("https://example.com/login", form);

Sending two of the three, or pairing a fresh validate with a stale challenge, gets rejected the same way a wrong answer would.

The whole flow, in order

Fetching the pair and solving have to sit next to each other. This shape keeps them there:

using System;
using System.Net.Http;
using System.Text.Json;
using CapSkip;

// 1. Fetch a fresh gt/challenge pair from the site's own init endpoint.
using var http = new HttpClient();
var initJson = await http.GetStringAsync(
    "https://example.com/geetest/init?t=" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());

using var doc = JsonDocument.Parse(initJson);
var gt        = doc.RootElement.GetProperty("gt").GetString();
var challenge = doc.RootElement.GetProperty("challenge").GetString();

// 2. Solve immediately. Do not queue this or await anything slow in between.
var result = await solver.GeetestAsync(gt, challenge, "https://example.com/login");

// 3. Post all three values together.
Console.WriteLine($"{result.Challenge} {result.Validate} {result.Seccode}");

The cache-busting timestamp on the init call matters more than it looks. GeeTest init endpoints are frequently cached by intermediaries, and a cached response hands you a challenge that was already consumed.

When it fails

using System;
using CapSkip;

try
{
    var result = await solver.GeetestAsync(gt, challenge, pageUrl);
}
catch (CapSkip.ValidationException) { /* missing gt or challenge */ }
catch (NetworkException)            { /* CapSkip is not running */ }
catch (ApiException)                { /* API error, often a dead challenge */ }
catch (CapSkip.TimeoutException)    { /* exceeded recaptchaTimeout */ }
catch (CapSkipError)                { /* anything else from the SDK */ }

Qualify ValidationException and TimeoutException with the CapSkip namespace. Both names also exist in System, and with both namespaces imported an unqualified catch binds to the System type and silently never fires. Catching the base CapSkipError sidesteps the problem entirely.

In practice most GeeTest failures surface as ApiException and mean the challenge died before the solve finished. The fix is fetching later, not retrying with the same values.

Proxies and concurrency

GeeTest is one of the three types that accept a proxy, alongside reCAPTCHA and Turnstile. Image CAPTCHAs do not, because they never touch the target site.

using System.Collections.Generic;

var result = await solver.GeetestAsync(gt, challenge, pageUrl,
    new Dictionary<string, object?>
    {
        ["proxy"] = new Proxy("HTTPS", "user:[email protected]:3128"),
    });

Running several in parallel works, but each one needs its own freshly fetched pair. Do not fetch a batch of challenges up front and then solve them together, because the last ones will have expired before their turn.

// Correct: fetch and solve inside the same task.
var tasks = urls.Select(async url =>
{
    var (gt, challenge) = await FetchPairAsync(url);
    return await solver.GeetestAsync(gt, challenge, url);
});

var results = await Task.WhenAll(tasks);

Frequently asked questions

Why is result.Code not a usable token?

Because GeeTest’s answer is three values, not one. Code keeps the raw JSON string for completeness, while the SDK expands the useful parts into Challenge, Validate and Seccode. Use those three.

Can I cache the challenge to save a request?

No. It is single use and expires in about a minute. Caching it is the most common reason GeeTest integrations work in testing and fail under load, because queueing delay pushes the solve past the expiry window. The gt value is safe to cache.

Does this work for GeeTest v4?

The GeetestAsync method targets v3, the slide-puzzle version built around the gt and challenge pair. v4 changed the parameter model, so check the current API documentation for what is supported before assuming the same call works.

Do I need a proxy?

Only when the site is geo-sensitive or already treats your address as suspicious. Solve and submit from the same network path when you do use one, otherwise the mismatch can itself trigger a re-challenge.

Summary

Fetch gt and challenge immediately before solving, call GeetestAsync with both plus the page URL, then post Challenge, Validate and Seccode back together. Treat the challenge as perishable, use the returned challenge rather than your input, and catch CapSkipError to avoid the namespace collision.

Method signatures for the other languages are on the GeeTest solver page, the full .NET surface is on the C# CAPTCHA solver page, and you can try a live puzzle on our GeeTest v3 demo. CapSkip handles captcha bypass locally, so solve volume costs nothing per request.