CAPTCHA Detector in C#: Find the Type, Call CapSkip

captcha detector in c# - CAPTCHA Detector in C#: Find the Type, Call CapSkip

A CAPTCHA detector in C# reads a page’s HTML for the widget’s script and container, pulls the sitekey out of them, and maps the family to the CapSkip method that solves it. A reCAPTCHA v2 box, a v3 script and a Turnstile widget need three different calls with different options, and a wrong guess wastes a whole solve and gives you a token you cannot use. This guide builds a detector of about 200 lines with no packages besides CapSkip. It returns the type, the method and the arguments, or says plainly that it cannot tell. CapSkip runs on your own Windows PC with no per-solve fee, so a program that checks a hundred pages costs nothing extra. The same detector exists in Python and Node.js, linked at the end.

What you need

  • CapSkip running on a Windows PC, with the solvers you need switched on. The setup guide covers the first start.
  • The .NET 8 SDK or later, because the code uses raw string literals, which need C# 11. It was tested with SDK 10.0.100 and the CapSkip package 1.3.0, targeting net8.0 and net10.0. Targeting net6.0 also worked after adding LangVersion 11 to the project file. .NET Framework was not tried.
  • A terminal. The commands work in PowerShell.
  • A page that you own or may automate, with a CAPTCHA on it.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a program on another PC, a VPS or a hosted platform can reach it over the API. Both are set under connection settings.

How this was tested: the detector ran against the same 66 saved pages as the Python one and returned the same result for every page. Of those, 59 were built by hand (about 40 follow the vendors’ documentation, the rest are edge cases I made up) and 7 are snapshots of public pages taken on 29 October 2026. The 40 results that name a method were sent through the real CapSkip NuGet package to a mock CapSkip that copies the app’s input checks. The real app was not running, so no number below is a solve time.

Why you detect CAPTCHA type before you solve

CapSkip has a different method for each family: RecaptchaAsync, TurnstileAsync, GeetestAsync, AltchaAsync, CapyAsync, CaptchaFoxAsync, FriendlyCaptchaAsync and NormalAsync for images in the SDK, plus hcaptcha on the HTTP API, which the SDK does not wrap yet. Within reCAPTCHA the options matter too. Invisible, Enterprise and v3 are flags on the same call. Send the wrong ones and the best case is a failed job, the worst a token the site rejects with no hint why. I could not check which one the real app does for a wrong reCAPTCHA version. Either way the time of one solve is wasted.

Other mistakes fail fast and loudly. Reading the sitekey from a page that renders reCAPTCHA explicitly gives the word explicit, and CapSkip refuses it with ERROR_GOOGLEKEY. The ERROR_GOOGLEKEY and ERROR_PAGEURL guide covers that error from the other side. To detect CAPTCHA type reliably, a detector has to check the signals in a fixed order, and that order avoids both kinds of mistake. A detector is also the first thing to build before a program that meets more than one site. If you want the background on what each type is, what a CAPTCHA is explains the families without code.

Step 1: create the project

Create a console project and add the CapSkip package. The detector itself uses only what .NET ships with: regular expressions and HttpClient.

dotnet new console -o detect-demo
cd detect-demo
dotnet add package CapSkip

A CAPTCHA detector in C# works on text, so it needs only two inputs: the page’s HTML and its address. A plain request shows what the server sends before any JavaScript runs. Google’s reCAPTCHA demo pages, Cloudflare’s Turnstile demo and the hCaptcha demo all carry their widget in that HTML. Pages that add the widget with JavaScript do not, and the section “When the HTML says nothing” covers that case. One HttpClient keeps one cookie jar, which matters for the image CAPTCHA step: the program below uses the same client for the page and for the picture.

Step 2: write the helpers

Save everything from here to Step 9 in one file called Detector.cs, one block after another. The file will not compile until Step 9 closes the class, and it relies on the implicit usings that dotnet new console turns on. The first block holds the usings, two small records and the first helpers. Each reader returns null when nothing matches, so a missing attribute never throws.

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

public record Info(string Type, string? Method, Dictionary<string, object?> Args,
    string? NeedsBrowser = null, bool Unsupported = false, string[]? Also = null);
record Page(string H, string Srcs, string Js, string Url);

public static class Detector
{
    const RegexOptions F = RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant;
    const string SiteKeyJs = """sitekey['"]?\s*:\s*['"]([^'"]+)""";

    static string? Grab(string pattern, string? text)  // first capture group that matched, or null
    {
        var m = Regex.Match(text ?? "", pattern, F);
        for (var i = 1; i < m.Groups.Count; i++) if (m.Groups[i].Success) return m.Groups[i].Value;
        return null;
    }
    static string? Or(params string?[] v) => v.FirstOrDefault(s => !string.IsNullOrEmpty(s));
    static string Abs(string url, string rel) => Uri.TryCreate(new Uri(url), rel, out var u) ? u.AbsoluteUri : rel;
    static Dictionary<string, object?> Args(params (string, object?)[] kv) =>  // null values are left out
        kv.Where(x => x.Item2 != null).ToDictionary(x => x.Item1, x => x.Item2);

The Grab method returns the first capture group that matched. The Tag method finds an opening tag by CSS class and refuses a class that only contains the name inside a longer one, so a class called cf-turnstile-response is not a Turnstile widget. The Attr method reads an attribute written with either quote style and decodes HTML entities, which matters for a challenge held in an attribute. Unquoted attributes are not read.

The second block turns a page into something the checks can share. It strips comments and style blocks, so a CSS rule that mentions a widget class cannot match, and it collects the script addresses and the inline JavaScript separately. The last helper builds the answer for pages where the HTML is not enough. Its NeedsBrowser field holds the reason, for example that the sitekey is set by JavaScript, and this post calls that a needs-browser result.

    static string? Attr(string? tag, string name) =>  // attribute value, either quote style, HTML-decoded
        Grab("""\s""" + name + """\s*=\s*(?:"([^"]*)"|'([^']*)')""", tag) is { } v ? WebUtility.HtmlDecode(v) : null;
    static string? Tag(string cls, string h) =>  // opening tag that has CSS class cls
        Grab("""(<\w+[^>]*\sclass\s*=\s*["'][^"']*(?<![\w-])""" + cls + """(?![\w-])[^"']*["'][^>]*>)""", h);
    static string? Opts(string call, string js) => Grab(call + """\([^,]+,\s*\{(.{0,400})""", js);

    static Page Parse(string html, string url)
    {
        var h = Regex.Replace(html, """<!--.*?-->|<style\b.*?</style>""", "", F);
        var srcs = Regex.Matches(h, """<script\b[^>]*?\ssrc\s*=\s*["']([^"']+)""", RegexOptions.IgnoreCase);
        var js = Regex.Matches(h, """<script\b[^>]*>(.*?)</script>""", F);
        return new(h, WebUtility.HtmlDecode(string.Join(" ", srcs.Select(m => m.Groups[1].Value))),
                   string.Join("\n", js.Select(m => m.Groups[1].Value)), url);
    }
    static Info Browser(string type, string method, Page p, string why) =>  // HTML is not enough: do not guess
        new(type, method, new() { ["url"] = p.Url }, why);
    static readonly (string, string)[] Vendors = { ("arkose", """arkoselabs\.com/v2/|funcaptcha\.com/fc/"""),
        ("geetest_v4", """gt4\.js|initGeetest4""") };
    static Info? Unsup(Page p) => Vendors.Where(v => Regex.IsMatch(p.Srcs + " " + p.Js, v.Item2))  // CapSkip cannot solve these
        .Select(v => new Info(v.Item1, null, new(), Unsupported: true)).FirstOrDefault();
    static Info? Cf(Page p) => Regex.IsMatch(p.Js, """_cf_chl_opt\s*=\s*\{""")  // the "Just a moment..." page, not a widget
        ? Browser("turnstile_challenge", "turnstile", p, "data and pagedata (Cloudflare calls them cData and chlPageData) only exist at run time") : null;
    static Info? Hcaptcha(Page p)  // before reCAPTCHA: a site that moved to hCaptcha may keep the g-recaptcha class
    {
        if (!Regex.IsMatch(p.Srcs + " " + p.Js, """hcaptcha\.com/1/api\.js""")) return null;
        if (Regex.IsMatch(p.Js, """\.setData\s*\(|rqdata"""))  // Enterprise rqdata is issued per challenge
            return Browser("hcaptcha_enterprise", "hcaptcha", p, "rqdata is issued for one challenge at run time");
        var t = Tag("h-captcha", p.H) ?? Tag("g-recaptcha", p.H);
        var key = Or(Attr(t, "data-sitekey"), Grab(SiteKeyJs, Opts("""hcaptcha\.render""", p.Js)));
        if (key == null) return Browser("hcaptcha", "hcaptcha", p, "no sitekey in the static HTML");
        var invisible = Attr(t, "data-size") == "invisible" ? 1 : (int?)null;
        return new("hcaptcha", "hcaptcha", Args(("sitekey", key), ("url", p.Url), ("invisible", invisible)));
    }

Step 3: hCaptcha, and what CapSkip cannot solve

These three checks sit at the end of the second block above, so there is nothing new to paste in this step. Start with the pages where the right answer is no. CapSkip does not solve FunCaptcha or Arkose, and GeeTest v4 is outside the GeeTest method, which is for v3. If the detector skipped those, a page with an Arkose box would fall through to the next check and might come back as something it is not. So this check runs first and returns a result with no method.

The same block handles the Cloudflare full-page check, the “Just a moment” interstitial. That page is not a Turnstile widget. The turnstile method needs two values from it, data and pagedata, and a plain request does not hold them. The detector looks for the page’s inline challenge options and answers that a browser is needed. The cf_clearance guide covers what comes after a challenge page is passed.

hCaptcha comes next, before reCAPTCHA. CapSkip solves it, whether the widget is a checkbox, invisible or Enterprise, but a site that moved from reCAPTCHA to hCaptcha often keeps the g-recaptcha class on its widget, and only the hCaptcha script tells the two apart. The check reads the sitekey from the widget or from an hcaptcha.render call, and adds invisible when the widget has data-size set to invisible. An Enterprise page that hands the widget an rqdata value through hcaptcha.setData gets a needs-browser result instead, because that value is issued for one challenge and expires quickly.

Step 4: reCAPTCHA, in the right order

reCAPTCHA needs the most care because several CapSkip variants share two script names. The order of questions is what keeps them apart. First the script name: enterprise.js means the Enterprise flag, api.js does not. Then the render parameter in the script address. A key after render= means v3, but the word explicit after render= means a v2 page that renders its widget from code, and it must never be used as a key. Then the markup. A button with the g-recaptcha class and a data-action attribute is Google’s v3 button binding. A button without data-action, or any element with data-size set to invisible, is the invisible v2 widget. Anything else with the class is the checkbox.

    static Info? Recaptcha(Page p)  // Enterprise flag, then v3 (render=KEY), then v2
    {
        var m = Regex.Match(p.Srcs, """/recaptcha/(api|enterprise)\.js(\?\S*)?""");
        if (!m.Success) return null;
        var render = Grab("""[?&]render=([^&\s]+)""", m.Groups[2].Value);  // a v3 key, or "explicit" (a v2 page)
        var t = Tag("g-recaptcha", p.H);
        var o = Opts("""\.render""", p.Js);
        var button = (t ?? "").StartsWith("<button", StringComparison.OrdinalIgnoreCase);
        var action = Attr(t, "data-action");
        var v3 = (render != null && render != "explicit") || (button && !string.IsNullOrEmpty(action));
        var key = Or(render == "explicit" ? null : render, Attr(t, "data-sitekey"), Grab(SiteKeyJs, o));
        if (key == null) return Browser("recaptcha", "recaptcha", p, "no sitekey in the static HTML (set by JavaScript)");
        var small = Attr(t, "data-size") == "invisible" || Grab("""size['"]?\s*:\s*['"](invisible)""", o) != null;
        var invisible = !v3 && (button || small);  // a <button> is invisible v2 unless it has data-action (v3)
        action = Or(action, Grab("""execute\(\s*[^,)]+,\s*\{\s*['"]?action['"]?\s*:\s*['"]([^'"]+)""", p.Js));
        var ent = m.Groups[1].Value == "enterprise";
        var a = Args(("sitekey", key), ("url", p.Url), ("enterprise", ent ? 1 : null), ("version", v3 ? "v3" : null),
                     ("action", v3 ? action : null), ("invisible", invisible ? 1 : null));
        return new("recaptcha_" + (v3 ? "v3" : invisible ? "v2_invisible" : "v2") + (ent ? "_enterprise" : ""), "recaptcha", a);
    }

Each outcome maps to one RecaptchaAsync call. A checkbox is RecaptchaAsync with a sitekey, the page URL and an optional options dictionary. Invisible adds "invisible", Enterprise adds "enterprise", and v3 adds "version" and the action. The action name comes from the data-action attribute or from the page’s execute call; if neither is in the HTML, the entry is left out and CapSkip uses its default of verify. See the reCAPTCHA solver page for every variant it handles.

Step 5: Turnstile and GeeTest

A Turnstile widget needs two things on the page: Cloudflare’s api.js and a sitekey. The key sits in a data-sitekey attribute on a div with the cf-turnstile class, or in a turnstile.render call. The check requires the script, so a stray class name on an unrelated page cannot trigger it. It also reads data-action and data-cdata when they are present and passes them on as action and data. The Turnstile solver page lists the options.

GeeTest v3 gives you two values, and one of them is a trap. The gt value is fixed for the site and can sit in the page. The challenge value is single-use, and sites fetch it from a register endpoint at run time. If the page carries both, the detector returns them. If it carries only the initGeetest call, it answers that the HTML is not enough, because a challenge copied from a saved page would already be dead.

    static Info? Turnstile(Page p)  // widget only: div.cf-turnstile or turnstile.render
    {
        if (!p.Srcs.Contains("challenges.cloudflare.com/turnstile/v0/api.js")) return null;
        var t = Tag("cf-turnstile", p.H);
        var o = Opts("""turnstile\.render""", p.Js);
        var key = Or(Attr(t, "data-sitekey"), Grab(SiteKeyJs, o));
        if (key == null) return Browser("turnstile", "turnstile", p, "no sitekey in the static HTML");
        var action = Or(Attr(t, "data-action"), Grab("""\baction['"]?\s*:\s*['"]([^'"]+)""", o));
        var cdata = Or(Attr(t, "data-cdata"), Grab("""\bcData['"]?\s*:\s*['"]([^'"]+)""", o));
        return new("turnstile", "turnstile", Args(("sitekey", key), ("url", p.Url), ("action", action), ("data", cdata)));
    }
    static Info? Geetest(Page p)  // v3 only: gt is static, challenge comes from the register endpoint
    {
        if (!Regex.IsMatch(p.Js, """initGeetest\s*\(""") && !Regex.IsMatch(p.Srcs, """/gt(\.[\d.]+)?\.js""")) return null;
        var gt = Grab("""\bgt['"]?\s*:\s*['"]([0-9a-f]{32})""", p.Js);
        var ch = Grab("""\bchallenge['"]?\s*:\s*['"]([0-9a-f]{32,34})""", p.Js);
        return gt == null || ch == null ? Browser("geetest", "geetest", p, "gt and challenge come from the register endpoint")
            : new("geetest", "geetest", Args(("gt", gt), ("challenge", ch), ("url", p.Url)));
    }

Step 6: ALTCHA and Capy

ALTCHA changed its attribute names between generations. Versions 1 and 2 use challengeurl, or challengejson for an inline challenge. Version 3 and later use one attribute called challenge, which takes either a URL or the challenge data. The check reads all three and joins a relative URL to the page address. In C# the option keys are challenge_url and challenge_json.

Capy needs no widget div for this check. Its key starts with PUZZLE_ and sits in the k parameter of the widget script’s address, and a host other than https://jp.api.capy.me is passed as api_server.

    static Info? Altcha(Page p)  // challengeurl / challengejson (v1, v2) or challenge (v3+)
    {
        var t = Grab("""(<altcha-widget\b[^>]*>)""", p.H);
        if (t == null) return null;
        var c = Attr(t, "challenge");
        var json = Or(Attr(t, "challengejson"), (c ?? "").TrimStart().StartsWith('{') ? c : null);
        var url = Or(Attr(t, "challengeurl"), json != null ? null : c);
        return json != null ? new("altcha", "altcha", Args(("url", p.Url), ("challenge_json", json)))
            : url != null ? new("altcha", "altcha", Args(("url", p.Url), ("challenge_url", Abs(p.Url, url))))
            : Browser("altcha", "altcha", p, "no challenge attribute");
    }
    static Info? Capy(Page p)  // the widget script is .../puzzle/get_js/?k=PUZZLE_...
    {
        var m = Regex.Match(p.Srcs, """((?:https?:)?//[^\s"']+?)/puzzle/get_js/\S*?(PUZZLE_[\w-]+)""");
        if (!m.Success) return null;
        var full = m.Groups[1].Value.StartsWith("http") ? m.Groups[1].Value : "https:" + m.Groups[1].Value;
        var host = full == "https://jp.api.capy.me" ? null : full;
        return new("capy", "capy", Args(("sitekey", m.Groups[2].Value), ("url", p.Url), ("api_server", host)));
    }

Step 7: CaptchaFox and Friendly Captcha

The CaptchaFox widget reaches pages from two places. The plain one loads from cdn.captchafox.com. The other is bundled by a hosting platform under a shared CDN path, so the detector looks for the CaptchaFox package name in that path and not for the path alone, which other widgets also use.

Friendly Captcha is the trickiest. Version 1 and version 2 use the same div and the same sitekey namespace, but different protocols, and CapSkip returns a well-formed token for the wrong one, and the site rejects it. The scripts tell them apart: version 2 loads the @friendlycaptcha/sdk package, version 1 loads friendly-challenge. When the page gives no sign, the detector refuses to guess and says the HTML is not enough.

    const string Mam = "mampkg/@mamdev/core.frontend.libs.captchafox";
    static Info? CaptchaFox(Page p)  // plain build from cdn.captchafox.com, or the bundled build from s.uicdn.com
    {
        var t = Tag("captchafox", p.H);
        var key = Grab("""captchafox[^"']*/api\.js\?key=([^"'&]+)""", p.Srcs);
        if (t == null && key == null) return null;
        var mam = p.Srcs.Contains(Mam) ? "https://s.uicdn.com/" + Mam + "/" : null;
        var sitekey = Or(Attr(t, "data-sitekey"), key);
        if (sitekey == null) return Browser("captchafox", "captchafox", p, "no sitekey in the static HTML");
        return new("captchafox", "captchafox", Args(("sitekey", sitekey), ("url", p.Url), ("api_server", mam)));
    }
    static Info? Friendly(Page p)  // v2 loads @friendlycaptcha/sdk (site.min.js), v1 loads friendly-challenge
    {
        var t = Tag("frc-captcha", p.H);
        if (t == null) return null;
        var e2 = Attr(t, "data-api-endpoint");
        var e1 = Attr(t, "data-puzzle-endpoint");
        if (Attr(t, "data-sitekey") == null) return Browser("friendly_captcha", "friendly_captcha", p, "no sitekey in the static HTML");
        var v2 = e2 != null || Regex.IsMatch(p.Srcs, """friendlycaptcha/sdk|site(\.compat)?\.min\.js""");
        var v1 = e1 != null || Regex.IsMatch(p.Srcs, """friendly-challenge|widget(\.module)?\.min\.js""");
        if (v2 == v1) return Browser("friendly_captcha", "friendly_captcha", p, "cannot tell v1 from v2 in the HTML");
        return new(v2 ? "friendly_captcha_v2" : "friendly_captcha_v1", "friendly_captcha", Args(("sitekey", Attr(t, "data-sitekey")),
            ("url", p.Url), ("version", v2 ? "v2" : "v1"), ("api_server", Or(e2, e1))));
    }

Each of these has its own solver page with the options the method takes:

Step 8: the image CAPTCHA, a guess, and the checks

An image CAPTCHA has no vendor script to find, so this check is a guess and runs only when nothing else matched. The check wants an image whose tag says captcha, and a text box elsewhere on the page whose tag says captcha or security code. It missed in every case where the markup did not use those words, and a drawn canvas is invisible to it. Treat a hit as likely, not certain.

The same block lists the checks in order and exposes Detect. The order is the logic: unsupported vendors first, then the Cloudflare page, then hCaptcha, then each other family, and the image guess only when the list found nothing. The Detect method returns the first result and adds an Also list when the page carries more than one family, for example a v3 script plus a Turnstile box. The list names families, not a second reCAPTCHA widget with another key.

    static Info? Image(Page p)  // a guess: an <img> that says captcha, next to a text box that says captcha
    {
        const string box = """<input\b(?![^>]*type\s*=\s*["']?(?:hidden|checkbox|submit))[^>]*(?:captcha|security.?code)""";
        if (!Regex.IsMatch(p.H, box, RegexOptions.IgnoreCase)) return null;
        foreach (Match m in Regex.Matches(p.H, """<img\b[^>]*>""", RegexOptions.IgnoreCase))
            if (Regex.IsMatch(m.Value, "captcha", RegexOptions.IgnoreCase) && Attr(m.Value, "src") is { Length: > 0 } src)
                return new("image", "normal", Args(("image", Abs(p.Url, src))));
        return null;
    }
    public static Info? Detect(string html, string pageUrl)  // every check runs; the first hit wins, the rest are "also"
    {
        var p = Parse(html, pageUrl);
        var found = new[] { Unsup(p), Cf(p), Hcaptcha(p), Recaptcha(p), Turnstile(p), Geetest(p), Altcha(p), Capy(p), CaptchaFox(p), Friendly(p) }
            .Where(r => r != null).ToList();
        return found.Count == 0 ? Image(p) : found.Count == 1 ? found[0]
            : found[0]! with { Also = found.Skip(1).Select(r => r!.Type).ToArray() };
    }

Step 9: solve

The last block dispatches the result to the right CapSkip method. It refuses anything unsupported or marked as needing a browser with an InvalidOperationException. The CapSkip methods take their main values by position and the rest as an options dictionary, so the block pulls the positional values out of the arguments and passes everything else on under the raw API names. An image is downloaded with your own HttpClient, so its cookie jar goes with the request, because the SDK’s own download of a URL sends no cookies. A bad status on that download throws an HttpRequestException.

The SDK has no hCaptcha call yet, so an hCaptcha result goes to SolveHcaptcha instead. It posts the sitekey and the page address to in.php with method=hcaptcha, then polls res.php every 5 seconds until the token is ready, as the hCaptcha section of the API docs describes. It reads the address and the API key from the CapSkipClient you pass in, so there is nothing extra to configure. A refused job throws the SDK’s own ApiException with the error code, so the catch for CapSkipError in the program below handles it. The result carries the token in Code and the User-Agent it was earned with in UserAgent.

    public static async Task<SolveResult> Solve(Info i, CapSkipClient s, HttpClient http)
    {
        if (i.Unsupported || i.NeedsBrowser != null) throw new InvalidOperationException(i.NeedsBrowser ?? i.Type + " is not supported by CapSkip");
        if (i.Method == "hcaptcha") return await SolveHcaptcha(i.Args, s, http);
        string K(string k) => (string)i.Args[k]!;  // the positional arguments
        var o = i.Args.Where(kv => kv.Key is not ("sitekey" or "url" or "gt" or "challenge" or "image"))
                      .ToDictionary(kv => kv.Key, kv => kv.Value);  // everything else is an options entry
        return i.Method switch
        {
            "recaptcha" => await s.RecaptchaAsync(K("sitekey"), K("url"), o),
            "turnstile" => await s.TurnstileAsync(K("sitekey"), K("url"), o),
            "geetest" => await s.GeetestAsync(K("gt"), K("challenge"), K("url"), o),
            "altcha" => await s.AltchaAsync(K("url"), o),
            "capy" => await s.CapyAsync(K("sitekey"), K("url"), o),
            "captchafox" => await s.CaptchaFoxAsync(K("sitekey"), K("url"), o),
            "friendly_captcha" => await s.FriendlyCaptchaAsync(K("sitekey"), K("url"), o),
            "normal" => await s.NormalAsync(await ImageData(K("image"), http)),  // fetched with YOUR cookies
            _ => throw new InvalidOperationException("unknown method " + i.Method),
        };
    }
    static async Task<string> ImageData(string src, HttpClient http) => src.StartsWith("data:") ? src
        : "data:image/png;base64," + Convert.ToBase64String(await http.GetByteArrayAsync(src));
    static async Task<SolveResult> SolveHcaptcha(Dictionary<string, object?> a, CapSkipClient s, HttpClient http)
    {   // no SDK call yet: post to in.php and poll res.php at the address the client already uses
        var form = new Dictionary<string, string> { ["method"] = "hcaptcha", ["sitekey"] = (string)a["sitekey"]!,
            ["pageurl"] = (string)a["url"]!, ["invisible"] = a.ContainsKey("invisible") ? "1" : "0", ["json"] = "1" };
        if (a.TryGetValue("data", out var d) && d != null) form["data"] = d.ToString()!;
        var key = string.IsNullOrEmpty(s.ApiKey) ? "" : "&key=" + Uri.EscapeDataString(s.ApiKey);
        if (key != "") form["key"] = s.ApiKey;
        using var sent = await http.PostAsync(s.ApiClient.BaseUrl + "/in.php", new FormUrlEncodedContent(form));
        var job = JsonDocument.Parse(await sent.Content.ReadAsStringAsync()).RootElement;
        var id = job.GetProperty("request").GetString()!;
        if (job.GetProperty("status").GetInt32() != 1) throw new ApiException(id);
        for (var n = 0; n < 36; n++)  // a solve takes 10 to 30 seconds, so give up after 3 minutes
        {
            await Task.Delay(5000);
            var r = JsonDocument.Parse(await http.GetStringAsync($"{s.ApiClient.BaseUrl}/res.php?action=get&id={id}&json=1{key}")).RootElement;
            var value = r.GetProperty("request").GetString()!;
            if (value == "CAPCHA_NOT_READY") continue;
            if (r.GetProperty("status").GetInt32() != 1) throw new ApiException(value);
            return new SolveResult { CaptchaId = id, Code = value, UserAgent = r.TryGetProperty("useragent", out var ua) ? ua.GetString() : null };
        }
        throw new CapSkip.TimeoutException("hCaptcha was not solved within 3 minutes");
    }
}

Once an image is solved, send the answer back with the same http object, as a form post, so the site sees the cookie it set when it showed you the picture. The result for a Turnstile or hCaptcha solve also carries a UserAgent, which has to travel with the token on a challenge page. The program below prints only the Code field, so a GeeTest or Capy result shows as raw JSON.

Run the CAPTCHA detector in C#

Replace the contents of Program.cs with this block. It fetches your page, prints the HTTP status, prints what the detector found, and only calls CapSkip when there is a method to call and nothing is missing. It catches CapSkipError, the base of the SDK’s exceptions, instead of naming them one by one. That also avoids a trap: with implicit usings on, a bare TimeoutException is ambiguous between System and CapSkip and does not compile. Run it with dotnet run, two hyphens and the full page address, including https://, as in the command below. The port 8080 is the default; use the port shown in CapSkip’s connection settings if yours differs.

dotnet run -- "https://example.com/login"

This is the whole program:

using CapSkip;

var pageUrl = args.Length > 0 ? args[0] : "https://example.com/login";
var http = new HttpClient();  // one client keeps one cookie jar for the page and the image
http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36");
Info? info;
try
{
    var page = await http.GetAsync(pageUrl);
    Console.WriteLine((int)page.StatusCode);  // a Cloudflare challenge page arrives as a 403
    var finalUrl = page.RequestMessage!.RequestUri!.ToString();  // the URL after redirects
    info = Detector.Detect(await page.Content.ReadAsStringAsync(), finalUrl);
}
catch (Exception e) when (e is HttpRequestException or InvalidOperationException or TaskCanceledException or NotSupportedException)
{
    Console.WriteLine("Could not fetch the page: " + e.Message);
    return;
}
if (info is null) { Console.WriteLine("No CAPTCHA found."); return; }
Console.WriteLine($"{info.Type} via {info.Method ?? "nothing"}"
    + (info.Also is null ? "" : " (also " + string.Join(", ", info.Also) + ")"));
try
{
    var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
    var result = await Detector.Solve(info, solver, http);
    Console.WriteLine(result.Code.Length > 80 ? result.Code[..80] + "..." : result.Code);
}
catch (InvalidOperationException e) { Console.WriteLine("Not solvable from this HTML: " + e.Message); }
catch (HttpRequestException e) { Console.WriteLine("Image download failed: " + e.Message); }
catch (CapSkipError e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); }

On a page with a reCAPTCHA v2 checkbox the first line shows the type and the method, and the second shows the token. In the mock run it looked like this:

PageThe program prints
reCAPTCHA v2 checkbox200, then recaptcha_v2 via recaptcha, then the token
hCaptcha widget200, then hcaptcha via hcaptcha, then the token
Cloudflare “Just a moment” page403, then turnstile_challenge via turnstile, then a message that data and pagedata only exist at run time
Page with no CAPTCHA200, then No CAPTCHA found.
Address that returns 404404, then No CAPTCHA found.

When the HTML says nothing: the browser console

Some pages add their widget with JavaScript after loading. A plain request then finds no CAPTCHA at all, and the detector returns null for a page that has one. Two cases are known and expected: a Google or Cloudflare script added by code, and a sitekey set by code. For those, open the page in Chrome, press F12 and paste this into the Console. If Chrome blocks the paste, type allow pasting and press Enter first. It reads the sitekeys from the resources the page has already loaded, so it sees what the browser built.

(() => {
  const iframes = [...document.querySelectorAll('iframe')].map((f) => f.src);
  const urls = performance.getEntriesByType('resource').map((r) => r.name).concat(iframes);
  const found = new Map(), add = (o) => found.set(JSON.stringify(o), o);
  for (const u of urls) {
    let m;
    if ((m = u.match(/recaptcha\/(?:api2|(enterprise))\/anchor\?.*?\bk=([\w-]+)/)))
      add({ type: 'recaptcha', sitekey: m[2], enterprise: !!m[1], invisible: /size=invisible/.test(u) });
    else if ((m = u.match(/recaptcha\/(?:api|enterprise)\.js\?.*?\brender=([\w-]+)/)) && m[1] !== 'explicit')
      add({ type: 'recaptcha v3', sitekey: m[1], enterprise: /enterprise\.js/.test(u) });
    else if ((m = u.match(/challenges\.cloudflare\.com\/.*turnstile\/.*\/(0x4[\w-]+|[123]x0+[A-Za-z]+)\//)))
      add({ type: 'turnstile', sitekey: m[1] });
    else if ((m = u.match(/hcaptcha\.com\/captcha\/.*[#&?]sitekey=([\w-]+)/))) add({ type: 'hcaptcha', sitekey: m[1] });
    else if (/arkoselabs\.com\/fc\//.test(u)) add({ type: 'arkose: CapSkip cannot solve' });
  }
  if (window._cf_chl_opt) add({ type: 'cloudflare challenge page' });
  document.querySelectorAll('altcha-widget').forEach((e) => add({ type: 'altcha', challenge: e.getAttribute('challenge') || e.getAttribute('challengeurl') }));
  document.querySelectorAll('.h-captcha[data-sitekey]').forEach((e) => add({ type: 'hcaptcha', sitekey: e.dataset.sitekey }));
  document.querySelectorAll('.frc-captcha, .captchafox').forEach((e) => add({ type: e.classList.contains('captchafox') ? 'captchafox' : 'frc-captcha', sitekey: e.dataset.sitekey }));
  const v3 = [...found.values()].filter((o) => o.type === 'recaptcha v3').map((o) => o.sitekey);  // a v3 badge also adds an invisible anchor frame
  const res = [...found.values()].filter((o) => !(o.type === 'recaptcha' && v3.includes(o.sitekey)));
  return res.length ? res : 'no known CAPTCHA found (GeeTest, Capy and CaptchaFox scripts are not checked)';
})();

It found the sitekey and flags on a reCAPTCHA v2 page, an invisible one, a v3 page, a Turnstile page and the hCaptcha demo, and it printed a message on a page with no CAPTCHA. Three limits: the browser keeps only about 250 resource entries by default, so a long-running single page app can lose early ones, and the snippet cannot tell Friendly Captcha version 1 from version 2, and it does not look for GeeTest, Capy or CaptchaFox scripts. On Cloudflare’s full-page check it can show the sitekey from a resource address, but not the data and pagedata values, which need a hook on the render call. That hook was not tested here.

Where detection fails

SituationWhat Detect returnsWhat to do
Widget script added by JavaScriptnullLoad the page in a browser and run the console snippet
Sitekey set by JavaScripta result that needs a browserRead the key from the live page
Cloudflare full-page checka result that needs a browserRead the values in a browser, see the cf_clearance guide
Friendly Captcha script with no frc-captcha div in the HTMLnullRun the console snippet or read the page source
CaptchaFox or Capy script added by JavaScriptnullRun the console snippet or read the page source
A 403 or 503 page from the site itselfnull, unless it is a Cloudflare challengeThe program prints the status first: a block page holds no CAPTCHA to find
Arkose, GeeTest v4unsupportedCapSkip cannot solve these
hCaptcha Enterprise that sets rqdataneeds a browserRead a fresh rqdata from the live page for each task and pass it to SolveHcaptcha as data
Friendly Captcha with no script hinta result that needs a browserCheck which script the page loads
Image CAPTCHA without the word captchanullAdd your own rule for that site

Common errors

ErrorCauseFix
ERROR_GOOGLEKEYThe key was the word explicit, or a key that is not a reCAPTCHA keyRead the key from the widget, not from the render parameter
ERROR_CAPTCHA_UNSOLVABLEAny failed job reads this way and the code does not name the cause. A wrong reCAPTCHA version is one possible causeCheck the detected type first, then see the API docs
ValidationExceptionAn option the method does not take, such as an action on a v2 callUse the arguments the detector returned
NetworkExceptionCapSkip is not running, or the host or port is wrong; on Windows it arrives after about 2 secondsStart CapSkip and check the connection settings
InvalidOperationException from SolveThe result is unsupported or needs a browserHandle those two cases before calling Solve

Running the solver on a server

The detector only needs two things from CapSkip: a host and a port. In Local mode that is 127.0.0.1 and your own PC. In Server mode CapSkip listens on your network address or a public IP, so a detector running on another PC, a VPS or a hosted platform can call it over the same API. Change the host and port in the CapSkipClient constructor, and nothing else in the code changes. A static public IP is recommended for stable remote access. Server mode is still your own hardware and still unmetered. Note that Server mode opens the port to any address, so add a Windows Firewall block rule for the addresses that should not reach it. See the connection settings for how to switch modes.

FAQ

Can I tell which CAPTCHA a site uses without code?

Yes. Open DevTools, go to the Network tab, reload the page and look at the script addresses. A google.com/recaptcha or recaptcha.net script is reCAPTCHA, challenges.cloudflare.com is Turnstile, and js.hcaptcha.com is hCaptcha. The Console snippet above does the same lookup for you.

Why does Detect return a needs-browser result instead of a guess?

Because a wrong guess wastes a full solve and returns a token the site rejects with no hint why. When the static HTML does not hold the sitekey, a GeeTest challenge or a Friendly Captcha version, saying so is quicker than a failed job.

Does CapSkip solve hCaptcha?

Yes. CapSkip solves hCaptcha checkbox, invisible and Enterprise widgets through its HTTP API with method=hcaptcha, as the API docs describe. The .NET SDK has no hCaptcha call yet, so SolveHcaptcha above posts the job to in.php itself. FunCaptcha, Arkose and GeeTest v4 are still not supported, and the detector reports those as unsupported so a program can skip the page instead of failing on it.

Can a hosted platform use the detector with my CapSkip?

Yes, through Server mode. Set CapSkip to listen on your network or public IP, point the CapSkipClient constructor at that host and port, and the detector and the Solve call work the same as on one PC. The solver stays on your hardware.

The short version

A CAPTCHA detector in C# earns its place by running first: detect CAPTCHA type, then solve. Read the script and the markup in a fixed order, hand the arguments to the right method, and treat null, a needs-browser result and unsupported as answers, not as crashes. With CapSkip on your own machine there is no meter running while you check a page, which is what makes a captcha solver practical for a crawl that meets a new site every few minutes. For the .NET SDK itself, see the C# CAPTCHA solver page.

The same detector in other languages: