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
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:
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:
# 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:
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:
# 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:
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:
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:
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:
# 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:
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.
pip install scrapy nonecap 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
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
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.
# 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 theurlfield of the solve, and submit the token back to that same page. Don’t solve against the site root. - Same cookies. Scrapy’s
CookiesMiddlewarekeeps one jar per spider by default, so the retry naturally shares the session that received the gate. If you shard sessions with thecookiejarmeta key, make sure the retry carries the same key;request.replace()preservesmeta, a hand-builtFormRequestdoes 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"]survivesreplace(); 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?
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?
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?
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?
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?
How do I handle enterprise hCaptcha (rqdata) in Scrapy?
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?
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.