How to Solve hCaptcha in C# With HCaptchaAsync (.NET 8+)

To solve hCaptcha in C#, call HCaptchaAsync from the CapSkip NuGet package with the widget’s sitekey and the page address. The SolveResult that comes back holds the token, the respKey and the User-Agent the token was earned with, and you post your form with the token under that User-Agent. The same method covers checkbox, invisible and Enterprise widgets. The call goes to the CapSkip hCaptcha solver running on your own Windows PC, and the flat license means a second try costs nothing.
What you need
- A Windows PC running CapSkip 4.0.0 or newer, the release that added hCaptcha. Install steps are in the setup guide.
- The .NET 8 SDK or newer, and the CapSkip NuGet package 1.6.0 or newer, the first release with HCaptchaAsync. The package targets netstandard2.0 and depends on System.Text.Json 8.0.5. I have one SDK installed, 10.0.100, and ran everything with CapSkip 1.6.0 on net10.0 and net8.0 only.
- A PowerShell prompt, and a page you may automate that shows an hCaptcha widget, with its sitekey.
- An address for CapSkip. Local mode answers on 127.0.0.1 only, for programs on that PC. Server mode also listens on your network or public IP, so a program on another machine can call the same API. The connection settings hold that choice.
Test setup: the real CapSkip 1.6.0 package from NuGet and two mock servers of my own, with no real hCaptcha site. One mock stands in for the CapSkip app and follows the hCaptcha section of the API docs. The other plays the sign-up page and can act as a proxy. Its rules (a session cookie, a Referer or an Origin, the User-Agent the token came with) are my choices, not hCaptcha’s, whose developer docs describe how a real site checks a token. I read the package source at its v1.6.0 tag and sent the real CapSkip app nothing, so I have no real solve time.
Step 1: create the project and install CapSkip
Make a console project and install the package. A new project enables implicit usings and nullable reference types, and this code needs both. Without a version, dotnet add package takes the newest release, 1.6.0 when I ran it.
dotnet new console -o hcaptcha-demo cd hcaptcha-demo dotnet add package CapSkip
Delete everything in Program.cs, including the template’s Console.WriteLine line, then paste the three C# blocks below in order, starting with Step 2. Only the first block has using lines, and nothing runs before the last block is in.
Step 2: call HCaptchaAsync
The sitekey is on the page. Most pages print it as data-sitekey on the widget container, and a script-built widget passes it as the sitekey option of hcaptcha.render(). HCaptchaAsync takes the sitekey, the page address, an options dictionary and a CancellationToken. Its typed overload takes a bool named invisible before the options: true sends invisible=1 and false sends nothing. An Enterprise site that sets rqdata gets the value under the key data, fresh for every task. A proxy is a Proxy object with a type (HTTP, HTTPS, SOCKS5 or SOCKS5H) and a login:password@host:port string. Write the port out, because Uri.Authority drops a default one. In the mock run, invisible, data and the proxy reached in.php.
using System.Net;
using System.Text.RegularExpressions;
using CapSkip;
using SolverTimeout = CapSkip.TimeoutException; // System.TimeoutException is in scope too (CS0104)
static class Solve
{
// Local mode. In Server mode, use your server's address instead. If API Key
// Validation is on in CapSkip, also pass apiKey: "YOUR_KEY" here.
static readonly CapSkipClient Client = new(host: "127.0.0.1", port: 8080);
public static Task<SolveResult> Run(string siteKey, string pageUrl, bool invisible,
string? rqData, Uri? proxy, CancellationToken ct)
{
var options = new Dictionary<string, object?>();
if (rqData is not null) options["data"] = rqData; // Enterprise rqdata, fresh per task
if (proxy is not null)
{
// Uri.Authority drops a default port such as 80, so build host:port by hand.
var login = proxy.UserInfo.Length > 0 ? Uri.UnescapeDataString(proxy.UserInfo) + "@" : "";
options["proxy"] = new Proxy(proxy.Scheme.ToUpperInvariant(), $"{login}{proxy.Host}:{proxy.Port}");
}
// invisible: false sends nothing, true sends invisible=1.
return Client.HCaptchaAsync(siteKey, pageUrl, invisible, options, ct);
}
}The result has four properties to know. Token and Code hold the same string. RespKey is the second value hCaptcha hands the widget, the one hcaptcha.getRespKey() returns in a browser. Most sites never read it, and it is an empty string, not null, when there is none. UserAgent is the User-Agent string of the browser that earned the token. It stays null if the reply names none, and then Step 3 sends no User-Agent and my mock site refused the post.
The SDK checks your arguments before any request. A blank sitekey, a page address that is not absolute http or https, an invisible value other than 0, 1, true or false, and any parameter that CapSkip’s hCaptcha API does not take, 2Captcha’s domain among them, raise a ValidationException, and the mock received no task for any of them. Otherwise the SDK submits the task and polls at once, again after 0.25 seconds, and then with the gap doubling up to 5 seconds. Against the mock the polls landed within 0.15 seconds of 0, 0.25, 0.75, 1.75, 3.75, 7.75 and 12.75 seconds after the submit.
The wait ends with a TimeoutException after RecaptchaTimeout, 300 seconds unless you pass recaptchaTimeout to the constructor or a timeout option to one call. hCaptcha gets that longer timeout because the widget is passed in a real browser, and the docs say a solve usually takes 10 to 30 seconds. The deadline is checked between polls: with a timeout of 22 seconds and a mock that never answered, the exception arrived 22.9 seconds after the call.
Every exception the SDK throws extends CapSkipError. ValidationException is a local refusal. NetworkException means the request failed or CapSkip did not answer with HTTP 200. ApiException means CapSkip replied with an error, and its message is the whole reply body (JSON after a poll and bare text after a submit, against the mock), so the code sits inside it. Two traps follow. TimeoutException also exists in System, and implicit usings import System, so a bare TimeoutException next to using CapSkip fails with CS0104, an ambiguous reference. The first block aliases the SDK’s class as SolverTimeout. And a CancellationToken that fires during the submit comes out as a NetworkException wrapping the cancellation, while one that fires during polling comes out as a TaskCanceledException.
Step 3: post the form from a handler of its own
The package keeps its own HttpClient for CapSkip, so nothing below shares it. The form post is different, because a cookie jar belongs to the handler and two sign-ups must not share cookies. Site.SignUp builds a handler with its own CookieContainer, wraps a client around it and disposes both at the end. Microsoft’s HttpClient guidelines name separate cookie containers as a reason for more than one instance.
The page load comes first, so the page’s cookies go back with the post. Both requests carry the User-Agent from the result. HttpClient sends none unless you set one: with that line removed, the mock site, which checks it against the token, saw an empty one and refused every post. The post also carries a Referer and an Origin, because HttpClient sends neither. My mock site requires one of them, a rule I chose, and I did not measure how many real sites do. The token goes into h-captcha-response and g-recaptcha-response. It works once and, according to the docs, lasts about two minutes, so post it immediately.
A proxy is optional, though the docs warn that a steady stream of solves from one IP is what hCaptcha’s rate limits catch. The Uri you pass goes to CapSkip through Solve.Run and to this handler, because the docs ask strict sites for the form to come from the same address as the solve. WebProxy ignores a login in the address, so the handler lifts it into a NetworkCredential. A mock proxy that demanded Basic credentials accepted it, even with a percent sign in the password. I did not run the site leg through SOCKS.
static class Site
{
// One HttpClient per sign-up, because the cookie jar belongs to the handler and two
// sign-ups must not share cookies. Dispose it when the sign-up is over.
public static async Task<(HttpStatusCode Status, string Body)> SignUp(
string pageUrl, SolveResult solved, string email, Uri? proxy, CancellationToken ct)
{
var handler = new HttpClientHandler { CookieContainer = new CookieContainer() };
if (proxy is not null)
{
var address = new UriBuilder(proxy.Scheme, proxy.Host, proxy.Port).Uri;
var login = proxy.UserInfo.Split(':', 2).Select(Uri.UnescapeDataString).ToArray();
handler.Proxy = new WebProxy(address)
{
Credentials = login.Length == 2 ? new NetworkCredential(login[0], login[1]) : null,
};
}
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
// The User-Agent the token was earned with, on the page load and on the post.
if (solved.UserAgent is not null)
http.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", solved.UserAgent);
using (await http.GetAsync(pageUrl, ct)) { } // the page's own cookies land in the jar
var origin = new Uri(pageUrl).GetLeftPart(UriPartial.Authority);
using var post = new HttpRequestMessage(HttpMethod.Post, pageUrl)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["email"] = email, // your form's own fields go here
["h-captcha-response"] = solved.Token!, // always set on an hCaptcha result
["g-recaptcha-response"] = solved.Token!, // the widget fills both
}),
};
post.Headers.TryAddWithoutValidation("Referer", pageUrl); // HttpClient sends none
post.Headers.TryAddWithoutValidation("Origin", origin);
using var response = await http.SendAsync(post, ct);
return (response.StatusCode, await response.Content.ReadAsStringAsync(ct));
}
}Step 4: run the whole hCaptcha in C# loop
The last block is the Main method. Edit the PageUrl and SiteKey constants (as pasted it requests example.com, which has no widget), then run dotnet run in the project folder. It makes up to three attempts under one CancellationTokenSource, and each attempt has the SDK’s own 300 second wait inside it. By my reading of the CapSkip 4.1.0 source, one task can run up to 300 seconds before CapSkip calls it unsolvable, after waiting up to 250 seconds for a Max. Threads slot, so under load raise recaptchaTimeout.
ERROR_CAPTCHA_UNSOLVABLE is the one code where an immediate new task can help, according to the docs. ERROR_NO_SLOT_AVAILABLE, which a key over its rate limit gets, is retried after a 5 second pause. Every other code ends the run, and so does a ValidationException, since the same arguments would be refused again. A site that answers 400 or higher gets a new task and a new token. A 200 does not always mean success, because many sites put the error inside the page, so swap the status test for something only a success shows. When the 30 second HttpClient.Timeout of the form post expires, the last catch reports it.
static class Program
{
const string PageUrl = "https://example.com/signup"; // the page that shows the widget
const string SiteKey = "YOUR_SITEKEY"; // the data-sitekey of its h-captcha div
static readonly Uri? ProxyUri = null; // for example new Uri("http://user:[email protected]:8080")
// The reply body is the exception message, JSON on a poll and bare text on a submit.
static string ErrorCode(ApiException ex)
{
var found = Regex.Match(ex.Message, "ERROR_[A-Z_]+");
return found.Success ? found.Value : ex.Message;
}
static async Task<int> Main()
{
// One budget for every attempt. Cancelling it stops a wait or a request at once.
using var budget = new CancellationTokenSource(TimeSpan.FromMinutes(6));
try
{
for (var attempt = 1; attempt <= 3; attempt++)
{
SolveResult solved;
try
{
solved = await Solve.Run(SiteKey, PageUrl, invisible: false, rqData: null, ProxyUri, budget.Token);
}
catch (ApiException ex) when (ErrorCode(ex) is "ERROR_CAPTCHA_UNSOLVABLE" or "ERROR_NO_SLOT_AVAILABLE")
{
Console.WriteLine($"attempt {attempt}: {ErrorCode(ex)}, new task");
// The second code means the key is over its rate limit, so pause first.
if (ErrorCode(ex) == "ERROR_NO_SLOT_AVAILABLE") await Task.Delay(TimeSpan.FromSeconds(5), budget.Token);
continue;
}
var (status, body) = await Site.SignUp(PageUrl, solved, "[email protected]", ProxyUri, budget.Token);
if ((int)status < 400)
{
Console.WriteLine($"{(int)status} {body[..Math.Min(40, body.Length)]}");
return 0;
}
Console.WriteLine($"attempt {attempt}: the site answered {(int)status}, new token");
}
Console.WriteLine("no accepted form after 3 attempts");
}
// A cancelled submit surfaces as a NetworkException, a cancelled poll as a
// TaskCanceledException, so test the budget itself.
catch (Exception) when (budget.IsCancellationRequested) { Console.WriteLine("the 6 minute budget ran out"); }
catch (ApiException ex) { Console.WriteLine($"CapSkip answered {ErrorCode(ex)}"); }
catch (SolverTimeout ex) { Console.WriteLine($"no token in time: {ex.Message}"); }
catch (CapSkipError ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
catch (HttpRequestException ex) { Console.WriteLine($"request failed: {ex.Message}"); }
catch (TaskCanceledException) { Console.WriteLine("the site did not answer in 30 seconds"); } // HttpClient.Timeout
return 1;
}
}Against the mocks, which prove nothing about a real site, every row came out the same on net10.0 and net8.0.
| Mock setting | What the program did |
|---|---|
| Token ready on the 7th poll, SDK defaults | One task, polls within 0.15 seconds of 0, 0.25, 0.75, 1.75, 3.75, 7.75 and 12.75 seconds after the submit, then 200 |
| Two refusals (ERROR_CAPTCHA_UNSOLVABLE, or ERROR_NO_SLOT_AVAILABLE with a pause), then success; or refusals every time | 200 on the third task; or three attempt lines, exit code 1 and no request to the site |
| Site refuses the first form post with a 403 | A new task and token, then 200 |
| Blank sitekey | A ValidationException line and exit code 1, with no task at the mock |
| ERROR_ZERO_BALANCE, ERROR_PROXY_CONNECT_REFUSED or ERROR_RECAPTCHA_INVALID_SITEKEY | Each stopped the run after one task |
| Invisible call, Enterprise call, proxy with or without a login | in.php saw invisible=1, the data value, or proxytype HTTP plus the address, and the site requests arrived through the proxy mock |
| An apiKey in the constructor, key validation on | The key reached the submit and every poll. The default key got ERROR_KEY_DOES_NOT_EXIST |
| recaptchaTimeout of 22 and a mock that never answers | TimeoutException, 22.9 seconds after the call |
| Budget of 2 seconds, during a poll wait or a hung in.php | The budget message after 2.0 seconds both times |
| Nothing listening at CapSkip or at the site; a site page that never answers | NetworkException; request failed; the 30 second message after 30 seconds |
| Three sign-ups at once | Three tasks, three sessions, three User-Agents on the posts, three 200 answers |
Two snippets cover the rest. The first swaps the call for an invisible or an Enterprise widget. The second signs up several accounts at once: replace the lines between the braces of the outer try in Main with it, and keep the catch lines. Each sign-up builds its own handler, and the mock gives each task its own User-Agent, so a mix-up would have failed the posts. The snippet passes no rqData, so for an Enterprise site read a fresh value inside the lambda.
// Snippet, not part of Program.cs. In Main, replace the Solve.Run line with one of these. // An invisible widget (data-size="invisible"): solved = await Solve.Run(SiteKey, PageUrl, invisible: true, rqData: null, ProxyUri, budget.Token); // An Enterprise widget. Read a fresh rqdata value from the page before every attempt, // because it is single use. The literal below stands in for that code. solved = await Solve.Run(SiteKey, PageUrl, invisible: false, rqData: "RQDATA_FROM_THE_PAGE", ProxyUri, budget.Token);
// Snippet, not part of Program.cs. In Main, replace the lines between the braces of the outer try
// with these, and keep the catch lines. Three accounts sign up at once on one SDK client.
var emails = new[] { "[email protected]", "[email protected]", "[email protected]" };
var codes = await Task.WhenAll(emails.Select(async email =>
{
var solved = await Solve.Run(SiteKey, PageUrl, false, null, ProxyUri, budget.Token);
var (status, _) = await Site.SignUp(PageUrl, solved, email, ProxyUri, budget.Token);
return (int)status;
}));
Console.WriteLine(string.Join(" ", codes));
return codes.All(c => c < 400) ? 0 : 1;Common errors
| What you see | Cause | Fix |
|---|---|---|
| ValidationException | Refused before sending: a blank sitekey, a page address that is not absolute http or https, a bad invisible value, or a parameter such as domain that the hCaptcha API does not take | Fix the argument. Retrying cannot help |
| ERROR_RECAPTCHA_INVALID_SITEKEY | The sitekey was refused for that page | Re-copy data-sitekey and check the page address |
| ERROR_CAPTCHA_UNSOLVABLE | CapSkip did not pass the widget | The loop submits a new task, up to three times |
| ERROR_PROXY_FORMAT or ERROR_PROXY_CONNECT_REFUSED | CapSkip could not read the proxy value, or reach the target through it (the second case I only simulated) | Pass a Uri with a scheme, a host and a port, and test the proxy on its own |
| ERROR_WRONG_METHOD | A CapSkip older than 4.0.0 may not know the hCaptcha method (not tested) | Update CapSkip to 4.0.0 or later |
| ERROR_NO_SLOT_AVAILABLE, ERROR_ZERO_BALANCE or ERROR_IP_NOT_ALLOWED | Only with API Key Validation on. By my reading of the 4.1.0 source: the key is over its rate limit, out of credits, or a one-device key is in use elsewhere | The loop pauses and retries the first. Change the key’s settings in CapSkip for the others |
| ERROR_WRONG_USER_KEY or ERROR_KEY_DOES_NOT_EXIST | API Key Validation is on, and the apiKey in your constructor call is missing or wrong | Pass a valid apiKey in the CapSkipClient line |
| TimeoutException | No token arrived within recaptchaTimeout. An empty reply counts as not ready, so the SDK keeps polling | Check the CapSkip Monitor for the task, then raise recaptchaTimeout |
| NetworkException when the call starts | No program answers at that host and port. The inner exception is an HttpRequestException | Run CapSkip, then copy its host and port from the connection settings into your constructor call |
| Error CS0104: ambiguous reference | TimeoutException exists in System and in CapSkip. ValidationException clashes the same way once you import System.ComponentModel.DataAnnotations | Alias the SDK’s class, as the first block does, or write its full name |
| A valid-looking token is refused | The docs name a reused token, one older than about two minutes, and a User-Agent other than the result’s. My mock also refused a missing User-Agent, Referer, Origin or session cookie. A 407 means the proxy wants a login | Post each token once, right away, from Site.SignUp |
Using CapSkip on a server
In Server mode CapSkip listens on your network address or a public IP, so the same hCaptcha in C# code works from another PC, a Windows VPS or a hosted platform. Server mode needs CapSkip to run as administrator, and the setup guide recommends a static public IP. Change the host in the CapSkipClient line to the server’s address and nothing else changes.
CapSkip opens its port to every address when Server mode starts, so to keep it to the machines you expect, add a Windows Firewall block rule for the others, and turn on API Key Validation with an apiKey in the CapSkipClient line. The solve now runs on the server, so hCaptcha sees that machine’s network.
FAQ
How do I solve an invisible or Enterprise hCaptcha in C#?
With the same HCaptchaAsync call. For a widget whose data-size is invisible, pass true for the invisible bool. For an Enterprise site that sets rqdata, pass the value under the key data and read a fresh one for every task, because the docs say the site issues it for one challenge. The first snippet in Step 4 shows both.
Can I register the SDK with IHttpClientFactory?
There is nothing to register. The package makes its calls to CapSkip with one static HttpClient of its own. Keep Site.SignUp off the factory too: Microsoft’s guidelines warn that pooled handlers share their CookieContainer.
Why does the site reject a token CapSkip gave me?
A strict site may refuse a token that hCaptcha scored as risky, even when the widget was passed, and Enterprise sites are the usual case. The docs advise High score mode, in the hCaptcha settings. It needs Google Chrome on the CapSkip PC, and by my reading of the 4.1.0 source it is on by default. I had no strict site to test it on.
Solving hCaptcha in C# comes down to one HCaptchaAsync call, a loop that retries only the codes worth retrying, and a form post from a handler with the right cookies and User-Agent. CapSkip is a local captcha bypass with a flat license, so another try costs only the time it takes. The C# CAPTCHA solver page covers the other CAPTCHA types in .NET, and the hCaptcha demo shows a widget being passed.
