{"id":26714,"date":"2026-10-29T07:24:52","date_gmt":"2026-10-29T07:24:52","guid":{"rendered":"https:\/\/capskip.com\/?p=26714"},"modified":"2026-10-29T07:24:52","modified_gmt":"2026-10-29T07:24:52","slug":"minimal-api-captcha-solver","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/minimal-api-captcha-solver\/","title":{"rendered":"\u5982\u4f55\u7528 C# \u6784\u5efa\u6700\u5c0f API \u9a8c\u8bc1\u7801\u8bc6\u522b\u7aef\u70b9"},"content":{"rendered":"<p>To build a minimal API CAPTCHA solver, register one CapSkipClient as a singleton in ASP.NET Core. Expose a POST endpoint that takes a sitekey and a page URL. Wrap each call in a deadline and a SemaphoreSlim, and map the SDK&#8217;s four exceptions to HTTP status codes. Your scrapers, workers and scripts in any language then call one internal address. They never see CapSkip&#8217;s port or key. CapSkip runs on your own Windows machine, so the service has no per-solve bill either. This guide builds the service in about 120 lines and shows what each part does when CapSkip is slow, closed or silent.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>CapSkip running on a Windows PC, with the reCAPTCHA solver switched on. The <a href=\"https:\/\/capskip.com\/setup-guide\/\">setup guide<\/a> walks through the first start.<\/li>\n<li>The .NET 10 SDK. Everything below ran on Windows 11 with SDK 10.0.100, ASP.NET Core 10.0.0 and the CapSkip NuGet package 1.3.0.<\/li>\n<li>A PowerShell prompt. The commands use PowerShell syntax.<\/li>\n<li>A page with a reCAPTCHA v2 checkbox. The service needs its sitekey and its address, and nothing else about the page.<\/li>\n<li>An address for CapSkip. In Local mode it answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a service on another machine can call it over the API. Both are set under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<\/ul>\n<p>The numbers in this guide come from a mock CapSkip on the same PC. ASP.NET Core and the SDK are the real ones. The mock holds each solve for a set time, two seconds unless a test says otherwise, and real solve times will differ. Nothing here was run against a live CapSkip or a live site.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Why put a service in front of CapSkip<\/h2>\n<p>If one C# app solves CAPTCHAs, call the SDK from that app. The <a href=\"https:\/\/capskip.com\/csharp-captcha-solver\/\">C# CAPTCHA solver page<\/a> covers that. A service pays off when several callers share one CapSkip. Four jobs then belong in one place.<\/p>\n<ul>\n<li>One deadline. The .NET SDK polls for up to 300 seconds by default, so each caller would need its own timer.<\/li>\n<li>One limit. CapSkip runs a fixed number of solves at once, and extra jobs wait. A queue you control is easier to reason about than a queue inside CapSkip.<\/li>\n<li>One key. Callers get a service key. CapSkip&#8217;s address and API key stay in the service&#8217;s settings.<\/li>\n<li>Any language. A Python script, a PHP site and a PowerShell task all speak HTTP and JSON.<\/li>\n<\/ul>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: create the project<\/h2>\n<p>The web template is the empty ASP.NET Core project: one Program.cs, no controllers. Keep the project in its own folder. The package install writes to the project file only.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># &quot;web&quot; is the empty ASP.NET Core template. This guide uses .NET SDK 10.\ndotnet new web -n CapSkipSolver -f net10.0\ncd CapSkipSolver\ndotnet add package CapSkip<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: put the settings in appsettings.json<\/h2>\n<p>The CapSkip section holds the address, the solver&#8217;s API key, the number of CapSkip threads to match and the deadline in seconds. The Solver section holds the key your callers will send. Replace both placeholders before you run anything.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"json\" class=\"EnlighterJSRAW\">{\n  &quot;CapSkip&quot;: {\n    &quot;Host&quot;: &quot;127.0.0.1&quot;,\n    &quot;Port&quot;: 8080,\n    &quot;ApiKey&quot;: &quot;YOUR_API_KEY&quot;,\n    &quot;MaxThreads&quot;: 10,\n    &quot;DeadlineSeconds&quot;: 120\n  },\n  &quot;Solver&quot;: {\n    &quot;ApiKey&quot;: &quot;YOUR_SERVICE_KEY&quot;\n  },\n  &quot;Logging&quot;: {\n    &quot;LogLevel&quot;: {\n      &quot;Default&quot;: &quot;Information&quot;,\n      &quot;Microsoft.AspNetCore&quot;: &quot;Warning&quot;\n    }\n  },\n  &quot;AllowedHosts&quot;: &quot;*&quot;\n}<\/pre>\n<\/div>\n<p>Any value can be overridden by an environment variable. A double underscore stands for the colon, so CapSkip__Port overrides CapSkip:Port. The section called Run the service away from CapSkip uses that to point the service at another machine without editing the file.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: read the settings and refuse to start without a key<\/h2>\n<p>Replace everything in the template&#8217;s Program.cs with the blocks that follow, and add each new block below the one before. The project builds only after Step 10, because the records and helper functions come last.<\/p>\n<p>The top of Program.cs binds the CapSkip section to a record and reads the service key. The key check uses IsNullOrWhiteSpace, not a null check with throw. On the mock, an empty string passed a null check and the service started, then accepted every request that sent an empty header.<\/p>\n<p>This block also holds the one .NET-specific trap. System and CapSkip both define a class called TimeoutException. With implicit usings and using CapSkip, a bare TimeoutException does not compile. A switch arm on System.TimeoutException compiles but never matches the SDK&#8217;s exception. In the service, that sent an SDK timeout to the catch-all arm and returned 500 instead of 504. The last using line gives the SDK&#8217;s class its own name.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">\/\/ Program.cs\nusing System.Net.Sockets;\nusing System.Security.Cryptography;\nusing System.Text;\nusing CapSkip;\n\/\/ System and CapSkip both define TimeoutException, so give the SDK's\n\/\/ one its own name.\nusing SolverTimeout = CapSkip.TimeoutException;\n\nvar builder = WebApplication.CreateBuilder(args);\n\n\/\/ Reads the &quot;CapSkip&quot; section of appsettings.json. The environment\n\/\/ variable CapSkip__Port overrides CapSkip:Port (__ stands for a colon).\nvar cfg = builder.Configuration.GetSection(&quot;CapSkip&quot;).Get&lt;CapSkipSettings&gt;()\n    ?? new CapSkipSettings();\nvar serviceKey = builder.Configuration[&quot;Solver:ApiKey&quot;];\nif (string.IsNullOrWhiteSpace(serviceKey))\n    throw new InvalidOperationException(&quot;Set Solver:ApiKey before starting.&quot;);<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 4: register one client and one gate<\/h2>\n<p>CapSkipClient is safe as a singleton. In the SDK source it keeps no per-call state, it copies the options dictionary on every call, and all instances share one static HttpClient. There is nothing to dispose and no need for IHttpClientFactory. The SDK uses one private static HttpClient and does not take yours. A long-lived connection would not notice a DNS change, so use an IP address for the CapSkip host.<\/p>\n<p>The SemaphoreSlim is the gate. Give it one slot per CapSkip thread, which is the Max. Threads setting in CapSkip (it defaults to 10). AddProblemDetails together with UseStatusCodePages makes the 400, 404, 405 and 415 answers that ASP.NET Core writes itself come back in the same format as ours. Without UseStatusCodePages, those answers had no body.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">builder.Services.AddSingleton(cfg);\n\/\/ One client for the whole process. It keeps no per-call state.\nbuilder.Services.AddSingleton(new CapSkipClient(\n    cfg.ApiKey, cfg.Host, cfg.Port, recaptchaTimeout: cfg.SdkTimeoutSeconds));\n\/\/ One slot per CapSkip thread (Max. Threads in the CapSkip settings).\nbuilder.Services.AddSingleton(new SemaphoreSlim(cfg.MaxThreads));\n\/\/ With these two calls, 400, 404, 405 and 415 also come back as\n\/\/ ProblemDetails.\nbuilder.Services.AddProblemDetails();\n\nvar app = builder.Build();\napp.UseStatusCodePages();<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 5: check the service key<\/h2>\n<p>An unprotected solve endpoint on a network address is a free solver for everyone who can reach the port. This middleware guards the \/solve path. FixedTimeEquals compares the bytes in the same time wherever they differ, and it returns false for a different length instead of throwing. A missing key, a wrong key and a too-long key all got 401 on the mock.<\/p>\n<p>It returns 401, not 403, because there is one credential and it is missing or wrong. The middleware runs before model binding, so a request with no key and a broken JSON body gets 401, not 400.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">\/\/ Only callers that know the service key may use \/solve. FixedTimeEquals\n\/\/ takes the same time wherever the first wrong byte is.\nvar expected = Encoding.UTF8.GetBytes(serviceKey);\napp.Use(async (ctx, next) =&gt;\n{\n    var sent = Encoding.UTF8.GetBytes(ctx.Request.Headers[&quot;X-API-Key&quot;].ToString());\n    if (ctx.Request.Path.StartsWithSegments(&quot;\/solve&quot;)\n        &amp;&amp; !CryptographicOperations.FixedTimeEquals(sent, expected))\n    {\n        await Fail(401, &quot;Missing or wrong X-API-Key&quot;).ExecuteAsync(ctx);\n        return;\n    }\n    await next(ctx);\n});<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 6: add a health check<\/h2>\n<p>The SDK has no ping call, so the health endpoint opens a TCP connection to CapSkip&#8217;s port and gives up after three seconds. With CapSkip closed it answered 503 after about two seconds, because Windows refuses a loopback connection that slowly. It tells a load balancer or a monitoring script that the service is up and CapSkip is not. It does not check that a solve works.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">\/\/ CapSkip has no ping call, so check that its port accepts a connection.\napp.MapGet(&quot;\/health&quot;, async (CapSkipSettings s, CancellationToken ct) =&gt;\n{\n    using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct);\n    probe.CancelAfter(TimeSpan.FromSeconds(3));\n    try\n    {\n        using var tcp = new TcpClient();\n        await tcp.ConnectAsync(s.Host, s.Port, probe.Token);\n        return Results.Ok(new { status = &quot;ok&quot; });\n    }\n    catch (Exception ex) when (ex is SocketException or OperationCanceledException)\n    {\n        return Fail(503, &quot;CapSkip is not reachable&quot;);\n    }\n});<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 7: validate the request and start the deadline<\/h2>\n<p>A record parameter does not make a missing field an error. System.Text.Json leaves the property null, so the handler checks the sitekey and the URL itself and answers with a validation problem.<\/p>\n<p>The deadline is one linked CancellationTokenSource. It is cancelled when the caller hangs up (RequestAborted) or when the deadline passes (CancelAfter). Every later step takes its token, so there is only one clock to look at.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">app.MapPost(&quot;\/solve&quot;, async (SolveRequest req, CapSkipClient solver,\n    SemaphoreSlim gate, CapSkipSettings s, HttpContext http) =&gt;\n{\n    if (string.IsNullOrWhiteSpace(req.Sitekey)\n        || !Uri.IsWellFormedUriString(req.Url, UriKind.Absolute))\n        return Results.ValidationProblem(new Dictionary&lt;string, string[]&gt;\n        {\n            [&quot;request&quot;] = [&quot;sitekey and an absolute url are required&quot;]\n        });\n\n    \/\/ One token for the whole request. It is cancelled when the caller\n    \/\/ hangs up (RequestAborted) or when the deadline passes.\n    using var deadline = CancellationTokenSource.CreateLinkedTokenSource(\n        http.RequestAborted);\n    deadline.CancelAfter(TimeSpan.FromSeconds(s.DeadlineSeconds));<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 8: wait for a slot, solve and release<\/h2>\n<p>The wait for a slot uses the same token, plus its own limit of QueueSeconds (20 in the settings record). If every slot stays busy for that long, the caller gets 503 and can retry. Without a limit the request would sit in the queue until the deadline took it.<\/p>\n<p>CapSkipError is the base class of the SDK&#8217;s four exceptions, and the catch names it together with the cancellation exception. RecaptchaAsync takes the token too. The SDK passes it to every poll request, so the deadline cancels the HTTP call in flight instead of waiting for it. The finally block releases the slot only when it was taken. Releasing a slot you never held would let one more solve through than CapSkip can run.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">    var held = false;\n    try\n    {\n        \/\/ Wait for a free slot, but not longer than QueueSeconds.\n        if (!await gate.WaitAsync(TimeSpan.FromSeconds(s.QueueSeconds), deadline.Token))\n            return Fail(503, &quot;All solver slots are busy&quot;);\n        held = true;\n        var result = await solver.RecaptchaAsync(\n            req.Sitekey, req.Url, req.Options(), deadline.Token);\n        return Results.Ok(new SolveResponse(result.Code));\n    }\n    catch (Exception ex) when (ex is CapSkipError or OperationCanceledException)\n    {\n        return ToProblem(ex, http.RequestAborted.IsCancellationRequested,\n            deadline.IsCancellationRequested);\n    }\n    finally\n    {\n        if (held) gate.Release();\n    }\n});<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 9: turn exceptions into status codes<\/h2>\n<p>This is the part callers notice. The order of the switch cases matters, because the SDK reports a cancelled call in two different ways. A cancelled submit comes out as a NetworkException (&#8220;The operation was canceled&#8221;). A cancelled poll comes out as a TaskCanceledException. Mapping by exception type would tell a caller that CapSkip is down when really the deadline passed. So the two flags are checked first.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">app.Run();\n\nstatic IResult Fail(int status, string title, string? detail = null) =&gt;\n    Results.Problem(statusCode: status, title: title, detail: detail);\n\n\/\/ Check the two flags first: a cancelled submit surfaces as\n\/\/ NetworkException, a cancelled poll as TaskCanceledException.\nstatic IResult ToProblem(Exception ex, bool callerLeft, bool deadlinePassed) =&gt; ex switch\n{\n    _ when callerLeft =&gt; Results.StatusCode(499),\n    _ when deadlinePassed =&gt; Fail(504, &quot;Solve deadline exceeded&quot;),\n    SolverTimeout =&gt; Fail(504, &quot;CapSkip timed out&quot;),\n    ValidationException =&gt; Fail(400, &quot;Bad solve parameters&quot;, ex.Message),\n    ApiException when ex.Message.Contains(&quot;UNSOLVABLE&quot;) =&gt; Fail(422, &quot;CAPTCHA could not be solved&quot;),\n    ApiException =&gt; Fail(502, &quot;CapSkip refused the request&quot;),\n    NetworkException =&gt; Fail(503, &quot;CapSkip is not reachable&quot;),\n    _ =&gt; Fail(500, &quot;Unexpected error&quot;),\n};<\/pre>\n<\/div>\n<p>The SDK&#8217;s ApiException carries its text in the message and has no code property. The unsolvable case is matched on the word UNSOLVABLE in that message. The status codes below were each provoked against the mock.<\/p>\n<table>\n<thead>\n<tr>\n<th>Status<\/th>\n<th>What happened<\/th>\n<th>How long it took<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>503<\/td>\n<td>CapSkip is closed, so the submit was refused<\/td>\n<td>2.1 s<\/td>\n<\/tr>\n<tr>\n<td>422<\/td>\n<td>CapSkip reported ERROR_CAPTCHA_UNSOLVABLE<\/td>\n<td>3.8 s<\/td>\n<\/tr>\n<tr>\n<td>502<\/td>\n<td>CapSkip refused the service&#8217;s own API key<\/td>\n<td>under 0.1 s<\/td>\n<\/tr>\n<tr>\n<td>400<\/td>\n<td>A v2 request carried an action, which only v3 accepts<\/td>\n<td>under 0.1 s<\/td>\n<\/tr>\n<tr>\n<td>504<\/td>\n<td>The deadline passed with a silent CapSkip (6 s test deadline)<\/td>\n<td>6.0 s<\/td>\n<\/tr>\n<tr>\n<td>504<\/td>\n<td>The SDK&#8217;s own 5 s timeout fired first<\/td>\n<td>7.8 s<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The 502 is deliberate. A rejected CapSkip key is the service&#8217;s mistake, not the caller&#8217;s, so it is not a 401 or a 403. The last row is longer than 5 seconds because the SDK checks its clock only after each wait. It waits 0.25 s, then doubles the wait up to 5 s. The wait that starts at 3.75 s lasts 4 s, so the timeout fires at 7.75 s. The same schedule explains the 3.8 s row: the result is ready at 2 s and found at the 3.75 s check.<\/p>\n<p>One failure never gets an early answer. If CapSkip closes after it accepted the job, the SDK treats each refused poll as not ready yet. The caller then waits for the whole deadline (about 8.0 s with an 8 s deadline on the mock) and gets a 504. Only a refused submit gives a quick 503.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 10: add the records<\/h2>\n<p>The settings record carries the defaults. The request record builds the options dictionary only from fields the caller sent. A v3 caller can send a version and an action, and a v2 caller sends neither. SdkTimeoutSeconds is the SDK&#8217;s own limit, and QueueSeconds is how long a request may wait for a slot. Neither is in appsettings.json, so the defaults apply. Keep the SDK&#8217;s limit above the deadline so the deadline fires first.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">record CapSkipSettings(string Host = &quot;127.0.0.1&quot;, int Port = 8080,\n    string ApiKey = &quot;capskip&quot;, int MaxThreads = 10, int DeadlineSeconds = 120,\n    int QueueSeconds = 20, int SdkTimeoutSeconds = 300);\n\nrecord SolveRequest(string Sitekey, string Url, string? Version = null, string? Action = null)\n{\n    \/\/ Extra reCAPTCHA options, only when the caller sent them.\n    public Dictionary&lt;string, object?&gt; Options()\n    {\n        var o = new Dictionary&lt;string, object?&gt;();\n        if (Version is not null) o[&quot;version&quot;] = Version;\n        if (Action is not null) o[&quot;action&quot;] = Action;\n        return o;\n    }\n}\n\nrecord SolveResponse(string Token);<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Run it on Windows<\/h2>\n<p>Use the urls option of dotnet run, and do not count on the ASPNETCORE_URLS variable. The web template ships a launchSettings.json file with a random port, and dotnet run reads it. On the test PC, a plain dotnet run with ASPNETCORE_URLS set ignored the variable, listened on the profile&#8217;s port and switched to the Development environment. The urls option beat the profile, and the no-launch-profile switch made the variable work again and gave the Production environment. The urls option alone still leaves the profile&#8217;s Development environment on, so add the switch too if you want Production. With no profile and no URL set, Kestrel falls back to localhost:5000, which was not tested here.<\/p>\n<p>For the same PC, bind to 127.0.0.1. Nothing else on the network can reach the service then.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Local mode: CapSkip and this service run on the same PC. The service\n# listens on 127.0.0.1 only, so no other machine can reach it.\ndotnet run --urls http:\/\/127.0.0.1:5080<\/pre>\n<\/div>\n<p>For callers on other machines, bind to 0.0.0.0, which means every network adapter. On the test PC, that address answered on its own LAN IP with the right key and refused the wrong one with 401. A service bound to 127.0.0.1 refused the LAN address. Set a real service key first. The service key also crosses the network in clear text over plain HTTP, so use this only on a network you trust. Windows Firewall may ask you to allow the program, so check what rule it creates for the port.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Callers on other machines: 0.0.0.0 means every network adapter.\n# Put the service key in place first, because anyone on the network can\n# reach this port.\ndotnet run --urls http:\/\/0.0.0.0:5080<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Run the service away from CapSkip<\/h2>\n<p>The service does not have to sit on the same PC. Put CapSkip in Server mode on a Windows machine you control, preferably with a static public IP. Point the service at it with two environment variables. The test ran this shape against a mock bound to a LAN address, and a solve took 1.85 s.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># The service runs on another PC and CapSkip runs in Server mode on\n# 203.0.113.10. Environment variables override appsettings.json.\n$env:CapSkip__Host = '203.0.113.10'\n$env:CapSkip__Port = '8080'\ndotnet run --urls http:\/\/0.0.0.0:5080<\/pre>\n<\/div>\n<p>Three cautions. Turn on API key validation in CapSkip and use the same key in the CapSkip section of the settings. The SDK builds a plain http address, so that key crosses the network in clear text. CapSkip also adds an allow-any rule for its port, so add a block rule for every address that should not reach it. The <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a> section of the setup guide lists both modes.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Call it from PowerShell or Python<\/h2>\n<p>The caller sends the sitekey, the page URL and the service key. The answer is a JSON object with one field, token. Use the token within about two minutes, because a reCAPTCHA token expires, and use it once. The <a href=\"https:\/\/capskip.com\/recaptcha-token-expiration\/\">token expiry guide<\/a> explains the clock.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Ask the service for a token. It answers when the solve finishes.\n$body = @{\n    sitekey = 'YOUR_SITEKEY'\n    url     = 'https:\/\/example.com\/page-with-recaptcha'\n} | ConvertTo-Json\n$r = Invoke-RestMethod -Method Post -Uri http:\/\/127.0.0.1:5080\/solve `\n    -Headers @{ 'X-API-Key' = 'YOUR_SERVICE_KEY' } `\n    -ContentType 'application\/json' -Body $body\n$r.token<\/pre>\n<\/div>\n<p>Any other language works the same way. The Python caller below sets a timeout longer than the service&#8217;s deadline. That way the 504 from the service arrives before the caller&#8217;s own timer gives up.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"python\" class=\"EnlighterJSRAW\">import requests\n\n# The caller's timeout must be longer than DeadlineSeconds (120 here),\n# so the 504 from the service arrives before your own timeout fires.\nr = requests.post(\n    &quot;http:\/\/127.0.0.1:5080\/solve&quot;,\n    headers={&quot;X-API-Key&quot;: &quot;YOUR_SERVICE_KEY&quot;},\n    json={&quot;sitekey&quot;: &quot;YOUR_SITEKEY&quot;, &quot;url&quot;: &quot;https:\/\/example.com\/page-with-recaptcha&quot;},\n    timeout=130,\n)\nprint(r.status_code, r.json())<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What the gate does and does not do<\/h2>\n<p>With CapSkip&#8217;s thread limit set to 3 in the mock and ten simultaneous requests, the gate kept at most three jobs at the mock. Without the gate, all ten reached it and seven waited for a thread. The gate does not add speed, because three threads set the pace either way. With a one second hold, ten requests took about 7.2 s with the gate and 7.9 s without it. The gain is under a second. Its value shows when callers give up.<\/p>\n<p>In that run every caller gave up after 4 seconds and each solve needed 6. With the gate, three to five jobs reached CapSkip, because a waiter can take a slot that frees a moment before its own deadline. None were pending eight seconds later. Without it, all ten reached CapSkip, and seven were still pending six seconds after the callers gave up. Nobody was waiting for those solves, but they still held threads.<\/p>\n<p>One gap remains. When a caller hangs up, the service stops polling and frees its slot. The SDK has no cancel call, so assume CapSkip finishes the job and holds one thread until then. The mock behaves that way. The slot count can therefore run ahead of CapSkip&#8217;s real threads after a burst of cancellations, until those jobs finish. A real CapSkip was not tested, and its Row Timeout setting caps how long a job can run.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The whole Program.cs<\/h2>\n<p>This is Steps 3 to 10 joined, in order. Paste it over the template&#8217;s Program.cs.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"csharp\" class=\"EnlighterJSRAW\">\/\/ Program.cs\nusing System.Net.Sockets;\nusing System.Security.Cryptography;\nusing System.Text;\nusing CapSkip;\n\/\/ System and CapSkip both define TimeoutException, so give the SDK's\n\/\/ one its own name.\nusing SolverTimeout = CapSkip.TimeoutException;\n\nvar builder = WebApplication.CreateBuilder(args);\n\n\/\/ Reads the &quot;CapSkip&quot; section of appsettings.json. The environment\n\/\/ variable CapSkip__Port overrides CapSkip:Port (__ stands for a colon).\nvar cfg = builder.Configuration.GetSection(&quot;CapSkip&quot;).Get&lt;CapSkipSettings&gt;()\n    ?? new CapSkipSettings();\nvar serviceKey = builder.Configuration[&quot;Solver:ApiKey&quot;];\nif (string.IsNullOrWhiteSpace(serviceKey))\n    throw new InvalidOperationException(&quot;Set Solver:ApiKey before starting.&quot;);\n\nbuilder.Services.AddSingleton(cfg);\n\/\/ One client for the whole process. It keeps no per-call state.\nbuilder.Services.AddSingleton(new CapSkipClient(\n    cfg.ApiKey, cfg.Host, cfg.Port, recaptchaTimeout: cfg.SdkTimeoutSeconds));\n\/\/ One slot per CapSkip thread (Max. Threads in the CapSkip settings).\nbuilder.Services.AddSingleton(new SemaphoreSlim(cfg.MaxThreads));\n\/\/ With these two calls, 400, 404, 405 and 415 also come back as\n\/\/ ProblemDetails.\nbuilder.Services.AddProblemDetails();\n\nvar app = builder.Build();\napp.UseStatusCodePages();\n\n\/\/ Only callers that know the service key may use \/solve. FixedTimeEquals\n\/\/ takes the same time wherever the first wrong byte is.\nvar expected = Encoding.UTF8.GetBytes(serviceKey);\napp.Use(async (ctx, next) =&gt;\n{\n    var sent = Encoding.UTF8.GetBytes(ctx.Request.Headers[&quot;X-API-Key&quot;].ToString());\n    if (ctx.Request.Path.StartsWithSegments(&quot;\/solve&quot;)\n        &amp;&amp; !CryptographicOperations.FixedTimeEquals(sent, expected))\n    {\n        await Fail(401, &quot;Missing or wrong X-API-Key&quot;).ExecuteAsync(ctx);\n        return;\n    }\n    await next(ctx);\n});\n\n\/\/ CapSkip has no ping call, so check that its port accepts a connection.\napp.MapGet(&quot;\/health&quot;, async (CapSkipSettings s, CancellationToken ct) =&gt;\n{\n    using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct);\n    probe.CancelAfter(TimeSpan.FromSeconds(3));\n    try\n    {\n        using var tcp = new TcpClient();\n        await tcp.ConnectAsync(s.Host, s.Port, probe.Token);\n        return Results.Ok(new { status = &quot;ok&quot; });\n    }\n    catch (Exception ex) when (ex is SocketException or OperationCanceledException)\n    {\n        return Fail(503, &quot;CapSkip is not reachable&quot;);\n    }\n});\n\napp.MapPost(&quot;\/solve&quot;, async (SolveRequest req, CapSkipClient solver,\n    SemaphoreSlim gate, CapSkipSettings s, HttpContext http) =&gt;\n{\n    if (string.IsNullOrWhiteSpace(req.Sitekey)\n        || !Uri.IsWellFormedUriString(req.Url, UriKind.Absolute))\n        return Results.ValidationProblem(new Dictionary&lt;string, string[]&gt;\n        {\n            [&quot;request&quot;] = [&quot;sitekey and an absolute url are required&quot;]\n        });\n\n    \/\/ One token for the whole request. It is cancelled when the caller\n    \/\/ hangs up (RequestAborted) or when the deadline passes.\n    using var deadline = CancellationTokenSource.CreateLinkedTokenSource(\n        http.RequestAborted);\n    deadline.CancelAfter(TimeSpan.FromSeconds(s.DeadlineSeconds));\n\n    var held = false;\n    try\n    {\n        \/\/ Wait for a free slot, but not longer than QueueSeconds.\n        if (!await gate.WaitAsync(TimeSpan.FromSeconds(s.QueueSeconds), deadline.Token))\n            return Fail(503, &quot;All solver slots are busy&quot;);\n        held = true;\n        var result = await solver.RecaptchaAsync(\n            req.Sitekey, req.Url, req.Options(), deadline.Token);\n        return Results.Ok(new SolveResponse(result.Code));\n    }\n    catch (Exception ex) when (ex is CapSkipError or OperationCanceledException)\n    {\n        return ToProblem(ex, http.RequestAborted.IsCancellationRequested,\n            deadline.IsCancellationRequested);\n    }\n    finally\n    {\n        if (held) gate.Release();\n    }\n});\n\napp.Run();\n\nstatic IResult Fail(int status, string title, string? detail = null) =&gt;\n    Results.Problem(statusCode: status, title: title, detail: detail);\n\n\/\/ Check the two flags first: a cancelled submit surfaces as\n\/\/ NetworkException, a cancelled poll as TaskCanceledException.\nstatic IResult ToProblem(Exception ex, bool callerLeft, bool deadlinePassed) =&gt; ex switch\n{\n    _ when callerLeft =&gt; Results.StatusCode(499),\n    _ when deadlinePassed =&gt; Fail(504, &quot;Solve deadline exceeded&quot;),\n    SolverTimeout =&gt; Fail(504, &quot;CapSkip timed out&quot;),\n    ValidationException =&gt; Fail(400, &quot;Bad solve parameters&quot;, ex.Message),\n    ApiException when ex.Message.Contains(&quot;UNSOLVABLE&quot;) =&gt; Fail(422, &quot;CAPTCHA could not be solved&quot;),\n    ApiException =&gt; Fail(502, &quot;CapSkip refused the request&quot;),\n    NetworkException =&gt; Fail(503, &quot;CapSkip is not reachable&quot;),\n    _ =&gt; Fail(500, &quot;Unexpected error&quot;),\n};\n\nrecord CapSkipSettings(string Host = &quot;127.0.0.1&quot;, int Port = 8080,\n    string ApiKey = &quot;capskip&quot;, int MaxThreads = 10, int DeadlineSeconds = 120,\n    int QueueSeconds = 20, int SdkTimeoutSeconds = 300);\n\nrecord SolveRequest(string Sitekey, string Url, string? Version = null, string? Action = null)\n{\n    \/\/ Extra reCAPTCHA options, only when the caller sent them.\n    public Dictionary&lt;string, object?&gt; Options()\n    {\n        var o = new Dictionary&lt;string, object?&gt;();\n        if (Version is not null) o[&quot;version&quot;] = Version;\n        if (Action is not null) o[&quot;action&quot;] = Action;\n        return o;\n    }\n}\n\nrecord SolveResponse(string Token);<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors in a minimal API CAPTCHA solver<\/h2>\n<table>\n<thead>\n<tr>\n<th>What you see<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>CS0104 about TimeoutException<\/td>\n<td>System and CapSkip both define it<\/td>\n<td>Use the SolverTimeout alias from Step 3<\/td>\n<\/tr>\n<tr>\n<td>500 for an SDK timeout<\/td>\n<td>A switch arm names System.TimeoutException<\/td>\n<td>Catch the aliased SDK class<\/td>\n<\/tr>\n<tr>\n<td>Service ignores ASPNETCORE_URLS<\/td>\n<td>dotnet run applies launchSettings.json<\/td>\n<td>Pass the urls option instead<\/td>\n<\/tr>\n<tr>\n<td>Every call returns 401<\/td>\n<td>The X-API-Key header does not equal Solver:ApiKey<\/td>\n<td>Send the service key, not CapSkip&#8217;s<\/td>\n<\/tr>\n<tr>\n<td>503 from the health check<\/td>\n<td>Nothing accepts connections on CapSkip&#8217;s port<\/td>\n<td>Start CapSkip and check Local or Server mode<\/td>\n<\/tr>\n<tr>\n<td>502 on every solve<\/td>\n<td>CapSkip rejected the key in the CapSkip section<\/td>\n<td>Match it to the key set in CapSkip<\/td>\n<\/tr>\n<tr>\n<td>504 after the full deadline<\/td>\n<td>CapSkip is silent or the solve is slow<\/td>\n<td>Look at the solve in CapSkip&#8217;s Monitor tab, then raise the deadline<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Microsoft&#8217;s <a href=\"https:\/\/learn.microsoft.com\/en-us\/aspnet\/core\/fundamentals\/minimal-apis\/overview\" rel=\"nofollow noopener\" target=\"_blank\">minimal APIs overview<\/a> covers the framework side, and every CapSkip error code is listed in the <a href=\"https:\/\/capskip.com\/api-docs\/\">API docs<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Do I need a service if only one app solves CAPTCHAs?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No. Call the SDK from that app. A service helps when several callers, or callers in other languages, share one CapSkip and you want one deadline, one limit and one key. The <a href=\"https:\/\/capskip.com\/worker-service-captcha\/\">Worker Service guide<\/a> shows the in-process route for a background job.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can I host the service on Azure or another provider?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, but a hosted service runs on someone else&#8217;s machine, so it cannot reach 127.0.0.1 on your PC. Run CapSkip in Server mode on a Windows machine you control, preferably with a static public IP, and set CapSkip__Host to that address. Turn on API key validation, add a block rule for every other address, and remember that the key travels in clear text over plain HTTP. No hosting provider was tested for this guide.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does a cancelled request stop the solve in CapSkip?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No. The service stops polling and frees its slot. The SDK has no cancel call, so assume CapSkip keeps working on the job and holds one thread until it finishes. Keep the deadline long enough for a normal solve, so cancellations stay rare.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can the same endpoint solve reCAPTCHA v3?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes. Send version v3 and an action in the request body, and the options reach the SDK as they are. The service sends no score value, because CapSkip has no score setting. The <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">reCAPTCHA v2 solver page<\/a> covers the checkbox type this guide uses.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>To build a minimal API CAPTCHA solver, keep one CapSkipClient, one SemaphoreSlim and one linked cancellation token per request. Check the service key first. Check the two cancellation flags before the exception type. Start the service with the urls option, not the environment variable. Put a block rule in front of CapSkip if it runs in Server mode.<\/p>\n<ul>\n<li>Every CAPTCHA type the .NET package handles: <a href=\"https:\/\/capskip.com\/csharp-captcha-solver\/\">the C# CAPTCHA solver page<\/a>.<\/li>\n<li>How the checkbox solver works: <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">the reCAPTCHA v2 solver page<\/a>.<\/li>\n<li>The plain SDK call in C#: <a href=\"https:\/\/capskip.com\/recaptcha-v2-csharp\/\">solving reCAPTCHA v2 in C#<\/a>.<\/li>\n<\/ul>\n<p>A shared service can serve a whole team without anyone counting solves. Running it in front of a <a href=\"https:\/\/capskip.com\/\">local captcha solver<\/a> on your own Windows machine keeps every solve free of a per-call bill.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u5728 CapSkip \u524d\u9762\u653e\u4e00\u4e2a\u5c0f\u578b ASP.NET Core \u670d\u52a1\u3002\u5b83\u6301\u6709\u552f\u4e00\u7684\u5ba2\u6237\u7aef\uff0c\u4e3a\u6bcf\u6b21\u6c42\u89e3\u8bbe\u7f6e\u622a\u6b62\u65f6\u95f4\uff0c\u9650\u5236\u540c\u65f6\u8fd0\u884c\u7684\u6c42\u89e3\u6570\u91cf\uff0c\u5e76\u628a SDK \u9519\u8bef\u8f6c\u6362\u6210\u6e05\u6670\u7684 HTTP \u72b6\u6001\u7801\u3002<\/p>","protected":false},"author":1,"featured_media":26713,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Minimal API CAPTCHA Solver in ASP.NET Core | CapSkip","rank_math_description":"Build a minimal API CAPTCHA solver in ASP.NET Core: one CapSkip client, a hard deadline, a slot limit and clean status codes. Run it on Windows today.","rank_math_focus_keyword":"minimal api captcha solver","footnotes":""},"categories":[70],"tags":[],"class_list":["post-26714","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26714","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=26714"}],"version-history":[{"count":4,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26714\/revisions"}],"predecessor-version":[{"id":26722,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/26714\/revisions\/26722"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/26713"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=26714"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=26714"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=26714"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}