Solve hCaptcha in Python.

The official Python SDK is one pip install nonecap away. Call nc.solve() with the sitekey and page URL, get back a real hCaptcha token. Sync or asyncio, fully typed, and signup comes with 1,300 free credits, so you can test the whole flow before paying anything. Crawling with Scrapy? The spider and middleware patterns are further down.

Where the solving actually happens

Not in your Python process. You send NoneCap a sitekey and a page URL; NoneCap solves the challenge on its own servers and returns a normal P1_ token as a string. There's no headless browser in your stack for hCaptcha to fingerprint and no challenge UI for you to automate. Your script's only jobs are reading the sitekey off the page and putting the token where the form expects it.

Quick start

Terminal
pip install nonecap

Python 3.9+. The only dependency is httpx, and the package ships py.typed, so mypy and pyright get full types with no stubs. Grab an API key from dashboard.nonecap.com and the headline is one call:

Python · sync
from nonecap import NoneCap

nc = NoneCap(api_key="nc_live_...")

# One call. solve() submits the captcha and long-polls
# until it's done, then returns the solved solve.
solve = nc.solve(
    type="hcaptcha",
    sitekey="10000000-ffff-ffff-ffff-000000000001",
    url="https://example.com/login",
)

print(solve.token)  # a real hCaptcha token, ready to submit

nc.solve() submits the captcha and long-polls until the solve is terminal: the server holds each request open for up to 90 seconds and the client keeps re-attaching until the token arrives or its own timeout (180 seconds by default) runs out. You don't write the polling loop.

When you want the loop yourself, the resource methods map one to one onto the REST API:

Python · lower-level
# Submit, holding the connection up to 30s, then poll the
# rest yourself. Drop wait= to return immediately instead.
pending = nc.solves.create(type="hcaptcha", sitekey=sitekey, url=url, wait=30)
done = nc.solves.retrieve(pending.id, wait=30)

# Cancel, list, or iterate every solve newest-first.
nc.solves.cancel(pending.id)
for s in nc.solves.list_all():
    print(s.id, s.status)

# Your account and credit balance.
print(nc.me().credits_balance)

Async with asyncio

AsyncNoneCap is the same surface, awaited. Use it as an async context manager so the underlying httpx client gets closed for you:

Python · asyncio
import asyncio
from nonecap import AsyncNoneCap

async def main():
    async with AsyncNoneCap(api_key="nc_live_...") as nc:
        solve = await nc.solve(
            type="hcaptcha",
            sitekey="10000000-ffff-ffff-ffff-000000000001",
            url="https://example.com/login",
        )
        print(solve.token)

asyncio.run(main())

Solving is I/O-bound on your side (the hard work happens on NoneCap's servers), so asyncio is the natural fit for batches. One thing to size correctly: concurrency is capped per account, and a naked asyncio.gather over a few hundred targets will blow straight past it. A semaphore fixes that:

Python · fan-out
# Fan out a batch with asyncio.gather. Keep in-flight solves
# under your concurrency cap (5 on the free trial, 25 Starter,
# 50 Builder and up, 100 Professional plan, 200 Enterprise)
# with a semaphore.
sem = asyncio.Semaphore(5)

async def solve_one(nc, sitekey, url):
    async with sem:
        return await nc.solve(type="hcaptcha", sitekey=sitekey, url=url)

async with AsyncNoneCap(api_key="nc_live_...") as nc:
    solves = await asyncio.gather(
        *(solve_one(nc, sk, u) for sk, u in targets)
    )

Errors you can branch on

Everything the SDK raises extends NoneCapError, so you can catch the whole family or pick out the case you care about: SolveFailedError (with the full solve attached, including the underlying error code and timings), SolveTimeoutError, RateLimitError, InsufficientCreditsError, ValidationError (which carries the offending param), and AuthenticationError:

Python · error handling
from nonecap import (
    NoneCap,
    SolveFailedError,
    SolveTimeoutError,
    RateLimitError,
    InsufficientCreditsError,
)

nc = NoneCap(api_key="nc_live_...")

try:
    solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=url)
except SolveFailedError as e:
    # The full solve is attached: error code, timings, all of it.
    print("could not solve it:", e.solve.error.code)
except SolveTimeoutError:
    print("still pending after the client timeout (default 180s)")
except RateLimitError:
    print("too many solves in flight, back off and retry")
except InsufficientCreditsError:
    print("out of credits, top up at dashboard.nonecap.com")

Failed solves are never charged. The credits come back automatically, so a SolveFailedError costs you a retry, not money.

No SDK? Plain requests works too

The API is ordinary REST, so if you'd rather not add a dependency, twenty lines of requests does the same job. ?wait=90 makes the server hold the connection until the solve reaches a terminal state, which means the token usually comes back on the first response:

Python · requests, no SDK
import requests

API = "https://api.nonecap.com/v1"
HEADERS = {"Authorization": "Bearer " + NONECAP_KEY}

r = requests.post(
    f"{API}/solves?wait=90",
    headers=HEADERS,
    json={"type": "hcaptcha", "sitekey": sitekey, "url": url},
    timeout=120,
)
data = r.json()

# wait=90 holds the connection until the solve is terminal, so the
# token usually comes back inline. A 202 means it's still in flight
# (pending or solving): keep polling GET /v1/solves/{id} until it isn't.
while data["status"] in ("pending", "solving"):
    r = requests.get(
        f"{API}/solves/" + data["id"] + "?wait=90",
        headers=HEADERS,
        timeout=120,
    )
    data = r.json()

if data["status"] != "solved":
    raise RuntimeError("solve " + data["status"] + ": " + str(data.get("error")))

token = data["token"]  # a real P1_... token

The same code works with httpx if that's already in your project. Full request and response shapes are in the API reference.

Enterprise rqdata

Enterprise hCaptcha binds every challenge to a fresh, IP-bound rqdata blob that the page passes into hcaptcha.render() or execute(). When the page has one, capture it from the live page and forward it with the enterprise type; when it has none, leave rqdata out. The blob goes stale fast, so capture it right before you solve:

Python · enterprise
solve = nc.solve(
    type="hcaptcha_enterprise",
    sitekey=sitekey,
    url=url,
    rqdata=rqdata,  # optional: the fresh blob, when the site issues one
)

You have a token. Now what?

Two paths, depending on whether there's a browser in your stack.

Driving a browser? Set the value of textarea[name="h-captcha-response"] on the page, plus the g-recaptcha-response compatibility field if it exists (hCaptcha reuses reCAPTCHA's old field name; the value is still the hCaptcha token), then fire the widget callback for invisible sitekeys and submit. The Selenium guide and the Playwright guide cover the injection details for each driver, so this page won't repeat them.

Pure HTTP? Even simpler. Include the token as a form field in the POST you were going to send anyway:

Python · submit the token
# Pure-HTTP flow: put the token in the form POST you were
# going to send anyway. The g-recaptcha-response field is
# hCaptcha's compatibility name, same token in both.
requests.post(
    "https://target.example/login",
    data={
        "email": email,
        "password": password,
        "h-captcha-response": token,
        "g-recaptcha-response": token,
    },
)

One rule either way: don't hard-code the sitekey. Sites rotate them, and enterprise deployments bind challenges to the page. Read it fresh from the live page, either the [data-sitekey] attribute on the widget or the sitekey query param on the hcaptcha.com/1/api.js script tag.

Solving hCaptcha in Scrapy

Scrapy has no captcha story of its own: when a target throws an hCaptcha interstitial, your spider just parses gate pages. The fix is small. Read data-sitekey off the gated response, solve it with the SDK, and resend the request with the returned token as h-captcha-response. There are two ways to wire that in: directly in a spider callback, and as a reusable downloader middleware.

Recognizing the gate from a callback

An hCaptcha interstitial is easy to fingerprint in the HTML Scrapy already gives you: a div with class h-captcha carrying a data-sitekey attribute (something like f5ab1c2d-7e8f-4a9b-b1c2-d3e4f5a6b7c8), plus a script tag loading hcaptcha.com/1/api.js. Some sites render the widget without the class, so fall back to any element with data-sitekey:

Detection helper
def hcaptcha_sitekey(response):
    """Return the sitekey if this response is an hCaptcha gate, else None."""
    if b"hcaptcha" not in response.body:
        return None  # cheap pre-check before touching the selector engine
    return (
        response.css(".h-captcha::attr(data-sitekey)").get()
        or response.xpath("//*[@data-sitekey]/@data-sitekey").get()
    )

Two Scrapy-specific traps. First, gates often ship with a 403 or 503 status, and the HttpError spider middleware silently drops non-2xx responses, so your callback never sees them; set handle_httpstatus_list on the spider (or the handle_httpstatus_list key in Request.meta) to let them through. Second, read the sitekey fresh from each gated response, as above. The sitekey guide covers the places it hides.

The straightforward pattern: solve it in the spider

Scrapy callbacks are plain sync functions, which makes the sync client a natural fit: detect the gate, call nc.solve() with the sitekey and the page URL, and yield a FormRequest that carries the token. NoneCap holds the connection until the solve is terminal, so there is no polling loop to write.

Terminal
pip install scrapy nonecap
listings_spider.py
import os

import scrapy
from nonecap import NoneCap

nc = NoneCap(api_key=os.environ["NONECAP_KEY"])


class ListingsSpider(scrapy.Spider):
    name = "listings"
    start_urls = ["https://target.example/listings"]
    # Gates often arrive with a 403 status. Without this, the HttpError
    # spider middleware drops the response before parse() ever runs.
    handle_httpstatus_list = [403]

    def parse(self, response):
        sitekey = response.css(".h-captcha::attr(data-sitekey)").get()
        if sitekey is None:
            yield from self.parse_listings(response)  # no gate, business as usual
            return

        # Sync call: the crawl pauses here until the token arrives.
        # Fine for a single-session crawl; the middleware below scales.
        solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=response.url)

        yield scrapy.FormRequest(
            response.url,
            formdata={
                "h-captcha-response": solve.token,
                # hCaptcha's reCAPTCHA-compat field name, same token
                "g-recaptcha-response": solve.token,
            },
            callback=self.parse,
            dont_filter=True,  # same URL again, skip the dupefilter
        )

    def parse_listings(self, response):
        for row in response.css(".listing"):
            yield {
                "title": row.css("h3::text").get(),
                "price": row.css(".price::text").get(),
            }

The retry yields back into parse(), which now sees the unlocked page and falls through to the real parsing. dont_filter=True matters: the retry targets a URL the dupefilter has already seen.

Two caveats. nc.solve() is a blocking call, and Scrapy runs everything in one thread, so the whole crawl pauses while it waits; for a single-session spider that is usually fine, and the middleware below removes the stall. And Scrapy 2.16 (May 2026) deprecated scrapy.FormRequest in favour of the form2request package. It still works with a warning, and the no-dependency equivalent is a plain scrapy.Request with method="POST", a urlencoded body, and a Content-Type: application/x-www-form-urlencoded header, which is exactly what the middleware below builds.

The reusable pattern: a downloader middleware

Once two spiders need the same gate handling, move it into a downloader middleware. process_response() inspects every response on its way back to the spider; when it spots a gate it solves, then returns a new Request, which Scrapy reschedules through the full middleware chain while the spider stays captcha-blind. Since Scrapy 2.13 the asyncio reactor is the default, and downloader middleware methods can be coroutines, so the async client fits without blocking anything:

middlewares.py
# middlewares.py
import logging
from urllib.parse import urlencode

from scrapy.exceptions import NotConfigured
from scrapy.http import TextResponse
from nonecap import AsyncNoneCap

logger = logging.getLogger(__name__)


class HcaptchaSolverMiddleware:
    """Detect hCaptcha gates and retry the request with a real token."""

    def __init__(self, crawler):
        api_key = crawler.settings.get("NONECAP_API_KEY")
        if not api_key:
            raise NotConfigured("NONECAP_API_KEY is not set")
        self.crawler = crawler
        self.nc = AsyncNoneCap(api_key=api_key)
        self.max_solves = crawler.settings.getint("NONECAP_MAX_SOLVES", 2)

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)

    # Scrapy 2.13 and earlier also pass a trailing `spider` argument;
    # add it to the signature there. 2.14+ deprecated it.
    async def process_response(self, request, response):
        if not isinstance(response, TextResponse):
            return response  # binary body, nothing to inspect

        sitekey = response.css(".h-captcha::attr(data-sitekey)").get()
        if sitekey is None:
            return response  # not gated, pass straight through

        solves = request.meta.get("nonecap_solves", 0)
        if solves >= self.max_solves:
            logger.warning("still gated after %d solves: %s", solves, response.url)
            return response  # give up, let the spider decide

        solve = await self.nc.solve(
            type="hcaptcha", sitekey=sitekey, url=response.url
        )

        retry = request.replace(
            method="POST",
            body=urlencode({
                "h-captcha-response": solve.token,
                "g-recaptcha-response": solve.token,
            }),
            dont_filter=True,  # retries must survive the dupefilter
        )
        retry.headers["Content-Type"] = "application/x-www-form-urlencoded"
        retry.meta["nonecap_solves"] = solves + 1
        return retry  # the engine reschedules it; callback stays the same
settings.py
# settings.py
import os

NONECAP_API_KEY = os.environ["NONECAP_KEY"]

DOWNLOADER_MIDDLEWARES = {
    # 560 > RetryMiddleware's 550: process_response() runs in
    # DECREASING priority order, so this middleware sees a gated 503
    # before RetryMiddleware burns its retries on it.
    "myproject.middlewares.HcaptchaSolverMiddleware": 560,
}

# Default since Scrapy 2.13. Set it explicitly on older versions so
# async middleware methods and async callbacks run on asyncio.
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"

The priority number is doing real work. Scrapy calls process_response() in decreasing priority order, and the built-in RetryMiddleware sits at 550 with 503 in its default RETRY_HTTP_CODES. Registered at 560, the solver sees a 503-wrapped gate first and answers it with a token; left at a typical 543, the gate would eat RETRY_TIMES blind retries before your code ever ran. The nonecap_solves counter in meta caps how many tokens a stubborn URL can burn, and request.replace() keeps the original callback, cb_kwargs, and meta on the retry.

The blocking-call tradeoff

Scrapy is built on Twisted, and the reactor runs in a single thread, so a blocking HTTP call inside a plain def process_response() halts the whole process: every in-flight download and every scheduled callback waits for the solve. The middleware above avoids that by declaring the method async def and awaiting AsyncNoneCap. If you want to keep sync code, push the blocking call onto a thread instead. Both need the asyncio reactor, the default since Scrapy 2.13, and neither is helped by raising CONCURRENT_REQUESTS: concurrency settings cannot help a reactor that is not running.

middlewares.py · sync client variant
# Stuck with the sync client (or plain requests)? Keep the method
# async and push the blocking call onto a thread, so the reactor
# keeps scheduling every other download while the solve runs.
import asyncio

async def process_response(self, request, response):
    ...
    solve = await asyncio.to_thread(
        self.nc.solve, type="hcaptcha", sitekey=sitekey, url=response.url
    )

Keep the session consistent

A token is accepted in context. Three rules keep that context intact:

  • Same URL. Pass the gated page’s own URL (response.url) as the url field of the solve, and submit the token back to that same page. Don’t solve against the site root.
  • Same cookies. Scrapy’s CookiesMiddleware keeps one jar per spider by default, so the retry naturally shares the session that received the gate. If you shard sessions with the cookiejar meta key, make sure the retry carries the same key; request.replace() preserves meta, a hand-built FormRequest does not unless you pass it.
  • Same IP. If a rotating-proxy middleware assigns a fresh exit per request, pin the retry to the proxy that fetched the gate (request.meta["proxy"] survives replace(); check that your rotation middleware doesn’t overwrite it on the rescheduled pass).

Timing matters too: the token is single-use and valid for roughly 120 seconds, so solve on demand when a gate appears. Don’t mint tokens ahead of time and queue them.

When this isn't the right approach

NoneCap solves hCaptcha only: regular, invisible, and enterprise rqdata. If the page is actually protected by reCAPTCHA, Cloudflare Turnstile, or FunCaptcha, this technique doesn't apply; those are on the roadmap, not live. And if your target has no captcha at all, skip the token step and POST the form directly.

Pairing the solve with a browser? See hCaptcha in Selenium or hCaptcha in Playwright for the injection side. Running a no-browser pipeline at scale, read the web scraping guide; building autonomous tools, the AI agents guide. And if your project is TypeScript rather than Python, the same client exists for Node: npm install nonecap.

Last updated October 2026.

Frequently asked

Does the SDK have real type hints?
Yes. The package ships py.typed with full inline types, so mypy, pyright, and your IDE pick them up with zero config. The types do actual work: a misspelled type or a missing sitekey fails the type check before you ever burn a credit.
Can I solve captchas concurrently with asyncio?
Yes. AsyncNoneCap has the same surface as the sync client, awaited, so asyncio.gather fan-out works the way you would expect. Concurrency is capped per account (5 on the free trial, 25 on Starter, 50 on Builder and up, 100 on the Professional plan, 200 on Enterprise), so wrap the calls in an asyncio.Semaphore sized under your cap. If you push past it you will see RateLimitError: back off and retry.
Does this work with plain requests or httpx, without a browser?
Yes. The token is just a string. NoneCap solves the challenge on its own servers, so you never need a browser at all: include the returned token as h-captcha-response (or g-recaptcha-response) in the form POST your script was already sending. The web scraping guide walks through the full no-browser pipeline.
What Python versions and dependencies does it need?
Python 3.9 or newer. The only dependency is httpx, which also powers the async client. The SDK is open source at github.com/nonecap/nonecap-py and published on PyPI as nonecap.
How much does each solve cost?
At least one credit per solve. Billing starts at one credit per hCaptcha challenge round (hCaptcha decides how many: often one, sometimes two or three), charged only on success, so failed solves are never charged. Credits run $0.20 to $0.50 per 1,000 depending on your pack, you get 1,300 free credits on signup, and no subscription is required. See pricing.
How do I handle enterprise hCaptcha (rqdata) in Scrapy?
Enterprise deployments bind each challenge to a fresh rqdata blob the page passes into hcaptcha.render() or execute(). Scrape that value out of the gated response or the request that configures the widget, then call nc.solve(type="hcaptcha_enterprise", sitekey=..., url=..., rqdata=...). The blob goes stale fast, so capture it right before you solve. Details in the rqdata guide.
How many solves can a crawl keep in flight?
As many as your account’s concurrency cap, the same cap described in the asyncio answer above. Only gated responses trigger a solve, so most crawls sit far under the cap even with CONCURRENT_REQUESTS at its default of 16. If a heavily gated target pushes you past it, the API returns a rate-limit error: catch RateLimitError, back off, and retry, or lower CONCURRENT_REQUESTS for that spider.

Start solving hCaptcha in minutes.

1,300 free credits on signup. Pay per solve, credits never expire, failed solves never charged.